mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-29 13:53:54 +01:00
Merge branch 'main' into achievement_revamp
This commit is contained in:
commit
28ba71ec5d
63 changed files with 2468 additions and 2675 deletions
|
|
@ -52,12 +52,12 @@ class CampaignsPresenter @Inject constructor(
|
|||
return
|
||||
}
|
||||
|
||||
okHttpJsonApiClient.campaigns
|
||||
okHttpJsonApiClient.getCampaigns()
|
||||
.observeOn(mainThreadScheduler)
|
||||
.subscribeOn(ioScheduler)
|
||||
.doOnSubscribe { disposable = it }
|
||||
.subscribe({ campaignResponseDTO ->
|
||||
val campaigns = campaignResponseDTO.campaigns?.toMutableList()
|
||||
val campaigns = campaignResponseDTO?.campaigns?.toMutableList()
|
||||
if (campaigns.isNullOrEmpty()) {
|
||||
Timber.e("The campaigns list is empty")
|
||||
view!!.showCampaigns(null)
|
||||
|
|
|
|||
|
|
@ -170,14 +170,13 @@ class NetworkingModule {
|
|||
@Named(NAMED_WIKI_DATA_WIKI_SITE)
|
||||
fun provideWikidataWikiSite(): WikiSite = WikiSite(BuildConfig.WIKIDATA_URL)
|
||||
|
||||
|
||||
/**
|
||||
* Gson objects are very heavy. The app should ideally be using just one instance of it instead of creating new instances everywhere.
|
||||
* @return returns a singleton Gson instance
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGson(): Gson = GsonUtil.getDefaultGson()
|
||||
fun provideGson(): Gson = GsonUtil.defaultGson
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
package fr.free.nrw.commons.filepicker;
|
||||
|
||||
public interface Constants {
|
||||
String DEFAULT_FOLDER_NAME = "CommonsContributions";
|
||||
|
||||
/**
|
||||
* Provides the request codes for permission handling
|
||||
*/
|
||||
interface RequestCodes {
|
||||
int LOCATION = 1;
|
||||
int STORAGE = 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides locations as string for corresponding operations
|
||||
*/
|
||||
interface BundleKeys {
|
||||
String FOLDER_NAME = "fr.free.nrw.commons.folder_name";
|
||||
String ALLOW_MULTIPLE = "fr.free.nrw.commons.allow_multiple";
|
||||
String COPY_TAKEN_PHOTOS = "fr.free.nrw.commons.copy_taken_photos";
|
||||
String COPY_PICKED_IMAGES = "fr.free.nrw.commons.copy_picked_images";
|
||||
}
|
||||
}
|
||||
29
app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt
Normal file
29
app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package fr.free.nrw.commons.filepicker
|
||||
|
||||
interface Constants {
|
||||
companion object {
|
||||
const val DEFAULT_FOLDER_NAME = "CommonsContributions"
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the request codes for permission handling
|
||||
*/
|
||||
interface RequestCodes {
|
||||
companion object {
|
||||
const val LOCATION = 1
|
||||
const val STORAGE = 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides locations as string for corresponding operations
|
||||
*/
|
||||
interface BundleKeys {
|
||||
companion object {
|
||||
const val FOLDER_NAME = "fr.free.nrw.commons.folder_name"
|
||||
const val ALLOW_MULTIPLE = "fr.free.nrw.commons.allow_multiple"
|
||||
const val COPY_TAKEN_PHOTOS = "fr.free.nrw.commons.copy_taken_photos"
|
||||
const val COPY_PICKED_IMAGES = "fr.free.nrw.commons.copy_picked_images"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
package fr.free.nrw.commons.filepicker;
|
||||
|
||||
/**
|
||||
* Provides abstract methods which are overridden while handling Contribution Results
|
||||
* inside the ContributionsController
|
||||
*/
|
||||
public abstract class DefaultCallback implements FilePicker.Callbacks {
|
||||
|
||||
@Override
|
||||
public void onImagePickerError(Exception e, FilePicker.ImageSource source, int type) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCanceled(FilePicker.ImageSource source, int type) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package fr.free.nrw.commons.filepicker
|
||||
|
||||
/**
|
||||
* Provides abstract methods which are overridden while handling Contribution Results
|
||||
* inside the ContributionsController
|
||||
*/
|
||||
abstract class DefaultCallback: FilePicker.Callbacks {
|
||||
|
||||
override fun onImagePickerError(e: Exception, source: FilePicker.ImageSource, type: Int) {}
|
||||
|
||||
override fun onCanceled(source: FilePicker.ImageSource, type: Int) {}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package fr.free.nrw.commons.filepicker;
|
||||
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
public class ExtendedFileProvider extends FileProvider {
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package fr.free.nrw.commons.filepicker
|
||||
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
class ExtendedFileProvider: FileProvider() {}
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
package fr.free.nrw.commons.filepicker;
|
||||
|
||||
import static fr.free.nrw.commons.filepicker.PickedFiles.singleFileList;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ClipData;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ResolveInfo;
|
||||
import android.net.Uri;
|
||||
import android.provider.MediaStore;
|
||||
import android.text.TextUtils;
|
||||
import androidx.activity.result.ActivityResult;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import fr.free.nrw.commons.customselector.model.Image;
|
||||
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class FilePicker implements Constants {
|
||||
|
||||
private static final String KEY_PHOTO_URI = "photo_uri";
|
||||
private static final String KEY_VIDEO_URI = "video_uri";
|
||||
private static final String KEY_LAST_CAMERA_PHOTO = "last_photo";
|
||||
private static final String KEY_LAST_CAMERA_VIDEO = "last_video";
|
||||
private static final String KEY_TYPE = "type";
|
||||
|
||||
/**
|
||||
* Returns the uri of the clicked image so that it can be put in MediaStore
|
||||
*/
|
||||
private static Uri createCameraPictureFile(@NonNull Context context) throws IOException {
|
||||
File imagePath = PickedFiles.getCameraPicturesLocation(context);
|
||||
Uri uri = PickedFiles.getUriToFile(context, imagePath);
|
||||
SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(context).edit();
|
||||
editor.putString(KEY_PHOTO_URI, uri.toString());
|
||||
editor.putString(KEY_LAST_CAMERA_PHOTO, imagePath.toString());
|
||||
editor.apply();
|
||||
return uri;
|
||||
}
|
||||
|
||||
private static Intent createGalleryIntent(@NonNull Context context, int type,
|
||||
boolean openDocumentIntentPreferred) {
|
||||
// storing picked image type to shared preferences
|
||||
storeType(context, type);
|
||||
//Supported types are SVG, PNG and JPEG,GIF, TIFF, WebP, XCF
|
||||
final String[] mimeTypes = { "image/jpg","image/png","image/jpeg", "image/gif", "image/tiff", "image/webp", "image/xcf", "image/svg+xml", "image/webp"};
|
||||
return plainGalleryPickerIntent(openDocumentIntentPreferred)
|
||||
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, configuration(context).allowsMultiplePickingInGallery())
|
||||
.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateCustomSectorIntent, creates intent for custom selector activity.
|
||||
* @param context
|
||||
* @param type
|
||||
* @return Custom selector intent
|
||||
*/
|
||||
private static Intent createCustomSelectorIntent(@NonNull Context context, int type) {
|
||||
storeType(context, type);
|
||||
return new Intent(context, CustomSelectorActivity.class);
|
||||
}
|
||||
|
||||
private static Intent createCameraForImageIntent(@NonNull Context context, int type) {
|
||||
storeType(context, type);
|
||||
|
||||
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
|
||||
try {
|
||||
Uri capturedImageUri = createCameraPictureFile(context);
|
||||
//We have to explicitly grant the write permission since Intent.setFlag works only on API Level >=20
|
||||
grantWritePermission(context, intent, capturedImageUri);
|
||||
intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
private static void revokeWritePermission(@NonNull Context context, Uri uri) {
|
||||
context.revokeUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
}
|
||||
|
||||
private static void grantWritePermission(@NonNull Context context, Intent intent, Uri uri) {
|
||||
List<ResolveInfo> resInfoList = context.getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
|
||||
for (ResolveInfo resolveInfo : resInfoList) {
|
||||
String packageName = resolveInfo.activityInfo.packageName;
|
||||
context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
}
|
||||
}
|
||||
|
||||
private static void storeType(@NonNull Context context, int type) {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(KEY_TYPE, type).apply();
|
||||
}
|
||||
|
||||
private static int restoreType(@NonNull Context context) {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getInt(KEY_TYPE, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens default galery or a available galleries picker if there is no default
|
||||
*
|
||||
* @param type Custom type of your choice, which will be returned with the images
|
||||
*/
|
||||
public static void openGallery(Activity activity, ActivityResultLauncher<Intent> resultLauncher, int type, boolean openDocumentIntentPreferred) {
|
||||
Intent intent = createGalleryIntent(activity, type, openDocumentIntentPreferred);
|
||||
resultLauncher.launch(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens Custom Selector
|
||||
*/
|
||||
public static void openCustomSelector(Activity activity, ActivityResultLauncher<Intent> resultLauncher, int type) {
|
||||
Intent intent = createCustomSelectorIntent(activity, type);
|
||||
resultLauncher.launch(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the camera app to pick image clicked by user
|
||||
*/
|
||||
public static void openCameraForImage(Activity activity, ActivityResultLauncher<Intent> resultLauncher, int type) {
|
||||
Intent intent = createCameraForImageIntent(activity, type);
|
||||
resultLauncher.launch(intent);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static UploadableFile takenCameraPicture(Context context) throws URISyntaxException {
|
||||
String lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_LAST_CAMERA_PHOTO, null);
|
||||
if (lastCameraPhoto != null) {
|
||||
return new UploadableFile(new File(lastCameraPhoto));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static UploadableFile takenCameraVideo(Context context) throws URISyntaxException {
|
||||
String lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_LAST_CAMERA_VIDEO, null);
|
||||
if (lastCameraPhoto != null) {
|
||||
return new UploadableFile(new File(lastCameraPhoto));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<UploadableFile> handleExternalImagesPicked(Intent data, Activity activity) {
|
||||
try {
|
||||
return getFilesFromGalleryPictures(data, activity);
|
||||
} catch (IOException | SecurityException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
private static boolean isPhoto(Intent data) {
|
||||
return data == null || (data.getData() == null && data.getClipData() == null);
|
||||
}
|
||||
|
||||
private static Intent plainGalleryPickerIntent(boolean openDocumentIntentPreferred) {
|
||||
/*
|
||||
* Asking for ACCESS_MEDIA_LOCATION at runtime solved the location-loss issue
|
||||
* in the custom selector in Contributions fragment.
|
||||
* Detailed discussion: https://github.com/commons-app/apps-android-commons/issues/5015
|
||||
*
|
||||
* This permission check, however, was insufficient to fix location-loss in
|
||||
* the regular selector in Contributions fragment and Nearby fragment,
|
||||
* especially on some devices running Android 13 that use the new Photo Picker by default.
|
||||
*
|
||||
* New Photo Picker: https://developer.android.com/training/data-storage/shared/photopicker
|
||||
*
|
||||
* The new Photo Picker introduced by Android redacts location tags from EXIF metadata.
|
||||
* Reported on the Google Issue Tracker: https://issuetracker.google.com/issues/243294058
|
||||
* Status: Won't fix (Intended behaviour)
|
||||
*
|
||||
* Switched intent from ACTION_GET_CONTENT to ACTION_OPEN_DOCUMENT (by default; can
|
||||
* be changed through the Setting page) as:
|
||||
*
|
||||
* ACTION_GET_CONTENT opens the 'best application' for choosing that kind of data
|
||||
* The best application is the new Photo Picker that redacts the location tags
|
||||
*
|
||||
* ACTION_OPEN_DOCUMENT, however, displays the various DocumentsProvider instances
|
||||
* installed on the device, letting the user interactively navigate through them.
|
||||
*
|
||||
* So, this allows us to use the traditional file picker that does not redact location tags
|
||||
* from EXIF.
|
||||
*
|
||||
*/
|
||||
Intent intent;
|
||||
if (openDocumentIntentPreferred) {
|
||||
intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
} else {
|
||||
intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
}
|
||||
intent.setType("image/*");
|
||||
return intent;
|
||||
}
|
||||
|
||||
public static void onPictureReturnedFromDocuments(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
|
||||
if(result.getResultCode() == Activity.RESULT_OK && !isPhoto(result.getData())){
|
||||
try {
|
||||
Uri photoPath = result.getData().getData();
|
||||
UploadableFile photoFile = PickedFiles.pickedExistingPicture(activity, photoPath);
|
||||
callbacks.onImagesPicked(singleFileList(photoFile), FilePicker.ImageSource.DOCUMENTS, restoreType(activity));
|
||||
|
||||
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
|
||||
PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
callbacks.onImagePickerError(e, FilePicker.ImageSource.DOCUMENTS, restoreType(activity));
|
||||
}
|
||||
} else {
|
||||
callbacks.onCanceled(FilePicker.ImageSource.DOCUMENTS, restoreType(activity));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* onPictureReturnedFromCustomSelector.
|
||||
* Retrieve and forward the images to upload wizard through callback.
|
||||
*/
|
||||
public static void onPictureReturnedFromCustomSelector(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
|
||||
if(result.getResultCode() == Activity.RESULT_OK){
|
||||
try {
|
||||
List<UploadableFile> files = getFilesFromCustomSelector(result.getData(), activity);
|
||||
callbacks.onImagesPicked(files, ImageSource.CUSTOM_SELECTOR, restoreType(activity));
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
callbacks.onImagePickerError(e, ImageSource.CUSTOM_SELECTOR, restoreType(activity));
|
||||
}
|
||||
} else {
|
||||
callbacks.onCanceled(ImageSource.CUSTOM_SELECTOR, restoreType(activity));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files from custom selector
|
||||
* Retrieve and process the selected images from the custom selector.
|
||||
*/
|
||||
private static List<UploadableFile> getFilesFromCustomSelector(Intent data, Activity activity) throws IOException, SecurityException {
|
||||
List<UploadableFile> files = new ArrayList<>();
|
||||
ArrayList<Image> images = data.getParcelableArrayListExtra("Images");
|
||||
for(Image image : images) {
|
||||
Uri uri = image.getUri();
|
||||
UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri);
|
||||
files.add(file);
|
||||
}
|
||||
|
||||
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
|
||||
PickedFiles.copyFilesInSeparateThread(activity, files);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
public static void onPictureReturnedFromGallery(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
|
||||
if(result.getResultCode() == Activity.RESULT_OK && !isPhoto(result.getData())){
|
||||
try {
|
||||
List<UploadableFile> files = getFilesFromGalleryPictures(result.getData(), activity);
|
||||
callbacks.onImagesPicked(files, FilePicker.ImageSource.GALLERY, restoreType(activity));
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
callbacks.onImagePickerError(e, FilePicker.ImageSource.GALLERY, restoreType(activity));
|
||||
}
|
||||
} else{
|
||||
callbacks.onCanceled(FilePicker.ImageSource.GALLERY, restoreType(activity));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<UploadableFile> getFilesFromGalleryPictures(Intent data, Activity activity) throws IOException, SecurityException {
|
||||
List<UploadableFile> files = new ArrayList<>();
|
||||
ClipData clipData = data.getClipData();
|
||||
if (clipData == null) {
|
||||
Uri uri = data.getData();
|
||||
UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri);
|
||||
files.add(file);
|
||||
} else {
|
||||
for (int i = 0; i < clipData.getItemCount(); i++) {
|
||||
Uri uri = clipData.getItemAt(i).getUri();
|
||||
UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri);
|
||||
files.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
|
||||
PickedFiles.copyFilesInSeparateThread(activity, files);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
public static void onPictureReturnedFromCamera(ActivityResult activityResult, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
|
||||
if(activityResult.getResultCode() == Activity.RESULT_OK){
|
||||
try {
|
||||
String lastImageUri = PreferenceManager.getDefaultSharedPreferences(activity).getString(KEY_PHOTO_URI, null);
|
||||
if (!TextUtils.isEmpty(lastImageUri)) {
|
||||
revokeWritePermission(activity, Uri.parse(lastImageUri));
|
||||
}
|
||||
|
||||
UploadableFile photoFile = FilePicker.takenCameraPicture(activity);
|
||||
List<UploadableFile> files = new ArrayList<>();
|
||||
files.add(photoFile);
|
||||
|
||||
if (photoFile == null) {
|
||||
Exception e = new IllegalStateException("Unable to get the picture returned from camera");
|
||||
callbacks.onImagePickerError(e, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity));
|
||||
} else {
|
||||
if (configuration(activity).shouldCopyTakenPhotosToPublicGalleryAppFolder()) {
|
||||
PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile));
|
||||
}
|
||||
|
||||
callbacks.onImagesPicked(files, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity));
|
||||
}
|
||||
|
||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
.edit()
|
||||
.remove(KEY_LAST_CAMERA_PHOTO)
|
||||
.remove(KEY_PHOTO_URI)
|
||||
.apply();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
callbacks.onImagePickerError(e, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity));
|
||||
}
|
||||
} else {
|
||||
callbacks.onCanceled(FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity));
|
||||
}
|
||||
}
|
||||
|
||||
public static FilePickerConfiguration configuration(@NonNull Context context) {
|
||||
return new FilePickerConfiguration(context);
|
||||
}
|
||||
|
||||
|
||||
public enum ImageSource {
|
||||
GALLERY, DOCUMENTS, CAMERA_IMAGE, CAMERA_VIDEO, CUSTOM_SELECTOR
|
||||
}
|
||||
|
||||
public interface Callbacks {
|
||||
void onImagePickerError(Exception e, FilePicker.ImageSource source, int type);
|
||||
|
||||
void onImagesPicked(@NonNull List<UploadableFile> imageFiles, FilePicker.ImageSource source, int type);
|
||||
|
||||
void onCanceled(FilePicker.ImageSource source, int type);
|
||||
}
|
||||
|
||||
public interface HandleActivityResult{
|
||||
void onHandleActivityResult(FilePicker.Callbacks callbacks);
|
||||
}
|
||||
}
|
||||
441
app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt
Normal file
441
app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
package fr.free.nrw.commons.filepicker
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.preference.PreferenceManager
|
||||
import fr.free.nrw.commons.customselector.model.Image
|
||||
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity
|
||||
import fr.free.nrw.commons.filepicker.PickedFiles.singleFileList
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.URISyntaxException
|
||||
|
||||
|
||||
object FilePicker : Constants {
|
||||
|
||||
private const val KEY_PHOTO_URI = "photo_uri"
|
||||
private const val KEY_VIDEO_URI = "video_uri"
|
||||
private const val KEY_LAST_CAMERA_PHOTO = "last_photo"
|
||||
private const val KEY_LAST_CAMERA_VIDEO = "last_video"
|
||||
private const val KEY_TYPE = "type"
|
||||
|
||||
/**
|
||||
* Returns the uri of the clicked image so that it can be put in MediaStore
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
@JvmStatic
|
||||
private fun createCameraPictureFile(context: Context): Uri {
|
||||
val imagePath = PickedFiles.getCameraPicturesLocation(context)
|
||||
val uri = PickedFiles.getUriToFile(context, imagePath)
|
||||
val editor = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
editor.putString(KEY_PHOTO_URI, uri.toString())
|
||||
editor.putString(KEY_LAST_CAMERA_PHOTO, imagePath.toString())
|
||||
editor.apply()
|
||||
return uri
|
||||
}
|
||||
|
||||
|
||||
@JvmStatic
|
||||
private fun createGalleryIntent(
|
||||
context: Context,
|
||||
type: Int,
|
||||
openDocumentIntentPreferred: Boolean
|
||||
): Intent {
|
||||
// storing picked image type to shared preferences
|
||||
storeType(context, type)
|
||||
// Supported types are SVG, PNG and JPEG, GIF, TIFF, WebP, XCF
|
||||
val mimeTypes = arrayOf(
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/tiff",
|
||||
"image/webp",
|
||||
"image/xcf",
|
||||
"image/svg+xml",
|
||||
"image/webp"
|
||||
)
|
||||
return plainGalleryPickerIntent(openDocumentIntentPreferred)
|
||||
.putExtra(
|
||||
Intent.EXTRA_ALLOW_MULTIPLE,
|
||||
configuration(context).allowsMultiplePickingInGallery()
|
||||
)
|
||||
.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateCustomSectorIntent, creates intent for custom selector activity.
|
||||
* @param context
|
||||
* @param type
|
||||
* @return Custom selector intent
|
||||
*/
|
||||
@JvmStatic
|
||||
private fun createCustomSelectorIntent(context: Context, type: Int): Intent {
|
||||
storeType(context, type)
|
||||
return Intent(context, CustomSelectorActivity::class.java)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun createCameraForImageIntent(context: Context, type: Int): Intent {
|
||||
storeType(context, type)
|
||||
|
||||
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
try {
|
||||
val capturedImageUri = createCameraPictureFile(context)
|
||||
// We have to explicitly grant the write permission since Intent.setFlag works only on API Level >=20
|
||||
grantWritePermission(context, intent, capturedImageUri)
|
||||
intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return intent
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun revokeWritePermission(context: Context, uri: Uri) {
|
||||
context.revokeUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun grantWritePermission(context: Context, intent: Intent, uri: Uri) {
|
||||
val resInfoList =
|
||||
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
for (resolveInfo in resInfoList) {
|
||||
val packageName = resolveInfo.activityInfo.packageName
|
||||
context.grantUriPermission(
|
||||
packageName,
|
||||
uri,
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun storeType(context: Context, type: Int) {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(KEY_TYPE, type).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun restoreType(context: Context): Int {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getInt(KEY_TYPE, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens default gallery or available galleries picker if there is no default
|
||||
*
|
||||
* @param type Custom type of your choice, which will be returned with the images
|
||||
*/
|
||||
@JvmStatic
|
||||
fun openGallery(
|
||||
activity: Activity,
|
||||
resultLauncher: ActivityResultLauncher<Intent>,
|
||||
type: Int,
|
||||
openDocumentIntentPreferred: Boolean
|
||||
) {
|
||||
val intent = createGalleryIntent(activity, type, openDocumentIntentPreferred)
|
||||
resultLauncher.launch(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens Custom Selector
|
||||
*/
|
||||
@JvmStatic
|
||||
fun openCustomSelector(
|
||||
activity: Activity,
|
||||
resultLauncher: ActivityResultLauncher<Intent>,
|
||||
type: Int
|
||||
) {
|
||||
val intent = createCustomSelectorIntent(activity, type)
|
||||
resultLauncher.launch(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the camera app to pick image clicked by user
|
||||
*/
|
||||
@JvmStatic
|
||||
fun openCameraForImage(
|
||||
activity: Activity,
|
||||
resultLauncher: ActivityResultLauncher<Intent>,
|
||||
type: Int
|
||||
) {
|
||||
val intent = createCameraForImageIntent(activity, type)
|
||||
resultLauncher.launch(intent)
|
||||
}
|
||||
|
||||
@Throws(URISyntaxException::class)
|
||||
@JvmStatic
|
||||
private fun takenCameraPicture(context: Context): UploadableFile? {
|
||||
val lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getString(KEY_LAST_CAMERA_PHOTO, null)
|
||||
return if (lastCameraPhoto != null) {
|
||||
UploadableFile(File(lastCameraPhoto))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(URISyntaxException::class)
|
||||
@JvmStatic
|
||||
private fun takenCameraVideo(context: Context): UploadableFile? {
|
||||
val lastCameraVideo = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getString(KEY_LAST_CAMERA_VIDEO, null)
|
||||
return if (lastCameraVideo != null) {
|
||||
UploadableFile(File(lastCameraVideo))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun handleExternalImagesPicked(data: Intent?, activity: Activity): List<UploadableFile> {
|
||||
return try {
|
||||
getFilesFromGalleryPictures(data, activity)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
emptyList()
|
||||
} catch (e: SecurityException) {
|
||||
e.printStackTrace()
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun isPhoto(data: Intent?): Boolean {
|
||||
return data == null || (data.data == null && data.clipData == null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun plainGalleryPickerIntent(
|
||||
openDocumentIntentPreferred: Boolean
|
||||
): Intent {
|
||||
/*
|
||||
* Asking for ACCESS_MEDIA_LOCATION at runtime solved the location-loss issue
|
||||
* in the custom selector in Contributions fragment.
|
||||
* Detailed discussion: https://github.com/commons-app/apps-android-commons/issues/5015
|
||||
*
|
||||
* This permission check, however, was insufficient to fix location-loss in
|
||||
* the regular selector in Contributions fragment and Nearby fragment,
|
||||
* especially on some devices running Android 13 that use the new Photo Picker by default.
|
||||
*
|
||||
* New Photo Picker: https://developer.android.com/training/data-storage/shared/photopicker
|
||||
*
|
||||
* The new Photo Picker introduced by Android redacts location tags from EXIF metadata.
|
||||
* Reported on the Google Issue Tracker: https://issuetracker.google.com/issues/243294058
|
||||
* Status: Won't fix (Intended behaviour)
|
||||
*
|
||||
* Switched intent from ACTION_GET_CONTENT to ACTION_OPEN_DOCUMENT (by default; can
|
||||
* be changed through the Setting page) as:
|
||||
*
|
||||
* ACTION_GET_CONTENT opens the 'best application' for choosing that kind of data
|
||||
* The best application is the new Photo Picker that redacts the location tags
|
||||
*
|
||||
* ACTION_OPEN_DOCUMENT, however, displays the various DocumentsProvider instances
|
||||
* installed on the device, letting the user interactively navigate through them.
|
||||
*
|
||||
* So, this allows us to use the traditional file picker that does not redact location tags
|
||||
* from EXIF.
|
||||
*
|
||||
*/
|
||||
val intent = if (openDocumentIntentPreferred) {
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
} else {
|
||||
Intent(Intent.ACTION_GET_CONTENT)
|
||||
}
|
||||
intent.type = "image/*"
|
||||
return intent
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPictureReturnedFromDocuments(
|
||||
result: ActivityResult,
|
||||
activity: Activity,
|
||||
callbacks: Callbacks
|
||||
) {
|
||||
if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) {
|
||||
try {
|
||||
val photoPath = result.data?.data
|
||||
val photoFile = PickedFiles.pickedExistingPicture(activity, photoPath!!)
|
||||
callbacks.onImagesPicked(
|
||||
singleFileList(photoFile),
|
||||
ImageSource.DOCUMENTS,
|
||||
restoreType(activity)
|
||||
)
|
||||
|
||||
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
|
||||
PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
callbacks.onImagePickerError(e, ImageSource.DOCUMENTS, restoreType(activity))
|
||||
}
|
||||
} else {
|
||||
callbacks.onCanceled(ImageSource.DOCUMENTS, restoreType(activity))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* onPictureReturnedFromCustomSelector.
|
||||
* Retrieve and forward the images to upload wizard through callback.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun onPictureReturnedFromCustomSelector(
|
||||
result: ActivityResult,
|
||||
activity: Activity,
|
||||
callbacks: Callbacks
|
||||
) {
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
try {
|
||||
val files = getFilesFromCustomSelector(result.data, activity)
|
||||
callbacks.onImagesPicked(files, ImageSource.CUSTOM_SELECTOR, restoreType(activity))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
callbacks.onImagePickerError(e, ImageSource.CUSTOM_SELECTOR, restoreType(activity))
|
||||
}
|
||||
} else {
|
||||
callbacks.onCanceled(ImageSource.CUSTOM_SELECTOR, restoreType(activity))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files from custom selector
|
||||
* Retrieve and process the selected images from the custom selector.
|
||||
*/
|
||||
@Throws(IOException::class, SecurityException::class)
|
||||
@JvmStatic
|
||||
private fun getFilesFromCustomSelector(
|
||||
data: Intent?,
|
||||
activity: Activity
|
||||
): List<UploadableFile> {
|
||||
val files = mutableListOf<UploadableFile>()
|
||||
val images = data?.getParcelableArrayListExtra<Image>("Images")
|
||||
images?.forEach { image ->
|
||||
val uri = image.uri
|
||||
val file = PickedFiles.pickedExistingPicture(activity, uri)
|
||||
files.add(file)
|
||||
}
|
||||
|
||||
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
|
||||
PickedFiles.copyFilesInSeparateThread(activity, files)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPictureReturnedFromGallery(
|
||||
result: ActivityResult,
|
||||
activity: Activity,
|
||||
callbacks: Callbacks
|
||||
) {
|
||||
if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) {
|
||||
try {
|
||||
val files = getFilesFromGalleryPictures(result.data, activity)
|
||||
callbacks.onImagesPicked(files, ImageSource.GALLERY, restoreType(activity))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
callbacks.onImagePickerError(e, ImageSource.GALLERY, restoreType(activity))
|
||||
}
|
||||
} else {
|
||||
callbacks.onCanceled(ImageSource.GALLERY, restoreType(activity))
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class, SecurityException::class)
|
||||
@JvmStatic
|
||||
private fun getFilesFromGalleryPictures(
|
||||
data: Intent?,
|
||||
activity: Activity
|
||||
): List<UploadableFile> {
|
||||
val files = mutableListOf<UploadableFile>()
|
||||
val clipData = data?.clipData
|
||||
if (clipData == null) {
|
||||
val uri = data?.data
|
||||
val file = PickedFiles.pickedExistingPicture(activity, uri!!)
|
||||
files.add(file)
|
||||
} else {
|
||||
for (i in 0 until clipData.itemCount) {
|
||||
val uri = clipData.getItemAt(i).uri
|
||||
val file = PickedFiles.pickedExistingPicture(activity, uri)
|
||||
files.add(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
|
||||
PickedFiles.copyFilesInSeparateThread(activity, files)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPictureReturnedFromCamera(
|
||||
activityResult: ActivityResult,
|
||||
activity: Activity,
|
||||
callbacks: Callbacks
|
||||
) {
|
||||
if (activityResult.resultCode == Activity.RESULT_OK) {
|
||||
try {
|
||||
val lastImageUri = PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
.getString(KEY_PHOTO_URI, null)
|
||||
if (!lastImageUri.isNullOrEmpty()) {
|
||||
revokeWritePermission(activity, Uri.parse(lastImageUri))
|
||||
}
|
||||
|
||||
val photoFile = takenCameraPicture(activity)
|
||||
val files = mutableListOf<UploadableFile>()
|
||||
photoFile?.let { files.add(it) }
|
||||
|
||||
if (photoFile == null) {
|
||||
val e = IllegalStateException("Unable to get the picture returned from camera")
|
||||
callbacks.onImagePickerError(e, ImageSource.CAMERA_IMAGE, restoreType(activity))
|
||||
} else {
|
||||
if (configuration(activity).shouldCopyTakenPhotosToPublicGalleryAppFolder()) {
|
||||
PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile))
|
||||
}
|
||||
callbacks.onImagesPicked(files, ImageSource.CAMERA_IMAGE, restoreType(activity))
|
||||
}
|
||||
|
||||
PreferenceManager.getDefaultSharedPreferences(activity).edit()
|
||||
.remove(KEY_LAST_CAMERA_PHOTO)
|
||||
.remove(KEY_PHOTO_URI)
|
||||
.apply()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
callbacks.onImagePickerError(e, ImageSource.CAMERA_IMAGE, restoreType(activity))
|
||||
}
|
||||
} else {
|
||||
callbacks.onCanceled(ImageSource.CAMERA_IMAGE, restoreType(activity))
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun configuration(context: Context): FilePickerConfiguration {
|
||||
return FilePickerConfiguration(context)
|
||||
}
|
||||
|
||||
enum class ImageSource {
|
||||
GALLERY, DOCUMENTS, CAMERA_IMAGE, CAMERA_VIDEO, CUSTOM_SELECTOR
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
fun onImagePickerError(e: Exception, source: ImageSource, type: Int)
|
||||
|
||||
fun onImagesPicked(imageFiles: List<UploadableFile>, source: ImageSource, type: Int)
|
||||
|
||||
fun onCanceled(source: ImageSource, type: Int)
|
||||
}
|
||||
|
||||
interface HandleActivityResult {
|
||||
fun onHandleActivityResult(callbacks: Callbacks)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
package fr.free.nrw.commons.filepicker;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
public class FilePickerConfiguration implements Constants {
|
||||
|
||||
private Context context;
|
||||
|
||||
FilePickerConfiguration(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public FilePickerConfiguration setAllowMultiplePickInGallery(boolean allowMultiple) {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putBoolean(BundleKeys.ALLOW_MULTIPLE, allowMultiple)
|
||||
.apply();
|
||||
return this;
|
||||
}
|
||||
|
||||
public FilePickerConfiguration setCopyTakenPhotosToPublicGalleryAppFolder(boolean copy) {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putBoolean(BundleKeys.COPY_TAKEN_PHOTOS, copy)
|
||||
.apply();
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getFolderName() {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getString(BundleKeys.FOLDER_NAME, DEFAULT_FOLDER_NAME);
|
||||
}
|
||||
|
||||
public boolean allowsMultiplePickingInGallery() {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.ALLOW_MULTIPLE, false);
|
||||
}
|
||||
|
||||
public boolean shouldCopyTakenPhotosToPublicGalleryAppFolder() {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.COPY_TAKEN_PHOTOS, false);
|
||||
}
|
||||
|
||||
public boolean shouldCopyPickedImagesToPublicGalleryAppFolder() {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.COPY_PICKED_IMAGES, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package fr.free.nrw.commons.filepicker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
|
||||
class FilePickerConfiguration(
|
||||
private val context: Context
|
||||
): Constants {
|
||||
|
||||
fun setAllowMultiplePickInGallery(allowMultiple: Boolean): FilePickerConfiguration {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putBoolean(Constants.BundleKeys.ALLOW_MULTIPLE, allowMultiple)
|
||||
.apply()
|
||||
return this
|
||||
}
|
||||
|
||||
fun setCopyTakenPhotosToPublicGalleryAppFolder(copy: Boolean): FilePickerConfiguration {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putBoolean(Constants.BundleKeys.COPY_TAKEN_PHOTOS, copy)
|
||||
.apply()
|
||||
return this
|
||||
}
|
||||
|
||||
fun getFolderName(): String {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getString(
|
||||
Constants.BundleKeys.FOLDER_NAME,
|
||||
Constants.DEFAULT_FOLDER_NAME
|
||||
) ?: Constants.DEFAULT_FOLDER_NAME
|
||||
}
|
||||
|
||||
fun allowsMultiplePickingInGallery(): Boolean {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getBoolean(Constants.BundleKeys.ALLOW_MULTIPLE, false)
|
||||
}
|
||||
|
||||
fun shouldCopyTakenPhotosToPublicGalleryAppFolder(): Boolean {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getBoolean(Constants.BundleKeys.COPY_TAKEN_PHOTOS, false)
|
||||
}
|
||||
|
||||
fun shouldCopyPickedImagesToPublicGalleryAppFolder(): Boolean {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getBoolean(Constants.BundleKeys.COPY_PICKED_IMAGES, false)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package fr.free.nrw.commons.filepicker;
|
||||
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import com.facebook.common.internal.ImmutableMap;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class MimeTypeMapWrapper {
|
||||
|
||||
private static final MimeTypeMap sMimeTypeMap = MimeTypeMap.getSingleton();
|
||||
|
||||
private static final Map<String, String> sMimeTypeToExtensionMap =
|
||||
ImmutableMap.of(
|
||||
"image/heif", "heif",
|
||||
"image/heic", "heic");
|
||||
|
||||
public static String getExtensionFromMimeType(String mimeType) {
|
||||
String result = sMimeTypeToExtensionMap.get(mimeType);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
return sMimeTypeMap.getExtensionFromMimeType(mimeType);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package fr.free.nrw.commons.filepicker
|
||||
|
||||
import android.webkit.MimeTypeMap
|
||||
|
||||
class MimeTypeMapWrapper {
|
||||
|
||||
companion object {
|
||||
private val sMimeTypeMap = MimeTypeMap.getSingleton()
|
||||
|
||||
private val sMimeTypeToExtensionMap = mapOf(
|
||||
"image/heif" to "heif",
|
||||
"image/heic" to "heic"
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun getExtensionFromMimeType(mimeType: String): String? {
|
||||
val result = sMimeTypeToExtensionMap[mimeType]
|
||||
if (result != null) {
|
||||
return result
|
||||
}
|
||||
return sMimeTypeMap.getExtensionFromMimeType(mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
package fr.free.nrw.commons.filepicker;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.net.Uri;
|
||||
import android.os.Environment;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
/**
|
||||
* PickedFiles.
|
||||
* Process the upload items.
|
||||
*/
|
||||
public class PickedFiles implements Constants {
|
||||
|
||||
/**
|
||||
* Get Folder Name
|
||||
* @param context
|
||||
* @return default application folder name.
|
||||
*/
|
||||
private static String getFolderName(@NonNull Context context) {
|
||||
return FilePicker.configuration(context).getFolderName();
|
||||
}
|
||||
|
||||
/**
|
||||
* tempImageDirectory
|
||||
* @param context
|
||||
* @return temporary image directory to copy and perform exif changes.
|
||||
*/
|
||||
private static File tempImageDirectory(@NonNull Context context) {
|
||||
File privateTempDir = new File(context.getCacheDir(), DEFAULT_FOLDER_NAME);
|
||||
if (!privateTempDir.exists()) privateTempDir.mkdirs();
|
||||
return privateTempDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* writeToFile
|
||||
* writes inputStream data to the destination file.
|
||||
* @param in input stream of source file.
|
||||
* @param file destination file
|
||||
*/
|
||||
private static void writeToFile(InputStream in, File file) throws IOException {
|
||||
try (OutputStream out = new FileOutputStream(file)) {
|
||||
byte[] buf = new byte[1024];
|
||||
int len;
|
||||
while ((len = in.read(buf)) > 0) {
|
||||
out.write(buf, 0, len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file function.
|
||||
* Copies source file to destination file.
|
||||
* @param src source file
|
||||
* @param dst destination file
|
||||
* @throws IOException (File input stream exception)
|
||||
*/
|
||||
private static void copyFile(File src, File dst) throws IOException {
|
||||
try (InputStream in = new FileInputStream(src)) {
|
||||
writeToFile(in, dst);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy files in separate thread.
|
||||
* Copies all the uploadable files to the temp image folder on background thread.
|
||||
* @param context
|
||||
* @param filesToCopy uploadable file list to be copied.
|
||||
*/
|
||||
static void copyFilesInSeparateThread(final Context context, final List<UploadableFile> filesToCopy) {
|
||||
new Thread(() -> {
|
||||
List<File> copiedFiles = new ArrayList<>();
|
||||
int i = 1;
|
||||
for (UploadableFile uploadableFile : filesToCopy) {
|
||||
File fileToCopy = uploadableFile.getFile();
|
||||
File dstDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), getFolderName(context));
|
||||
if (!dstDir.exists()) {
|
||||
dstDir.mkdirs();
|
||||
}
|
||||
|
||||
String[] filenameSplit = fileToCopy.getName().split("\\.");
|
||||
String extension = "." + filenameSplit[filenameSplit.length - 1];
|
||||
String filename = String.format("IMG_%s_%d.%s", new SimpleDateFormat("yyyyMMdd_HHmmss").format(Calendar.getInstance().getTime()), i, extension);
|
||||
|
||||
File dstFile = new File(dstDir, filename);
|
||||
try {
|
||||
dstFile.createNewFile();
|
||||
copyFile(fileToCopy, dstFile);
|
||||
copiedFiles.add(dstFile);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
i++;
|
||||
}
|
||||
scanCopiedImages(context, copiedFiles);
|
||||
}).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* singleFileList.
|
||||
* converts a single uploadableFile to list of uploadableFile.
|
||||
* @param file uploadable file
|
||||
* @return
|
||||
*/
|
||||
static List<UploadableFile> singleFileList(UploadableFile file) {
|
||||
List<UploadableFile> list = new ArrayList<>();
|
||||
list.add(file);
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* ScanCopiedImages
|
||||
* Scan copied images metadata using media scanner.
|
||||
* @param context
|
||||
* @param copiedImages copied images list.
|
||||
*/
|
||||
static void scanCopiedImages(Context context, List<File> copiedImages) {
|
||||
String[] paths = new String[copiedImages.size()];
|
||||
for (int i = 0; i < copiedImages.size(); i++) {
|
||||
paths[i] = copiedImages.get(i).toString();
|
||||
}
|
||||
|
||||
MediaScannerConnection.scanFile(context,
|
||||
paths, null,
|
||||
(path, uri) -> {
|
||||
Timber.d("Scanned " + path + ":");
|
||||
Timber.d("-> uri=%s", uri);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* pickedExistingPicture
|
||||
* convert the image into uploadable file.
|
||||
* @param photoUri Uri of the image.
|
||||
* @return Uploadable file ready for tag redaction.
|
||||
*/
|
||||
public static UploadableFile pickedExistingPicture(@NonNull Context context, Uri photoUri) throws IOException, SecurityException {// SecurityException for those file providers who share URI but forget to grant necessary permissions
|
||||
File directory = tempImageDirectory(context);
|
||||
File photoFile = new File(directory, UUID.randomUUID().toString() + "." + getMimeType(context, photoUri));
|
||||
if (photoFile.createNewFile()) {
|
||||
try (InputStream pictureInputStream = context.getContentResolver().openInputStream(photoUri)) {
|
||||
writeToFile(pictureInputStream, photoFile);
|
||||
}
|
||||
} else {
|
||||
throw new IOException("could not create photoFile to write upon");
|
||||
}
|
||||
return new UploadableFile(photoUri, photoFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* getCameraPictureLocation
|
||||
*/
|
||||
static File getCameraPicturesLocation(@NonNull Context context) throws IOException {
|
||||
File dir = tempImageDirectory(context);
|
||||
return File.createTempFile(UUID.randomUUID().toString(), ".jpg", dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* To find out the extension of required object in given uri
|
||||
* Solution by http://stackoverflow.com/a/36514823/1171484
|
||||
*/
|
||||
private static String getMimeType(@NonNull Context context, @NonNull Uri uri) {
|
||||
String extension;
|
||||
|
||||
//Check uri format to avoid null
|
||||
if (uri.getScheme().equals(ContentResolver.SCHEME_CONTENT)) {
|
||||
//If scheme is a content
|
||||
extension = MimeTypeMapWrapper.getExtensionFromMimeType(context.getContentResolver().getType(uri));
|
||||
} else {
|
||||
//If scheme is a File
|
||||
//This will replace white spaces with %20 and also other special characters. This will avoid returning null values on file name with spaces and special characters.
|
||||
extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(uri.getPath())).toString());
|
||||
|
||||
}
|
||||
|
||||
return extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetUriToFile
|
||||
* @param file get uri of file
|
||||
* @return uri of requested file.
|
||||
*/
|
||||
static Uri getUriToFile(@NonNull Context context, @NonNull File file) {
|
||||
String packageName = context.getApplicationContext().getPackageName();
|
||||
String authority = packageName + ".provider";
|
||||
return FileProvider.getUriForFile(context, authority, file);
|
||||
}
|
||||
|
||||
}
|
||||
195
app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt
Normal file
195
app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package fr.free.nrw.commons.filepicker
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import fr.free.nrw.commons.filepicker.Constants.Companion.DEFAULT_FOLDER_NAME
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
/**
|
||||
* PickedFiles.
|
||||
* Process the upload items.
|
||||
*/
|
||||
object PickedFiles : Constants {
|
||||
|
||||
/**
|
||||
* Get Folder Name
|
||||
* @return default application folder name.
|
||||
*/
|
||||
@JvmStatic
|
||||
private fun getFolderName(context: Context): String {
|
||||
return FilePicker.configuration(context).getFolderName()
|
||||
}
|
||||
|
||||
/**
|
||||
* tempImageDirectory
|
||||
* @return temporary image directory to copy and perform exif changes.
|
||||
*/
|
||||
@JvmStatic
|
||||
private fun tempImageDirectory(context: Context): File {
|
||||
val privateTempDir = File(context.cacheDir, DEFAULT_FOLDER_NAME)
|
||||
if (!privateTempDir.exists()) privateTempDir.mkdirs()
|
||||
return privateTempDir
|
||||
}
|
||||
|
||||
/**
|
||||
* writeToFile
|
||||
* Writes inputStream data to the destination file.
|
||||
*/
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
private fun writeToFile(inputStream: InputStream, file: File) {
|
||||
inputStream.use { input ->
|
||||
FileOutputStream(file).use { output ->
|
||||
val buffer = ByteArray(1024)
|
||||
var length: Int
|
||||
while (input.read(buffer).also { length = it } > 0) {
|
||||
output.write(buffer, 0, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file function.
|
||||
* Copies source file to destination file.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
@JvmStatic
|
||||
private fun copyFile(src: File, dst: File) {
|
||||
FileInputStream(src).use { inputStream ->
|
||||
writeToFile(inputStream, dst)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy files in separate thread.
|
||||
* Copies all the uploadable files to the temp image folder on background thread.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun copyFilesInSeparateThread(context: Context, filesToCopy: List<UploadableFile>) {
|
||||
Thread {
|
||||
val copiedFiles = mutableListOf<File>()
|
||||
var index = 1
|
||||
filesToCopy.forEach { uploadableFile ->
|
||||
val fileToCopy = uploadableFile.file
|
||||
val dstDir = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
|
||||
getFolderName(context)
|
||||
)
|
||||
if (!dstDir.exists()) dstDir.mkdirs()
|
||||
|
||||
val filenameSplit = fileToCopy.name.split(".")
|
||||
val extension = ".${filenameSplit.last()}"
|
||||
val filename = "IMG_${SimpleDateFormat(
|
||||
"yyyyMMdd_HHmmss",
|
||||
Locale.getDefault()).format(Date())}_$index$extension"
|
||||
val dstFile = File(dstDir, filename)
|
||||
|
||||
try {
|
||||
dstFile.createNewFile()
|
||||
copyFile(fileToCopy, dstFile)
|
||||
copiedFiles.add(dstFile)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
index++
|
||||
}
|
||||
scanCopiedImages(context, copiedFiles)
|
||||
}.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* singleFileList
|
||||
* Converts a single uploadableFile to list of uploadableFile.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun singleFileList(file: UploadableFile): List<UploadableFile> {
|
||||
return listOf(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* ScanCopiedImages
|
||||
* Scans copied images metadata using media scanner.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun scanCopiedImages(context: Context, copiedImages: List<File>) {
|
||||
val paths = copiedImages.map { it.toString() }.toTypedArray()
|
||||
MediaScannerConnection.scanFile(context, paths, null) { path, uri ->
|
||||
Timber.d("Scanned $path:")
|
||||
Timber.d("-> uri=$uri")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* pickedExistingPicture
|
||||
* Convert the image into uploadable file.
|
||||
*/
|
||||
@Throws(IOException::class, SecurityException::class)
|
||||
@JvmStatic
|
||||
fun pickedExistingPicture(context: Context, photoUri: Uri): UploadableFile {
|
||||
val directory = tempImageDirectory(context)
|
||||
val mimeType = getMimeType(context, photoUri)
|
||||
val photoFile = File(directory, "${UUID.randomUUID()}.$mimeType")
|
||||
|
||||
if (photoFile.createNewFile()) {
|
||||
context.contentResolver.openInputStream(photoUri)?.use { inputStream ->
|
||||
writeToFile(inputStream, photoFile)
|
||||
}
|
||||
} else {
|
||||
throw IOException("Could not create photoFile to write upon")
|
||||
}
|
||||
return UploadableFile(photoUri, photoFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* getCameraPictureLocation
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
@JvmStatic
|
||||
fun getCameraPicturesLocation(context: Context): File {
|
||||
val dir = tempImageDirectory(context)
|
||||
return File.createTempFile(UUID.randomUUID().toString(), ".jpg", dir)
|
||||
}
|
||||
|
||||
/**
|
||||
* To find out the extension of the required object in a given uri
|
||||
*/
|
||||
@JvmStatic
|
||||
private fun getMimeType(context: Context, uri: Uri): String {
|
||||
return if (uri.scheme == ContentResolver.SCHEME_CONTENT) {
|
||||
context.contentResolver.getType(uri)
|
||||
?.let { MimeTypeMapWrapper.getExtensionFromMimeType(it) }
|
||||
} else {
|
||||
MimeTypeMap.getFileExtensionFromUrl(
|
||||
Uri.fromFile(uri.path?.let { File(it) }).toString()
|
||||
)
|
||||
} ?: "jpg" // Default to jpg if unable to determine type
|
||||
}
|
||||
|
||||
/**
|
||||
* GetUriToFile
|
||||
* @param file get uri of file
|
||||
* @return uri of requested file.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getUriToFile(context: Context, file: File): Uri {
|
||||
val packageName = context.applicationContext.packageName
|
||||
val authority = "$packageName.provider"
|
||||
return FileProvider.getUriForFile(context, authority, file)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
package fr.free.nrw.commons.filepicker;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.exifinterface.media.ExifInterface;
|
||||
|
||||
import fr.free.nrw.commons.upload.FileUtils;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Date;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class UploadableFile implements Parcelable {
|
||||
public static final Creator<UploadableFile> CREATOR = new Creator<UploadableFile>() {
|
||||
@Override
|
||||
public UploadableFile createFromParcel(Parcel in) {
|
||||
return new UploadableFile(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UploadableFile[] newArray(int size) {
|
||||
return new UploadableFile[size];
|
||||
}
|
||||
};
|
||||
|
||||
private final Uri contentUri;
|
||||
private final File file;
|
||||
|
||||
public UploadableFile(Uri contentUri, File file) {
|
||||
this.contentUri = contentUri;
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public UploadableFile(File file) {
|
||||
this.file = file;
|
||||
this.contentUri = Uri.fromFile(new File(file.getPath()));
|
||||
}
|
||||
|
||||
public UploadableFile(Parcel in) {
|
||||
this.contentUri = in.readParcelable(Uri.class.getClassLoader());
|
||||
file = (File) in.readSerializable();
|
||||
}
|
||||
|
||||
public Uri getContentUri() {
|
||||
return contentUri;
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public String getFilePath() {
|
||||
return file.getPath();
|
||||
}
|
||||
|
||||
public Uri getMediaUri() {
|
||||
return Uri.parse(getFilePath());
|
||||
}
|
||||
|
||||
public String getMimeType(Context context) {
|
||||
return FileUtils.getMimeType(context, getMediaUri());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* First try to get the file creation date from EXIF else fall back to CP
|
||||
* @param context
|
||||
* @return
|
||||
*/
|
||||
@Nullable
|
||||
public DateTimeWithSource getFileCreatedDate(Context context) {
|
||||
DateTimeWithSource dateTimeFromExif = getDateTimeFromExif();
|
||||
if (dateTimeFromExif == null) {
|
||||
return getFileCreatedDateFromCP(context);
|
||||
} else {
|
||||
return dateTimeFromExif;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filePath creation date from uri from all possible content providers
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
private DateTimeWithSource getFileCreatedDateFromCP(Context context) {
|
||||
try {
|
||||
Cursor cursor = context.getContentResolver().query(contentUri, null, null, null, null);
|
||||
if (cursor == null) {
|
||||
return null;//Could not fetch last_modified
|
||||
}
|
||||
//Content provider contracts for opening gallery from the app and that by sharing from gallery from outside are different and we need to handle both the cases
|
||||
int lastModifiedColumnIndex = cursor.getColumnIndex("last_modified");//If gallery is opened from in app
|
||||
if (lastModifiedColumnIndex == -1) {
|
||||
lastModifiedColumnIndex = cursor.getColumnIndex("datetaken");
|
||||
}
|
||||
//If both the content providers do not give the data, lets leave it to Jesus
|
||||
if (lastModifiedColumnIndex == -1) {
|
||||
cursor.close();
|
||||
return null;
|
||||
}
|
||||
cursor.moveToFirst();
|
||||
return new DateTimeWithSource(cursor.getLong(lastModifiedColumnIndex), DateTimeWithSource.CP_SOURCE);
|
||||
} catch (Exception e) {
|
||||
return null;////Could not fetch last_modified
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate whether the EXIF contains the location (both latitude and longitude).
|
||||
*
|
||||
* @return whether the location exists for the file's EXIF
|
||||
*/
|
||||
public boolean hasLocation() {
|
||||
try {
|
||||
ExifInterface exif = new ExifInterface(file.getAbsolutePath());
|
||||
final String latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE);
|
||||
final String longitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE);
|
||||
return latitude != null && longitude != null;
|
||||
} catch (IOException | NumberFormatException | IndexOutOfBoundsException e) {
|
||||
Timber.tag("UploadableFile");
|
||||
Timber.d(e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filePath creation date from uri from EXIF
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
private DateTimeWithSource getDateTimeFromExif() {
|
||||
try {
|
||||
ExifInterface exif = new ExifInterface(file.getAbsolutePath());
|
||||
// TAG_DATETIME returns the last edited date, we need TAG_DATETIME_ORIGINAL for creation date
|
||||
// See issue https://github.com/commons-app/apps-android-commons/issues/1971
|
||||
String dateTimeSubString = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL);
|
||||
if (dateTimeSubString!=null) { //getAttribute may return null
|
||||
String year = dateTimeSubString.substring(0,4);
|
||||
String month = dateTimeSubString.substring(5,7);
|
||||
String day = dateTimeSubString.substring(8,10);
|
||||
// This date is stored as a string (not as a date), the rason is we don't want to include timezones
|
||||
String dateCreatedString = String.format("%04d-%02d-%02d", Integer.parseInt(year), Integer.parseInt(month), Integer.parseInt(day));
|
||||
if (dateCreatedString.length() == 10) { //yyyy-MM-dd format of date is expected
|
||||
@SuppressLint("RestrictedApi") Long dateTime = exif.getDateTimeOriginal();
|
||||
if(dateTime != null){
|
||||
Date date = new Date(dateTime);
|
||||
return new DateTimeWithSource(date, dateCreatedString, DateTimeWithSource.EXIF_SOURCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException | NumberFormatException | IndexOutOfBoundsException e) {
|
||||
Timber.tag("UploadableFile");
|
||||
Timber.d(e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel parcel, int i) {
|
||||
parcel.writeParcelable(contentUri, 0);
|
||||
parcel.writeSerializable(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* This class contains the epochDate along with the source from which it was extracted
|
||||
*/
|
||||
public class DateTimeWithSource {
|
||||
public static final String CP_SOURCE = "contentProvider";
|
||||
public static final String EXIF_SOURCE = "exif";
|
||||
|
||||
private final long epochDate;
|
||||
private String dateString; // this does not includes timezone information
|
||||
private final String source;
|
||||
|
||||
public DateTimeWithSource(long epochDate, String source) {
|
||||
this.epochDate = epochDate;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public DateTimeWithSource(Date date, String source) {
|
||||
this.epochDate = date.getTime();
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public DateTimeWithSource(Date date, String dateString, String source) {
|
||||
this.epochDate = date.getTime();
|
||||
this.dateString = dateString;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public long getEpochDate() {
|
||||
return epochDate;
|
||||
}
|
||||
|
||||
public String getDateString() {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
public String getSource() {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
package fr.free.nrw.commons.filepicker
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
|
||||
import fr.free.nrw.commons.upload.FileUtils
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
import timber.log.Timber
|
||||
|
||||
class UploadableFile : Parcelable {
|
||||
|
||||
val contentUri: Uri
|
||||
val file: File
|
||||
|
||||
constructor(contentUri: Uri, file: File) {
|
||||
this.contentUri = contentUri
|
||||
this.file = file
|
||||
}
|
||||
|
||||
constructor(file: File) {
|
||||
this.file = file
|
||||
this.contentUri = Uri.fromFile(File(file.path))
|
||||
}
|
||||
|
||||
private constructor(parcel: Parcel) {
|
||||
contentUri = parcel.readParcelable(Uri::class.java.classLoader)!!
|
||||
file = parcel.readSerializable() as File
|
||||
}
|
||||
|
||||
fun getFilePath(): String {
|
||||
return file.path
|
||||
}
|
||||
|
||||
fun getMediaUri(): Uri {
|
||||
return Uri.parse(getFilePath())
|
||||
}
|
||||
|
||||
fun getMimeType(context: Context): String? {
|
||||
return FileUtils.getMimeType(context, getMediaUri())
|
||||
}
|
||||
|
||||
override fun describeContents(): Int = 0
|
||||
|
||||
/**
|
||||
* First try to get the file creation date from EXIF, else fall back to Content Provider (CP)
|
||||
*/
|
||||
fun getFileCreatedDate(context: Context): DateTimeWithSource? {
|
||||
return getDateTimeFromExif() ?: getFileCreatedDateFromCP(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filePath creation date from URI using all possible content providers
|
||||
*/
|
||||
private fun getFileCreatedDateFromCP(context: Context): DateTimeWithSource? {
|
||||
return try {
|
||||
val cursor: Cursor? = context.contentResolver.query(contentUri, null, null, null, null)
|
||||
cursor?.use {
|
||||
val lastModifiedColumnIndex = cursor
|
||||
.getColumnIndex(
|
||||
"last_modified"
|
||||
).takeIf { it != -1 }
|
||||
?: cursor.getColumnIndex("datetaken")
|
||||
if (lastModifiedColumnIndex == -1) return null // No valid column found
|
||||
cursor.moveToFirst()
|
||||
DateTimeWithSource(
|
||||
cursor.getLong(
|
||||
lastModifiedColumnIndex
|
||||
), DateTimeWithSource.CP_SOURCE)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("UploadableFile").d(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the EXIF contains the location (both latitude and longitude).
|
||||
*/
|
||||
fun hasLocation(): Boolean {
|
||||
return try {
|
||||
val exif = ExifInterface(file.absolutePath)
|
||||
val latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE)
|
||||
val longitude = exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE)
|
||||
latitude != null && longitude != null
|
||||
} catch (e: IOException) {
|
||||
Timber.tag("UploadableFile").d(e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filePath creation date from URI using EXIF data
|
||||
*/
|
||||
private fun getDateTimeFromExif(): DateTimeWithSource? {
|
||||
return try {
|
||||
val exif = ExifInterface(file.absolutePath)
|
||||
val dateTimeSubString = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
|
||||
if (dateTimeSubString != null) {
|
||||
val year = dateTimeSubString.substring(0, 4).toInt()
|
||||
val month = dateTimeSubString.substring(5, 7).toInt()
|
||||
val day = dateTimeSubString.substring(8, 10).toInt()
|
||||
val dateCreatedString = "%04d-%02d-%02d".format(year, month, day)
|
||||
if (dateCreatedString.length == 10) {
|
||||
@SuppressLint("RestrictedApi")
|
||||
val dateTime = exif.dateTimeOriginal
|
||||
if (dateTime != null) {
|
||||
val date = Date(dateTime)
|
||||
return DateTimeWithSource(date, dateCreatedString, DateTimeWithSource.EXIF_SOURCE)
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("UploadableFile").d(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeParcelable(contentUri, flags)
|
||||
parcel.writeSerializable(file)
|
||||
}
|
||||
|
||||
class DateTimeWithSource {
|
||||
companion object {
|
||||
const val CP_SOURCE = "contentProvider"
|
||||
const val EXIF_SOURCE = "exif"
|
||||
}
|
||||
|
||||
val epochDate: Long
|
||||
var dateString: String? = null
|
||||
val source: String
|
||||
|
||||
constructor(epochDate: Long, source: String) {
|
||||
this.epochDate = epochDate
|
||||
this.source = source
|
||||
}
|
||||
|
||||
constructor(date: Date, source: String) {
|
||||
epochDate = date.time
|
||||
this.source = source
|
||||
}
|
||||
|
||||
constructor(date: Date, dateString: String, source: String) {
|
||||
epochDate = date.time
|
||||
this.dateString = dateString
|
||||
this.source = source
|
||||
}
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<UploadableFile> {
|
||||
override fun createFromParcel(parcel: Parcel): UploadableFile {
|
||||
return UploadableFile(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<UploadableFile?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
package fr.free.nrw.commons.mwapi;
|
||||
|
||||
import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_PREFIX;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import fr.free.nrw.commons.BuildConfig;
|
||||
import fr.free.nrw.commons.category.CategoryItem;
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage;
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse;
|
||||
import io.reactivex.Single;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import javax.inject.Inject;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import timber.log.Timber;
|
||||
|
||||
/**
|
||||
* Uses the OkHttp library to implement calls to the Commons MediaWiki API to match GPS coordinates
|
||||
* with nearby Commons categories. Parses the results using GSON to obtain a list of relevant
|
||||
* categories. Note: that caller is responsible for executing the request() method on a background
|
||||
* thread.
|
||||
*/
|
||||
public class CategoryApi {
|
||||
|
||||
private final OkHttpClient okHttpClient;
|
||||
private final Gson gson;
|
||||
|
||||
@Inject
|
||||
public CategoryApi(final OkHttpClient okHttpClient, final Gson gson) {
|
||||
this.okHttpClient = okHttpClient;
|
||||
this.gson = gson;
|
||||
}
|
||||
|
||||
public Single<List<CategoryItem>> request(String coords) {
|
||||
return Single.fromCallable(() -> {
|
||||
HttpUrl apiUrl = buildUrl(coords);
|
||||
Timber.d("URL: %s", apiUrl.toString());
|
||||
|
||||
Request request = new Request.Builder().get().url(apiUrl).build();
|
||||
Response response = okHttpClient.newCall(request).execute();
|
||||
ResponseBody body = response.body();
|
||||
if (body == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
MwQueryResponse apiResponse = gson.fromJson(body.charStream(), MwQueryResponse.class);
|
||||
Set<CategoryItem> categories = new LinkedHashSet<>();
|
||||
if (apiResponse != null && apiResponse.query() != null && apiResponse.query().pages() != null) {
|
||||
for (MwQueryPage page : apiResponse.query().pages()) {
|
||||
if (page.categories() != null) {
|
||||
for (MwQueryPage.Category category : page.categories()) {
|
||||
categories.add(new CategoryItem(category.title().replace(CATEGORY_PREFIX, ""), "", "", false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(categories);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds URL with image coords for MediaWiki API calls
|
||||
* Example URL: https://commons.wikimedia.org/w/api.php?action=query&prop=categories|coordinates|pageprops&format=json&clshow=!hidden&coprop=type|name|dim|country|region|globe&codistancefrompoint=38.11386944444445|13.356263888888888&generator=geosearch&redirects=&ggscoord=38.11386944444445|1.356263888888888&ggsradius=100&ggslimit=10&ggsnamespace=6&ggsprop=type|name|dim|country|region|globe&ggsprimary=all&formatversion=2
|
||||
*
|
||||
* @param coords Coordinates to build query with
|
||||
* @return URL for API query
|
||||
*/
|
||||
private HttpUrl buildUrl(final String coords) {
|
||||
return HttpUrl
|
||||
.parse(BuildConfig.WIKIMEDIA_API_HOST)
|
||||
.newBuilder()
|
||||
.addQueryParameter("action", "query")
|
||||
.addQueryParameter("prop", "categories|coordinates|pageprops")
|
||||
.addQueryParameter("format", "json")
|
||||
.addQueryParameter("clshow", "!hidden")
|
||||
.addQueryParameter("coprop", "type|name|dim|country|region|globe")
|
||||
.addQueryParameter("codistancefrompoint", coords)
|
||||
.addQueryParameter("generator", "geosearch")
|
||||
.addQueryParameter("ggscoord", coords)
|
||||
.addQueryParameter("ggsradius", "10000")
|
||||
.addQueryParameter("ggslimit", "10")
|
||||
.addQueryParameter("ggsnamespace", "6")
|
||||
.addQueryParameter("ggsprop", "type|name|dim|country|region|globe")
|
||||
.addQueryParameter("ggsprimary", "all")
|
||||
.addQueryParameter("formatversion", "2")
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
83
app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt
Normal file
83
app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package fr.free.nrw.commons.mwapi
|
||||
|
||||
import com.google.gson.Gson
|
||||
import fr.free.nrw.commons.BuildConfig
|
||||
import fr.free.nrw.commons.category.CATEGORY_PREFIX
|
||||
import fr.free.nrw.commons.category.CategoryItem
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
|
||||
import io.reactivex.Single
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Uses the OkHttp library to implement calls to the Commons MediaWiki API to match GPS coordinates
|
||||
* with nearby Commons categories. Parses the results using GSON to obtain a list of relevant
|
||||
* categories. Note: that caller is responsible for executing the request() method on a background
|
||||
* thread.
|
||||
*/
|
||||
class CategoryApi @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val gson: Gson
|
||||
) {
|
||||
private val apiUrl : HttpUrl by lazy { BuildConfig.WIKIMEDIA_API_HOST.toHttpUrlOrNull()!! }
|
||||
|
||||
fun request(coords: String): Single<List<CategoryItem>> = Single.fromCallable {
|
||||
val apiUrl = buildUrl(coords)
|
||||
Timber.d("URL: %s", apiUrl.toString())
|
||||
|
||||
val request: Request = Request.Builder().get().url(apiUrl).build()
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
val body = response.body ?: return@fromCallable emptyList<CategoryItem>()
|
||||
|
||||
val apiResponse = gson.fromJson(body.charStream(), MwQueryResponse::class.java)
|
||||
val categories: MutableSet<CategoryItem> = mutableSetOf()
|
||||
if (apiResponse?.query() != null && apiResponse.query()!!.pages() != null) {
|
||||
for (page in apiResponse.query()!!.pages()!!) {
|
||||
if (page.categories() != null) {
|
||||
for (category in page.categories()!!) {
|
||||
categories.add(
|
||||
CategoryItem(
|
||||
name = category.title().replace(CATEGORY_PREFIX, ""),
|
||||
description = "",
|
||||
thumbnail = "",
|
||||
isSelected = false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ArrayList<CategoryItem>(categories)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds URL with image coords for MediaWiki API calls
|
||||
* Example URL: https://commons.wikimedia.org/w/api.php?action=query&prop=categories|coordinates|pageprops&format=json&clshow=!hidden&coprop=type|name|dim|country|region|globe&codistancefrompoint=38.11386944444445|13.356263888888888&generator=geosearch&redirects=&ggscoord=38.11386944444445|1.356263888888888&ggsradius=100&ggslimit=10&ggsnamespace=6&ggsprop=type|name|dim|country|region|globe&ggsprimary=all&formatversion=2
|
||||
*
|
||||
* @param coords Coordinates to build query with
|
||||
* @return URL for API query
|
||||
*/
|
||||
private fun buildUrl(coords: String): HttpUrl = apiUrl.newBuilder()
|
||||
.addQueryParameter("action", "query")
|
||||
.addQueryParameter("prop", "categories|coordinates|pageprops")
|
||||
.addQueryParameter("format", "json")
|
||||
.addQueryParameter("clshow", "!hidden")
|
||||
.addQueryParameter("coprop", "type|name|dim|country|region|globe")
|
||||
.addQueryParameter("codistancefrompoint", coords)
|
||||
.addQueryParameter("generator", "geosearch")
|
||||
.addQueryParameter("ggscoord", coords)
|
||||
.addQueryParameter("ggsradius", "10000")
|
||||
.addQueryParameter("ggslimit", "10")
|
||||
.addQueryParameter("ggsnamespace", "6")
|
||||
.addQueryParameter("ggsprop", "type|name|dim|country|region|globe")
|
||||
.addQueryParameter("ggsprimary", "all")
|
||||
.addQueryParameter("formatversion", "2")
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,677 +0,0 @@
|
|||
package fr.free.nrw.commons.mwapi;
|
||||
|
||||
import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LEADERBOARD_END_POINT;
|
||||
import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.UPDATE_AVATAR_END_POINT;
|
||||
|
||||
import android.text.TextUtils;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import com.google.gson.Gson;
|
||||
import fr.free.nrw.commons.campaigns.CampaignResponseDTO;
|
||||
import fr.free.nrw.commons.explore.depictions.DepictsClient;
|
||||
import fr.free.nrw.commons.location.LatLng;
|
||||
import fr.free.nrw.commons.nearby.Place;
|
||||
import fr.free.nrw.commons.nearby.model.ItemsClass;
|
||||
import fr.free.nrw.commons.nearby.model.NearbyResponse;
|
||||
import fr.free.nrw.commons.nearby.model.NearbyResultItem;
|
||||
import fr.free.nrw.commons.nearby.model.PlaceBindings;
|
||||
import fr.free.nrw.commons.profile.achievements.FeaturedImages;
|
||||
import fr.free.nrw.commons.profile.achievements.FeedbackResponse;
|
||||
import fr.free.nrw.commons.profile.leaderboard.LeaderboardResponse;
|
||||
import fr.free.nrw.commons.profile.leaderboard.UpdateAvatarResponse;
|
||||
import fr.free.nrw.commons.upload.FileUtils;
|
||||
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
|
||||
import fr.free.nrw.commons.utils.ConfigUtils;
|
||||
import fr.free.nrw.commons.wikidata.model.GetWikidataEditCountResponse;
|
||||
import io.reactivex.Observable;
|
||||
import io.reactivex.Single;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import timber.log.Timber;
|
||||
|
||||
/**
|
||||
* Test methods in ok http api client
|
||||
*/
|
||||
@Singleton
|
||||
public class OkHttpJsonApiClient {
|
||||
|
||||
private final OkHttpClient okHttpClient;
|
||||
private final DepictsClient depictsClient;
|
||||
private final HttpUrl wikiMediaToolforgeUrl;
|
||||
private final String sparqlQueryUrl;
|
||||
private final String campaignsUrl;
|
||||
private final Gson gson;
|
||||
|
||||
|
||||
@Inject
|
||||
public OkHttpJsonApiClient(OkHttpClient okHttpClient,
|
||||
DepictsClient depictsClient,
|
||||
HttpUrl wikiMediaToolforgeUrl,
|
||||
String sparqlQueryUrl,
|
||||
String campaignsUrl,
|
||||
Gson gson) {
|
||||
this.okHttpClient = okHttpClient;
|
||||
this.depictsClient = depictsClient;
|
||||
this.wikiMediaToolforgeUrl = wikiMediaToolforgeUrl;
|
||||
this.sparqlQueryUrl = sparqlQueryUrl;
|
||||
this.campaignsUrl = campaignsUrl;
|
||||
this.gson = gson;
|
||||
}
|
||||
|
||||
/**
|
||||
* The method will gradually calls the leaderboard API and fetches the leaderboard
|
||||
*
|
||||
* @param userName username of leaderboard user
|
||||
* @param duration duration for leaderboard
|
||||
* @param category category for leaderboard
|
||||
* @param limit page size limit for list
|
||||
* @param offset offset for the list
|
||||
* @return LeaderboardResponse object
|
||||
*/
|
||||
@NonNull
|
||||
public Observable<LeaderboardResponse> getLeaderboard(String userName, String duration,
|
||||
String category, String limit, String offset) {
|
||||
final String fetchLeaderboardUrlTemplate = wikiMediaToolforgeUrl
|
||||
+ LEADERBOARD_END_POINT;
|
||||
String url = String.format(Locale.ENGLISH,
|
||||
fetchLeaderboardUrlTemplate,
|
||||
userName,
|
||||
duration,
|
||||
category,
|
||||
limit,
|
||||
offset);
|
||||
HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder();
|
||||
urlBuilder.addQueryParameter("user", userName);
|
||||
urlBuilder.addQueryParameter("duration", duration);
|
||||
urlBuilder.addQueryParameter("category", category);
|
||||
urlBuilder.addQueryParameter("limit", limit);
|
||||
urlBuilder.addQueryParameter("offset", offset);
|
||||
Timber.i("Url %s", urlBuilder.toString());
|
||||
Request request = new Request.Builder()
|
||||
.url(urlBuilder.toString())
|
||||
.build();
|
||||
return Observable.fromCallable(() -> {
|
||||
Response response = okHttpClient.newCall(request).execute();
|
||||
if (response != null && response.body() != null && response.isSuccessful()) {
|
||||
String json = response.body().string();
|
||||
if (json == null) {
|
||||
return new LeaderboardResponse();
|
||||
}
|
||||
Timber.d("Response for leaderboard is %s", json);
|
||||
try {
|
||||
return gson.fromJson(json, LeaderboardResponse.class);
|
||||
} catch (Exception e) {
|
||||
return new LeaderboardResponse();
|
||||
}
|
||||
}
|
||||
return new LeaderboardResponse();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will update the leaderboard user avatar
|
||||
*
|
||||
* @param username username to update
|
||||
* @param avatar url of the new avatar
|
||||
* @return UpdateAvatarResponse object
|
||||
*/
|
||||
@NonNull
|
||||
public Single<UpdateAvatarResponse> setAvatar(String username, String avatar) {
|
||||
final String urlTemplate = wikiMediaToolforgeUrl
|
||||
+ UPDATE_AVATAR_END_POINT;
|
||||
return Single.fromCallable(() -> {
|
||||
String url = String.format(Locale.ENGLISH,
|
||||
urlTemplate,
|
||||
username,
|
||||
avatar);
|
||||
HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder();
|
||||
urlBuilder.addQueryParameter("user", username);
|
||||
urlBuilder.addQueryParameter("avatar", avatar);
|
||||
Timber.i("Url %s", urlBuilder.toString());
|
||||
Request request = new Request.Builder()
|
||||
.url(urlBuilder.toString())
|
||||
.build();
|
||||
Response response = okHttpClient.newCall(request).execute();
|
||||
if (response != null && response.body() != null && response.isSuccessful()) {
|
||||
String json = response.body().string();
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return gson.fromJson(json, UpdateAvatarResponse.class);
|
||||
} catch (Exception e) {
|
||||
return new UpdateAvatarResponse();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Single<Integer> getUploadCount(String userName) {
|
||||
HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder();
|
||||
urlBuilder
|
||||
.addPathSegments("uploadsbyuser.py")
|
||||
.addQueryParameter("user", userName);
|
||||
|
||||
if (ConfigUtils.isBetaFlavour()) {
|
||||
urlBuilder.addQueryParameter("labs", "commonswiki");
|
||||
}
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(urlBuilder.build())
|
||||
.build();
|
||||
|
||||
return Single.fromCallable(() -> {
|
||||
Response response = okHttpClient.newCall(request).execute();
|
||||
if (response != null && response.isSuccessful()) {
|
||||
ResponseBody responseBody = response.body();
|
||||
if (null != responseBody) {
|
||||
String responseBodyString = responseBody.string().trim();
|
||||
if (!TextUtils.isEmpty(responseBodyString)) {
|
||||
try {
|
||||
return Integer.parseInt(responseBodyString);
|
||||
} catch (NumberFormatException e) {
|
||||
Timber.e(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Single<Integer> getWikidataEdits(String userName) {
|
||||
HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder();
|
||||
urlBuilder
|
||||
.addPathSegments("wikidataedits.py")
|
||||
.addQueryParameter("user", userName);
|
||||
|
||||
if (ConfigUtils.isBetaFlavour()) {
|
||||
urlBuilder.addQueryParameter("labs", "commonswiki");
|
||||
}
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(urlBuilder.build())
|
||||
.build();
|
||||
|
||||
return Single.fromCallable(() -> {
|
||||
Response response = okHttpClient.newCall(request).execute();
|
||||
if (response != null &&
|
||||
response.isSuccessful() && response.body() != null) {
|
||||
String json = response.body().string();
|
||||
if (json == null) {
|
||||
return 0;
|
||||
}
|
||||
// Extract JSON from response
|
||||
json = json.substring(json.indexOf('{'));
|
||||
GetWikidataEditCountResponse countResponse = gson
|
||||
.fromJson(json, GetWikidataEditCountResponse.class);
|
||||
if (null != countResponse) {
|
||||
return countResponse.getWikidataEditCount();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This takes userName as input, which is then used to fetch the feedback/achievements
|
||||
* statistics using OkHttp and JavaRx. This function return JSONObject
|
||||
*
|
||||
* @param userName MediaWiki user name
|
||||
* @return
|
||||
*/
|
||||
public Single<FeedbackResponse> getAchievements(String userName) {
|
||||
final String fetchAchievementUrlTemplate =
|
||||
wikiMediaToolforgeUrl + (ConfigUtils.isBetaFlavour() ? "/feedback.py?labs=commonswiki"
|
||||
: "/feedback.py");
|
||||
return Single.fromCallable(() -> {
|
||||
String url = String.format(
|
||||
Locale.ENGLISH,
|
||||
fetchAchievementUrlTemplate,
|
||||
userName);
|
||||
HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder();
|
||||
urlBuilder.addQueryParameter("user", userName);
|
||||
Request request = new Request.Builder()
|
||||
.url(urlBuilder.toString())
|
||||
.build();
|
||||
Response response = okHttpClient.newCall(request).execute();
|
||||
if (response != null && response.body() != null && response.isSuccessful()) {
|
||||
String json = response.body().string();
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
// Extract JSON from response
|
||||
json = json.substring(json.indexOf('{'));
|
||||
Timber.d("Response for achievements is %s", json);
|
||||
try {
|
||||
return gson.fromJson(json, FeedbackResponse.class);
|
||||
} catch (Exception e) {
|
||||
return new FeedbackResponse(0, 0, 0, new FeaturedImages(0, 0), 0, "");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API Call to get Nearby Places
|
||||
*
|
||||
* @param cur Search lat long
|
||||
* @param language Language
|
||||
* @param radius Search Radius
|
||||
* @return
|
||||
* @throws Exception
|
||||
*/
|
||||
@Nullable
|
||||
public List<Place> getNearbyPlaces(final LatLng cur, final String language, final double radius,
|
||||
final String customQuery)
|
||||
throws Exception {
|
||||
|
||||
Timber.d("Fetching nearby items at radius %s", radius);
|
||||
Timber.d("CUSTOM_SPARQL: %s", String.valueOf(customQuery != null));
|
||||
final String wikidataQuery;
|
||||
if (customQuery != null) {
|
||||
wikidataQuery = customQuery;
|
||||
} else {
|
||||
wikidataQuery = FileUtils.readFromResource(
|
||||
"/queries/radius_query_for_upload_wizard.rq");
|
||||
}
|
||||
final String query = wikidataQuery
|
||||
.replace("${RAD}", String.format(Locale.ROOT, "%.2f", radius))
|
||||
.replace("${LAT}", String.format(Locale.ROOT, "%.4f", cur.getLatitude()))
|
||||
.replace("${LONG}", String.format(Locale.ROOT, "%.4f", cur.getLongitude()))
|
||||
.replace("${LANG}", language);
|
||||
|
||||
final HttpUrl.Builder urlBuilder = HttpUrl
|
||||
.parse(sparqlQueryUrl)
|
||||
.newBuilder()
|
||||
.addQueryParameter("query", query)
|
||||
.addQueryParameter("format", "json");
|
||||
|
||||
final Request request = new Request.Builder()
|
||||
.url(urlBuilder.build())
|
||||
.build();
|
||||
|
||||
final Response response = okHttpClient.newCall(request).execute();
|
||||
if (response.body() != null && response.isSuccessful()) {
|
||||
final String json = response.body().string();
|
||||
final NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class);
|
||||
final List<NearbyResultItem> bindings = nearbyResponse.getResults().getBindings();
|
||||
final List<Place> places = new ArrayList<>();
|
||||
for (final NearbyResultItem item : bindings) {
|
||||
final Place placeFromNearbyItem = Place.from(item);
|
||||
placeFromNearbyItem.setMonument(false);
|
||||
places.add(placeFromNearbyItem);
|
||||
}
|
||||
return places;
|
||||
}
|
||||
throw new Exception(response.message());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves nearby places based on screen coordinates and optional query parameters.
|
||||
*
|
||||
* @param screenTopRight The top right corner of the screen (latitude, longitude).
|
||||
* @param screenBottomLeft The bottom left corner of the screen (latitude, longitude).
|
||||
* @param language The language for the query.
|
||||
* @param shouldQueryForMonuments Flag indicating whether to include monuments in the query.
|
||||
* @param customQuery Optional custom SPARQL query to use instead of default
|
||||
* queries.
|
||||
* @return A list of nearby places.
|
||||
* @throws Exception If an error occurs during the retrieval process.
|
||||
*/
|
||||
@Nullable
|
||||
public List<Place> getNearbyPlaces(
|
||||
final fr.free.nrw.commons.location.LatLng screenTopRight,
|
||||
final fr.free.nrw.commons.location.LatLng screenBottomLeft, final String language,
|
||||
final boolean shouldQueryForMonuments, final String customQuery)
|
||||
throws Exception {
|
||||
|
||||
Timber.d("CUSTOM_SPARQL: %s", String.valueOf(customQuery != null));
|
||||
|
||||
final String wikidataQuery;
|
||||
if (customQuery != null) {
|
||||
wikidataQuery = customQuery;
|
||||
} else if (!shouldQueryForMonuments) {
|
||||
wikidataQuery = FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq");
|
||||
} else {
|
||||
wikidataQuery = FileUtils.readFromResource(
|
||||
"/queries/rectangle_query_for_nearby_monuments.rq");
|
||||
}
|
||||
|
||||
final double westCornerLat = screenTopRight.getLatitude();
|
||||
final double westCornerLong = screenTopRight.getLongitude();
|
||||
final double eastCornerLat = screenBottomLeft.getLatitude();
|
||||
final double eastCornerLong = screenBottomLeft.getLongitude();
|
||||
|
||||
final String query = wikidataQuery
|
||||
.replace("${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat))
|
||||
.replace("${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong))
|
||||
.replace("${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat))
|
||||
.replace("${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong))
|
||||
.replace("${LANG}", language);
|
||||
final HttpUrl.Builder urlBuilder = HttpUrl
|
||||
.parse(sparqlQueryUrl)
|
||||
.newBuilder()
|
||||
.addQueryParameter("query", query)
|
||||
.addQueryParameter("format", "json");
|
||||
|
||||
final Request request = new Request.Builder()
|
||||
.url(urlBuilder.build())
|
||||
.build();
|
||||
|
||||
final Response response = okHttpClient.newCall(request).execute();
|
||||
if (response.body() != null && response.isSuccessful()) {
|
||||
final String json = response.body().string();
|
||||
final NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class);
|
||||
final List<NearbyResultItem> bindings = nearbyResponse.getResults().getBindings();
|
||||
final List<Place> places = new ArrayList<>();
|
||||
for (final NearbyResultItem item : bindings) {
|
||||
final Place placeFromNearbyItem = Place.from(item);
|
||||
if (shouldQueryForMonuments && item.getMonument() != null) {
|
||||
placeFromNearbyItem.setMonument(true);
|
||||
} else {
|
||||
placeFromNearbyItem.setMonument(false);
|
||||
}
|
||||
places.add(placeFromNearbyItem);
|
||||
}
|
||||
return places;
|
||||
}
|
||||
throw new Exception(response.message());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of places based on the provided list of places and language.
|
||||
*
|
||||
* @param placeList A list of Place objects for which to fetch information.
|
||||
* @param language The language code to use for the query.
|
||||
* @return A list of Place objects with additional information retrieved from Wikidata, or null
|
||||
* if an error occurs.
|
||||
* @throws IOException If there is an issue with reading the resource file or executing the HTTP
|
||||
* request.
|
||||
*/
|
||||
@Nullable
|
||||
public List<Place> getPlaces(
|
||||
final List<Place> placeList, final String language) throws IOException {
|
||||
final String wikidataQuery = FileUtils.readFromResource("/queries/query_for_item.rq");
|
||||
String qids = "";
|
||||
for (final Place place : placeList) {
|
||||
qids += "\n" + ("wd:" + place.getWikiDataEntityId());
|
||||
}
|
||||
final String query = wikidataQuery
|
||||
.replace("${ENTITY}", qids)
|
||||
.replace("${LANG}", language);
|
||||
final HttpUrl.Builder urlBuilder = HttpUrl
|
||||
.parse(sparqlQueryUrl)
|
||||
.newBuilder()
|
||||
.addQueryParameter("query", query)
|
||||
.addQueryParameter("format", "json");
|
||||
|
||||
final Request request = new Request.Builder()
|
||||
.url(urlBuilder.build())
|
||||
.build();
|
||||
|
||||
try (Response response = okHttpClient.newCall(request).execute()) {
|
||||
if (response.isSuccessful()) {
|
||||
final String json = response.body().string();
|
||||
final NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class);
|
||||
final List<NearbyResultItem> bindings = nearbyResponse.getResults().getBindings();
|
||||
final List<Place> places = new ArrayList<>();
|
||||
for (final NearbyResultItem item : bindings) {
|
||||
final Place placeFromNearbyItem = Place.from(item);
|
||||
places.add(placeFromNearbyItem);
|
||||
}
|
||||
return places;
|
||||
} else {
|
||||
throw new IOException("Unexpected response code: " + response.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API Call to get Places
|
||||
*
|
||||
* @param leftLatLng Left lat long
|
||||
* @param rightLatLng Right lat long
|
||||
* @return
|
||||
* @throws Exception
|
||||
*/
|
||||
@Nullable
|
||||
public String getPlacesAsKML(final LatLng leftLatLng, final LatLng rightLatLng)
|
||||
throws Exception {
|
||||
String kmlString = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n" +
|
||||
"<!--Created by Wikimedia Commons Android app -->\n" +
|
||||
"<kml xmlns=\"http://www.opengis.net/kml/2.2\">\n" +
|
||||
" <Document>";
|
||||
List<PlaceBindings> placeBindings = runQuery(leftLatLng,
|
||||
rightLatLng);
|
||||
if (placeBindings != null) {
|
||||
for (PlaceBindings item : placeBindings) {
|
||||
if (item.getItem() != null && item.getLabel() != null && item.getClas() != null) {
|
||||
String input = item.getLocation().getValue();
|
||||
Pattern pattern = Pattern.compile(
|
||||
"Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)");
|
||||
Matcher matcher = pattern.matcher(input);
|
||||
|
||||
if (matcher.find()) {
|
||||
String longStr = matcher.group(1);
|
||||
String latStr = matcher.group(2);
|
||||
String itemUrl = item.getItem().getValue();
|
||||
String itemName = item.getLabel().getValue().replace("&", "&");
|
||||
String itemLatitude = latStr;
|
||||
String itemLongitude = longStr;
|
||||
String itemClass = item.getClas().getValue();
|
||||
|
||||
String formattedItemName =
|
||||
!itemClass.isEmpty() ? itemName + " (" + itemClass + ")"
|
||||
: itemName;
|
||||
|
||||
String kmlEntry = "\n <Placemark>\n" +
|
||||
" <name>" + formattedItemName + "</name>\n" +
|
||||
" <description>" + itemUrl + "</description>\n" +
|
||||
" <Point>\n" +
|
||||
" <coordinates>" + itemLongitude + ","
|
||||
+ itemLatitude
|
||||
+ "</coordinates>\n" +
|
||||
" </Point>\n" +
|
||||
" </Placemark>";
|
||||
kmlString = kmlString + kmlEntry;
|
||||
} else {
|
||||
Timber.e("No match found");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
kmlString = kmlString + "\n </Document>\n" +
|
||||
"</kml>\n";
|
||||
return kmlString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API Call to get Places
|
||||
*
|
||||
* @param leftLatLng Left lat long
|
||||
* @param rightLatLng Right lat long
|
||||
* @return
|
||||
* @throws Exception
|
||||
*/
|
||||
@Nullable
|
||||
public String getPlacesAsGPX(final LatLng leftLatLng, final LatLng rightLatLng)
|
||||
throws Exception {
|
||||
String gpxString = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n" +
|
||||
"<gpx\n" +
|
||||
" version=\"1.0\"\n" +
|
||||
" creator=\"Wikimedia Commons Android app\"\n" +
|
||||
" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n" +
|
||||
" xmlns=\"http://www.topografix.com/GPX/1/0\"\n" +
|
||||
" xsi:schemaLocation=\"http://www.topografix.com/GPX/1/0 http://www.topografix.com/GPX/1/0/gpx.xsd\">"
|
||||
+ "\n<bounds minlat=\"$MIN_LATITUDE\" minlon=\"$MIN_LONGITUDE\" maxlat=\"$MAX_LATITUDE\" maxlon=\"$MAX_LONGITUDE\"/>";
|
||||
|
||||
List<PlaceBindings> placeBindings = runQuery(leftLatLng, rightLatLng);
|
||||
if (placeBindings != null) {
|
||||
for (PlaceBindings item : placeBindings) {
|
||||
if (item.getItem() != null && item.getLabel() != null && item.getClas() != null) {
|
||||
String input = item.getLocation().getValue();
|
||||
Pattern pattern = Pattern.compile(
|
||||
"Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)");
|
||||
Matcher matcher = pattern.matcher(input);
|
||||
|
||||
if (matcher.find()) {
|
||||
String longStr = matcher.group(1);
|
||||
String latStr = matcher.group(2);
|
||||
String itemUrl = item.getItem().getValue();
|
||||
String itemName = item.getLabel().getValue().replace("&", "&");
|
||||
String itemLatitude = latStr;
|
||||
String itemLongitude = longStr;
|
||||
String itemClass = item.getClas().getValue();
|
||||
|
||||
String formattedItemName =
|
||||
!itemClass.isEmpty() ? itemName + " (" + itemClass + ")"
|
||||
: itemName;
|
||||
|
||||
String gpxEntry =
|
||||
"\n <wpt lat=\"" + itemLatitude + "\" lon=\"" + itemLongitude
|
||||
+ "\">\n" +
|
||||
" <name>" + itemName + "</name>\n" +
|
||||
" <url>" + itemUrl + "</url>\n" +
|
||||
" </wpt>";
|
||||
gpxString = gpxString + gpxEntry;
|
||||
|
||||
} else {
|
||||
Timber.e("No match found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
gpxString = gpxString + "\n</gpx>";
|
||||
return gpxString;
|
||||
}
|
||||
|
||||
private List<PlaceBindings> runQuery(final LatLng currentLatLng, final LatLng nextLatLng)
|
||||
throws IOException {
|
||||
|
||||
final String wikidataQuery = FileUtils.readFromResource("/queries/places_query.rq");
|
||||
final String query = wikidataQuery
|
||||
.replace("${LONGITUDE}",
|
||||
String.format(Locale.ROOT, "%.2f", currentLatLng.getLongitude()))
|
||||
.replace("${LATITUDE}", String.format(Locale.ROOT, "%.4f", currentLatLng.getLatitude()))
|
||||
.replace("${NEXT_LONGITUDE}",
|
||||
String.format(Locale.ROOT, "%.4f", nextLatLng.getLongitude()))
|
||||
.replace("${NEXT_LATITUDE}",
|
||||
String.format(Locale.ROOT, "%.4f", nextLatLng.getLatitude()));
|
||||
|
||||
final HttpUrl.Builder urlBuilder = HttpUrl
|
||||
.parse(sparqlQueryUrl)
|
||||
.newBuilder()
|
||||
.addQueryParameter("query", query)
|
||||
.addQueryParameter("format", "json");
|
||||
|
||||
final Request request = new Request.Builder()
|
||||
.url(urlBuilder.build())
|
||||
.build();
|
||||
|
||||
final Response response = okHttpClient.newCall(request).execute();
|
||||
if (response.body() != null && response.isSuccessful()) {
|
||||
final String json = response.body().string();
|
||||
final ItemsClass item = gson.fromJson(json, ItemsClass.class);
|
||||
return item.getResults().getBindings();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API Call to get Nearby Places Implementation does not expects a custom query
|
||||
*
|
||||
* @param cur Search lat long
|
||||
* @param language Language
|
||||
* @param radius Search Radius
|
||||
* @return
|
||||
* @throws Exception
|
||||
*/
|
||||
@Nullable
|
||||
public List<Place> getNearbyPlaces(final LatLng cur, final String language, final double radius)
|
||||
throws Exception {
|
||||
return getNearbyPlaces(cur, language, radius, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the QIDs of all Wikidata items that are subclasses of the given Wikidata item. Example:
|
||||
* bridge -> suspended bridge, aqueduct, etc
|
||||
*/
|
||||
public Single<List<DepictedItem>> getChildDepictions(String qid, int startPosition,
|
||||
int limit) throws IOException {
|
||||
return depictedItemsFrom(
|
||||
sparqlQuery(qid, startPosition, limit, "/queries/subclasses_query.rq"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the QIDs of all Wikidata items that are subclasses of the given Wikidata item. Example:
|
||||
* bridge -> suspended bridge, aqueduct, etc
|
||||
*/
|
||||
public Single<List<DepictedItem>> getParentDepictions(String qid, int startPosition,
|
||||
int limit) throws IOException {
|
||||
return depictedItemsFrom(sparqlQuery(qid, startPosition, limit,
|
||||
"/queries/parentclasses_query.rq"));
|
||||
}
|
||||
|
||||
private Single<List<DepictedItem>> depictedItemsFrom(Request request) {
|
||||
return depictsClient.toDepictions(Single.fromCallable(() -> {
|
||||
try (ResponseBody body = okHttpClient.newCall(request).execute().body()) {
|
||||
return gson.fromJson(body.string(), SparqlResponse.class);
|
||||
}
|
||||
}).doOnError(Timber::e));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private Request sparqlQuery(String qid, int startPosition, int limit, String fileName)
|
||||
throws IOException {
|
||||
String query = FileUtils.readFromResource(fileName)
|
||||
.replace("${QID}", qid)
|
||||
.replace("${LANG}", "\"" + Locale.getDefault().getLanguage() + "\"")
|
||||
.replace("${LIMIT}", "" + limit)
|
||||
.replace("${OFFSET}", "" + startPosition);
|
||||
HttpUrl.Builder urlBuilder = HttpUrl
|
||||
.parse(sparqlQueryUrl)
|
||||
.newBuilder()
|
||||
.addQueryParameter("query", query)
|
||||
.addQueryParameter("format", "json");
|
||||
return new Request.Builder()
|
||||
.url(urlBuilder.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
public Single<CampaignResponseDTO> getCampaigns() {
|
||||
return Single.fromCallable(() -> {
|
||||
Request request = new Request.Builder().url(campaignsUrl)
|
||||
.build();
|
||||
Response response = okHttpClient.newCall(request).execute();
|
||||
if (response != null && response.body() != null && response.isSuccessful()) {
|
||||
String json = response.body().string();
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
return gson.fromJson(json, CampaignResponseDTO.class);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,543 @@
|
|||
package fr.free.nrw.commons.mwapi
|
||||
|
||||
import android.text.TextUtils
|
||||
import com.google.gson.Gson
|
||||
import fr.free.nrw.commons.campaigns.CampaignResponseDTO
|
||||
import fr.free.nrw.commons.explore.depictions.DepictsClient
|
||||
import fr.free.nrw.commons.location.LatLng
|
||||
import fr.free.nrw.commons.nearby.Place
|
||||
import fr.free.nrw.commons.nearby.model.ItemsClass
|
||||
import fr.free.nrw.commons.nearby.model.NearbyResponse
|
||||
import fr.free.nrw.commons.nearby.model.PlaceBindings
|
||||
import fr.free.nrw.commons.profile.achievements.FeaturedImages
|
||||
import fr.free.nrw.commons.profile.achievements.FeedbackResponse
|
||||
import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants
|
||||
import fr.free.nrw.commons.profile.leaderboard.LeaderboardResponse
|
||||
import fr.free.nrw.commons.profile.leaderboard.UpdateAvatarResponse
|
||||
import fr.free.nrw.commons.upload.FileUtils
|
||||
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
|
||||
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
|
||||
import fr.free.nrw.commons.wikidata.model.GetWikidataEditCountResponse
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Single
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Test methods in ok http api client
|
||||
*/
|
||||
@Singleton
|
||||
class OkHttpJsonApiClient @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val depictsClient: DepictsClient,
|
||||
private val wikiMediaToolforgeUrl: HttpUrl,
|
||||
private val sparqlQueryUrl: String,
|
||||
private val campaignsUrl: String,
|
||||
private val gson: Gson
|
||||
) {
|
||||
fun getLeaderboard(
|
||||
userName: String?, duration: String?,
|
||||
category: String?, limit: String?, offset: String?
|
||||
): Observable<LeaderboardResponse> {
|
||||
val fetchLeaderboardUrlTemplate =
|
||||
wikiMediaToolforgeUrl.toString() + LeaderboardConstants.LEADERBOARD_END_POINT
|
||||
val url = String.format(Locale.ENGLISH,
|
||||
fetchLeaderboardUrlTemplate, userName, duration, category, limit, offset)
|
||||
val urlBuilder: HttpUrl.Builder = url.toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("user", userName)
|
||||
.addQueryParameter("duration", duration)
|
||||
.addQueryParameter("category", category)
|
||||
.addQueryParameter("limit", limit)
|
||||
.addQueryParameter("offset", offset)
|
||||
Timber.i("Url %s", urlBuilder.toString())
|
||||
val request: Request = Request.Builder()
|
||||
.url(urlBuilder.toString())
|
||||
.build()
|
||||
return Observable.fromCallable({
|
||||
val response: Response = okHttpClient.newCall(request).execute()
|
||||
if (response.body != null && response.isSuccessful) {
|
||||
val json: String = response.body!!.string()
|
||||
Timber.d("Response for leaderboard is %s", json)
|
||||
try {
|
||||
return@fromCallable gson.fromJson<LeaderboardResponse>(
|
||||
json,
|
||||
LeaderboardResponse::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return@fromCallable LeaderboardResponse()
|
||||
}
|
||||
}
|
||||
LeaderboardResponse()
|
||||
})
|
||||
}
|
||||
|
||||
fun setAvatar(username: String?, avatar: String?): Single<UpdateAvatarResponse?> {
|
||||
val urlTemplate = wikiMediaToolforgeUrl
|
||||
.toString() + LeaderboardConstants.UPDATE_AVATAR_END_POINT
|
||||
return Single.fromCallable<UpdateAvatarResponse?>({
|
||||
val url = String.format(Locale.ENGLISH, urlTemplate, username, avatar)
|
||||
val urlBuilder: HttpUrl.Builder = url.toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("user", username)
|
||||
.addQueryParameter("avatar", avatar)
|
||||
Timber.i("Url %s", urlBuilder.toString())
|
||||
val request: Request = Request.Builder()
|
||||
.url(urlBuilder.toString())
|
||||
.build()
|
||||
val response: Response = okHttpClient.newCall(request).execute()
|
||||
if (response.body != null && response.isSuccessful) {
|
||||
val json: String = response.body!!.string() ?: return@fromCallable null
|
||||
try {
|
||||
return@fromCallable gson.fromJson<UpdateAvatarResponse>(
|
||||
json,
|
||||
UpdateAvatarResponse::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return@fromCallable UpdateAvatarResponse()
|
||||
}
|
||||
}
|
||||
null
|
||||
})
|
||||
}
|
||||
|
||||
fun getUploadCount(userName: String?): Single<Int> {
|
||||
val urlBuilder: HttpUrl.Builder = wikiMediaToolforgeUrl.newBuilder()
|
||||
.addPathSegments("uploadsbyuser.py")
|
||||
.addQueryParameter("user", userName)
|
||||
|
||||
if (isBetaFlavour) {
|
||||
urlBuilder.addQueryParameter("labs", "commonswiki")
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.url(urlBuilder.build())
|
||||
.build()
|
||||
|
||||
return Single.fromCallable<Int>({
|
||||
val response: Response = okHttpClient.newCall(request).execute()
|
||||
if (response != null && response.isSuccessful) {
|
||||
val responseBody = response.body
|
||||
if (null != responseBody) {
|
||||
val responseBodyString = responseBody.string().trim { it <= ' ' }
|
||||
if (!TextUtils.isEmpty(responseBodyString)) {
|
||||
try {
|
||||
return@fromCallable responseBodyString.toInt()
|
||||
} catch (e: NumberFormatException) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
})
|
||||
}
|
||||
|
||||
fun getWikidataEdits(userName: String?): Single<Int> {
|
||||
val urlBuilder: HttpUrl.Builder = wikiMediaToolforgeUrl.newBuilder()
|
||||
.addPathSegments("wikidataedits.py")
|
||||
.addQueryParameter("user", userName)
|
||||
|
||||
if (isBetaFlavour) {
|
||||
urlBuilder.addQueryParameter("labs", "commonswiki")
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.url(urlBuilder.build())
|
||||
.build()
|
||||
|
||||
return Single.fromCallable<Int>({
|
||||
val response: Response = okHttpClient.newCall(request).execute()
|
||||
if (response != null && response.isSuccessful && response.body != null) {
|
||||
var json: String = response.body!!.string()
|
||||
// Extract JSON from response
|
||||
json = json.substring(json.indexOf('{'))
|
||||
val countResponse = gson
|
||||
.fromJson(
|
||||
json,
|
||||
GetWikidataEditCountResponse::class.java
|
||||
)
|
||||
if (null != countResponse) {
|
||||
return@fromCallable countResponse.wikidataEditCount
|
||||
}
|
||||
}
|
||||
0
|
||||
})
|
||||
}
|
||||
|
||||
fun getAchievements(userName: String?): Single<FeedbackResponse?> {
|
||||
val suffix = if (isBetaFlavour) "/feedback.py?labs=commonswiki" else "/feedback.py"
|
||||
val fetchAchievementUrlTemplate = wikiMediaToolforgeUrl.toString() + suffix
|
||||
return Single.fromCallable<FeedbackResponse?>({
|
||||
val url = String.format(
|
||||
Locale.ENGLISH,
|
||||
fetchAchievementUrlTemplate,
|
||||
userName
|
||||
)
|
||||
val urlBuilder: HttpUrl.Builder = url.toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("user", userName)
|
||||
val request: Request = Request.Builder()
|
||||
.url(urlBuilder.toString())
|
||||
.build()
|
||||
val response: Response = okHttpClient.newCall(request).execute()
|
||||
if (response.body != null && response.isSuccessful) {
|
||||
var json: String = response.body!!.string()
|
||||
// Extract JSON from response
|
||||
json = json.substring(json.indexOf('{'))
|
||||
Timber.d("Response for achievements is %s", json)
|
||||
try {
|
||||
return@fromCallable gson.fromJson<FeedbackResponse>(
|
||||
json,
|
||||
FeedbackResponse::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return@fromCallable FeedbackResponse(0, 0, 0, FeaturedImages(0, 0), 0, "")
|
||||
}
|
||||
}
|
||||
null
|
||||
})
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
@Throws(Exception::class)
|
||||
fun getNearbyPlaces(
|
||||
cur: LatLng, language: String, radius: Double,
|
||||
customQuery: String? = null
|
||||
): List<Place>? {
|
||||
Timber.d("Fetching nearby items at radius %s", radius)
|
||||
Timber.d("CUSTOM_SPARQL: %s", (customQuery != null).toString())
|
||||
val wikidataQuery: String = if (customQuery != null) {
|
||||
customQuery
|
||||
} else {
|
||||
FileUtils.readFromResource("/queries/radius_query_for_upload_wizard.rq")
|
||||
}
|
||||
val query = wikidataQuery
|
||||
.replace("\${RAD}", String.format(Locale.ROOT, "%.2f", radius))
|
||||
.replace("\${LAT}", String.format(Locale.ROOT, "%.4f", cur.latitude))
|
||||
.replace("\${LONG}", String.format(Locale.ROOT, "%.4f", cur.longitude))
|
||||
.replace("\${LANG}", language)
|
||||
|
||||
val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("query", query)
|
||||
.addQueryParameter("format", "json")
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.url(urlBuilder.build())
|
||||
.build()
|
||||
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
if (response.body != null && response.isSuccessful) {
|
||||
val json = response.body!!.string()
|
||||
val nearbyResponse = gson.fromJson(json, NearbyResponse::class.java)
|
||||
val bindings = nearbyResponse.results.bindings
|
||||
val places: MutableList<Place> = ArrayList()
|
||||
for (item in bindings) {
|
||||
val placeFromNearbyItem = Place.from(item)
|
||||
placeFromNearbyItem.isMonument = false
|
||||
places.add(placeFromNearbyItem)
|
||||
}
|
||||
return places
|
||||
}
|
||||
throw Exception(response.message)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun getNearbyPlaces(
|
||||
screenTopRight: LatLng,
|
||||
screenBottomLeft: LatLng, language: String,
|
||||
shouldQueryForMonuments: Boolean, customQuery: String?
|
||||
): List<Place>? {
|
||||
Timber.d("CUSTOM_SPARQL: %s", (customQuery != null).toString())
|
||||
|
||||
val wikidataQuery: String = if (customQuery != null) {
|
||||
customQuery
|
||||
} else if (!shouldQueryForMonuments) {
|
||||
FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq")
|
||||
} else {
|
||||
FileUtils.readFromResource("/queries/rectangle_query_for_nearby_monuments.rq")
|
||||
}
|
||||
|
||||
val westCornerLat = screenTopRight.latitude
|
||||
val westCornerLong = screenTopRight.longitude
|
||||
val eastCornerLat = screenBottomLeft.latitude
|
||||
val eastCornerLong = screenBottomLeft.longitude
|
||||
|
||||
val query = wikidataQuery
|
||||
.replace("\${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat))
|
||||
.replace("\${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong))
|
||||
.replace("\${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat))
|
||||
.replace("\${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong))
|
||||
.replace("\${LANG}", language)
|
||||
val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("query", query)
|
||||
.addQueryParameter("format", "json")
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.url(urlBuilder.build())
|
||||
.build()
|
||||
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
if (response.body != null && response.isSuccessful) {
|
||||
val json = response.body!!.string()
|
||||
val nearbyResponse = gson.fromJson(json, NearbyResponse::class.java)
|
||||
val bindings = nearbyResponse.results.bindings
|
||||
val places: MutableList<Place> = ArrayList()
|
||||
for (item in bindings) {
|
||||
val placeFromNearbyItem = Place.from(item)
|
||||
if (shouldQueryForMonuments && item.getMonument() != null) {
|
||||
placeFromNearbyItem.isMonument = true
|
||||
} else {
|
||||
placeFromNearbyItem.isMonument = false
|
||||
}
|
||||
places.add(placeFromNearbyItem)
|
||||
}
|
||||
return places
|
||||
}
|
||||
throw Exception(response.message)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getPlaces(
|
||||
placeList: List<Place>, language: String
|
||||
): List<Place>? {
|
||||
val wikidataQuery = FileUtils.readFromResource("/queries/query_for_item.rq")
|
||||
var qids = ""
|
||||
for (place in placeList) {
|
||||
qids += """
|
||||
${"wd:" + place.wikiDataEntityId}"""
|
||||
}
|
||||
val query = wikidataQuery
|
||||
.replace("\${ENTITY}", qids)
|
||||
.replace("\${LANG}", language)
|
||||
val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("query", query)
|
||||
.addQueryParameter("format", "json")
|
||||
|
||||
val request: Request = Request.Builder().url(urlBuilder.build()).build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val json = response.body!!.string()
|
||||
val nearbyResponse = gson.fromJson(json, NearbyResponse::class.java)
|
||||
val bindings = nearbyResponse.results.bindings
|
||||
val places: MutableList<Place> = ArrayList()
|
||||
for (item in bindings) {
|
||||
val placeFromNearbyItem = Place.from(item)
|
||||
places.add(placeFromNearbyItem)
|
||||
}
|
||||
return places
|
||||
} else {
|
||||
throw IOException("Unexpected response code: " + response.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun getPlacesAsKML(leftLatLng: LatLng, rightLatLng: LatLng): String? {
|
||||
var kmlString = """<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!--Created by Wikimedia Commons Android app -->
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>"""
|
||||
val placeBindings = runQuery(
|
||||
leftLatLng,
|
||||
rightLatLng
|
||||
)
|
||||
if (placeBindings != null) {
|
||||
for ((item1, label, location, clas) in placeBindings) {
|
||||
if (item1 != null && label != null && clas != null) {
|
||||
val input = location.value
|
||||
val pattern = Pattern.compile(
|
||||
"Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)"
|
||||
)
|
||||
val matcher = pattern.matcher(input)
|
||||
|
||||
if (matcher.find()) {
|
||||
val longStr = matcher.group(1)
|
||||
val latStr = matcher.group(2)
|
||||
val itemUrl = item1.value
|
||||
val itemName = label.value.replace("&", "&")
|
||||
val itemLatitude = latStr
|
||||
val itemLongitude = longStr
|
||||
val itemClass = clas.value
|
||||
|
||||
val formattedItemName =
|
||||
if (!itemClass.isEmpty())
|
||||
"$itemName ($itemClass)"
|
||||
else
|
||||
itemName
|
||||
|
||||
val kmlEntry = ("""
|
||||
<Placemark>
|
||||
<name>$formattedItemName</name>
|
||||
<description>$itemUrl</description>
|
||||
<Point>
|
||||
<coordinates>$itemLongitude,$itemLatitude</coordinates>
|
||||
</Point>
|
||||
</Placemark>""")
|
||||
kmlString = kmlString + kmlEntry
|
||||
} else {
|
||||
Timber.e("No match found")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
kmlString = """$kmlString
|
||||
</Document>
|
||||
</kml>
|
||||
"""
|
||||
return kmlString
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun getPlacesAsGPX(leftLatLng: LatLng, rightLatLng: LatLng): String? {
|
||||
var gpxString = ("""<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<gpx
|
||||
version="1.0"
|
||||
creator="Wikimedia Commons Android app"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://www.topografix.com/GPX/1/0"
|
||||
xsi:schemaLocation="http://www.topografix.com/GPX/1/0 http://www.topografix.com/GPX/1/0/gpx.xsd">
|
||||
<bounds minlat="${"$"}MIN_LATITUDE" minlon="${"$"}MIN_LONGITUDE" maxlat="${"$"}MAX_LATITUDE" maxlon="${"$"}MAX_LONGITUDE"/>""")
|
||||
|
||||
val placeBindings = runQuery(leftLatLng, rightLatLng)
|
||||
if (placeBindings != null) {
|
||||
for ((item1, label, location, clas) in placeBindings) {
|
||||
if (item1 != null && label != null && clas != null) {
|
||||
val input = location.value
|
||||
val pattern = Pattern.compile(
|
||||
"Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)"
|
||||
)
|
||||
val matcher = pattern.matcher(input)
|
||||
|
||||
if (matcher.find()) {
|
||||
val longStr = matcher.group(1)
|
||||
val latStr = matcher.group(2)
|
||||
val itemUrl = item1.value
|
||||
val itemName = label.value.replace("&", "&")
|
||||
val itemLatitude = latStr
|
||||
val itemLongitude = longStr
|
||||
val itemClass = clas.value
|
||||
|
||||
val formattedItemName = if (!itemClass.isEmpty())
|
||||
"$itemName ($itemClass)"
|
||||
else
|
||||
itemName
|
||||
|
||||
val gpxEntry =
|
||||
("""
|
||||
<wpt lat="$itemLatitude" lon="$itemLongitude">
|
||||
<name>$itemName</name>
|
||||
<url>$itemUrl</url>
|
||||
</wpt>""")
|
||||
gpxString = gpxString + gpxEntry
|
||||
} else {
|
||||
Timber.e("No match found")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
gpxString = "$gpxString\n</gpx>"
|
||||
return gpxString
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getChildDepictions(
|
||||
qid: String, startPosition: Int,
|
||||
limit: Int
|
||||
): Single<List<DepictedItem>> =
|
||||
depictedItemsFrom(sparqlQuery(qid, startPosition, limit, "/queries/subclasses_query.rq"))
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getParentDepictions(
|
||||
qid: String, startPosition: Int,
|
||||
limit: Int
|
||||
): Single<List<DepictedItem>> = depictedItemsFrom(
|
||||
sparqlQuery(
|
||||
qid,
|
||||
startPosition,
|
||||
limit,
|
||||
"/queries/parentclasses_query.rq"
|
||||
)
|
||||
)
|
||||
|
||||
fun getCampaigns(): Single<CampaignResponseDTO> {
|
||||
return Single.fromCallable<CampaignResponseDTO?>({
|
||||
val request: Request = Request.Builder().url(campaignsUrl).build()
|
||||
val response: Response = okHttpClient.newCall(request).execute()
|
||||
if (response.body != null && response.isSuccessful) {
|
||||
val json: String = response.body!!.string()
|
||||
return@fromCallable gson.fromJson<CampaignResponseDTO>(
|
||||
json,
|
||||
CampaignResponseDTO::class.java
|
||||
)
|
||||
}
|
||||
null
|
||||
})
|
||||
}
|
||||
|
||||
private fun depictedItemsFrom(request: Request): Single<List<DepictedItem>> {
|
||||
return depictsClient.toDepictions(Single.fromCallable({
|
||||
okHttpClient.newCall(request).execute().body.use { body ->
|
||||
return@fromCallable gson.fromJson<SparqlResponse>(
|
||||
body!!.string(),
|
||||
SparqlResponse::class.java
|
||||
)
|
||||
}
|
||||
}).doOnError({ t: Throwable? -> Timber.e(t) }))
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun sparqlQuery(
|
||||
qid: String,
|
||||
startPosition: Int,
|
||||
limit: Int,
|
||||
fileName: String
|
||||
): Request {
|
||||
val query = FileUtils.readFromResource(fileName)
|
||||
.replace("\${QID}", qid)
|
||||
.replace("\${LANG}", "\"" + Locale.getDefault().language + "\"")
|
||||
.replace("\${LIMIT}", "" + limit)
|
||||
.replace("\${OFFSET}", "" + startPosition)
|
||||
val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("query", query)
|
||||
.addQueryParameter("format", "json")
|
||||
return Request.Builder().url(urlBuilder.build()).build()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun runQuery(currentLatLng: LatLng, nextLatLng: LatLng): List<PlaceBindings>? {
|
||||
val wikidataQuery = FileUtils.readFromResource("/queries/places_query.rq")
|
||||
val query = wikidataQuery
|
||||
.replace("\${LONGITUDE}", String.format(Locale.ROOT, "%.2f", currentLatLng.longitude))
|
||||
.replace("\${LATITUDE}", String.format(Locale.ROOT, "%.4f", currentLatLng.latitude))
|
||||
.replace("\${NEXT_LONGITUDE}", String.format(Locale.ROOT, "%.4f", nextLatLng.longitude))
|
||||
.replace("\${NEXT_LATITUDE}", String.format(Locale.ROOT, "%.4f", nextLatLng.latitude))
|
||||
|
||||
val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("query", query)
|
||||
.addQueryParameter("format", "json")
|
||||
|
||||
val request: Request = Request.Builder().url(urlBuilder.build()).build()
|
||||
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
if (response.body != null && response.isSuccessful) {
|
||||
val json = response.body!!.string()
|
||||
val item = gson.fromJson(json, ItemsClass::class.java)
|
||||
return item.results.bindings
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ import fr.free.nrw.commons.campaigns.CampaignView
|
|||
import fr.free.nrw.commons.contributions.ContributionController
|
||||
import fr.free.nrw.commons.contributions.MainActivity
|
||||
import fr.free.nrw.commons.di.ApplicationlessInjection
|
||||
import fr.free.nrw.commons.filepicker.FilePicker
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore
|
||||
import fr.free.nrw.commons.location.LocationServiceManager
|
||||
import fr.free.nrw.commons.logging.CommonsLogSender
|
||||
|
|
@ -83,9 +84,17 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
|
||||
private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> =
|
||||
registerForActivityResult(StartActivityForResult()) { result ->
|
||||
contributionController.handleActivityResultWithCallback(requireActivity()) { callbacks ->
|
||||
contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks)
|
||||
}
|
||||
contributionController.handleActivityResultWithCallback(
|
||||
requireActivity(),
|
||||
object: FilePicker.HandleActivityResult {
|
||||
override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) {
|
||||
contributionController.onPictureReturnedFromCamera(
|
||||
result,
|
||||
requireActivity(),
|
||||
callbacks
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ class FileProcessor
|
|||
requireNotNull(imageCoordinates.decimalCoords)
|
||||
compositeDisposable.add(
|
||||
apiCall
|
||||
.request(imageCoordinates.decimalCoords)
|
||||
.request(imageCoordinates.decimalCoords!!)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.io())
|
||||
.subscribe(
|
||||
|
|
@ -220,7 +220,7 @@ class FileProcessor
|
|||
.concatMap {
|
||||
Observable.fromCallable {
|
||||
okHttpJsonApiClient.getNearbyPlaces(
|
||||
imageCoordinates.latLng,
|
||||
imageCoordinates.latLng!!,
|
||||
Locale.getDefault().language,
|
||||
it,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -496,14 +496,14 @@ class UploadWorker(
|
|||
|
||||
withContext(Dispatchers.Main) {
|
||||
wikidataEditService.handleImageClaimResult(
|
||||
contribution.wikidataPlace,
|
||||
contribution.wikidataPlace!!,
|
||||
revisionID,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
wikidataEditService.handleImageClaimResult(
|
||||
contribution.wikidataPlace,
|
||||
contribution.wikidataPlace!!,
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class CustomSelectorUtils {
|
|||
fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact())
|
||||
val sha1 =
|
||||
fileUtilsWrapper.getSHA1(
|
||||
fileUtilsWrapper.getFileInputStream(uploadableFile.filePath),
|
||||
fileUtilsWrapper.getFileInputStream(uploadableFile.getFilePath()),
|
||||
)
|
||||
uploadableFile.file.delete()
|
||||
sha1
|
||||
|
|
|
|||
|
|
@ -10,11 +10,10 @@ class CommonsServiceFactory(
|
|||
) {
|
||||
val builder: Retrofit.Builder by lazy {
|
||||
// All instances of retrofit share this configuration, but create it lazily
|
||||
Retrofit
|
||||
.Builder()
|
||||
Retrofit.Builder()
|
||||
.client(okHttpClient)
|
||||
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson()))
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonUtil.defaultGson))
|
||||
}
|
||||
|
||||
val retrofitCache: MutableMap<String, Retrofit> = mutableMapOf()
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata;
|
||||
|
||||
import android.net.Uri;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import fr.free.nrw.commons.wikidata.json.RequiredFieldsCheckOnReadTypeAdapterFactory;
|
||||
import fr.free.nrw.commons.wikidata.model.DataValue;
|
||||
import fr.free.nrw.commons.wikidata.model.WikiSite;
|
||||
import fr.free.nrw.commons.wikidata.json.NamespaceTypeAdapter;
|
||||
import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter;
|
||||
import fr.free.nrw.commons.wikidata.json.UriTypeAdapter;
|
||||
import fr.free.nrw.commons.wikidata.json.WikiSiteTypeAdapter;
|
||||
import fr.free.nrw.commons.wikidata.model.page.Namespace;
|
||||
|
||||
public final class GsonUtil {
|
||||
private static final String DATE_FORMAT = "MMM dd, yyyy HH:mm:ss";
|
||||
|
||||
private static final GsonBuilder DEFAULT_GSON_BUILDER = new GsonBuilder()
|
||||
.setDateFormat(DATE_FORMAT)
|
||||
.registerTypeAdapterFactory(DataValue.getPolymorphicTypeAdapter())
|
||||
.registerTypeHierarchyAdapter(Uri.class, new UriTypeAdapter().nullSafe())
|
||||
.registerTypeHierarchyAdapter(Namespace.class, new NamespaceTypeAdapter().nullSafe())
|
||||
.registerTypeAdapter(WikiSite.class, new WikiSiteTypeAdapter().nullSafe())
|
||||
.registerTypeAdapterFactory(new RequiredFieldsCheckOnReadTypeAdapterFactory())
|
||||
.registerTypeAdapterFactory(new PostProcessingTypeAdapter());
|
||||
|
||||
private static final Gson DEFAULT_GSON = DEFAULT_GSON_BUILDER.create();
|
||||
|
||||
public static Gson getDefaultGson() {
|
||||
return DEFAULT_GSON;
|
||||
}
|
||||
|
||||
private GsonUtil() { }
|
||||
}
|
||||
29
app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt
Normal file
29
app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package fr.free.nrw.commons.wikidata
|
||||
|
||||
import android.net.Uri
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import fr.free.nrw.commons.wikidata.json.NamespaceTypeAdapter
|
||||
import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter
|
||||
import fr.free.nrw.commons.wikidata.json.RequiredFieldsCheckOnReadTypeAdapterFactory
|
||||
import fr.free.nrw.commons.wikidata.json.UriTypeAdapter
|
||||
import fr.free.nrw.commons.wikidata.json.WikiSiteTypeAdapter
|
||||
import fr.free.nrw.commons.wikidata.model.DataValue.Companion.polymorphicTypeAdapter
|
||||
import fr.free.nrw.commons.wikidata.model.WikiSite
|
||||
import fr.free.nrw.commons.wikidata.model.page.Namespace
|
||||
|
||||
object GsonUtil {
|
||||
private const val DATE_FORMAT = "MMM dd, yyyy HH:mm:ss"
|
||||
|
||||
private val DEFAULT_GSON_BUILDER: GsonBuilder by lazy {
|
||||
GsonBuilder().setDateFormat(DATE_FORMAT)
|
||||
.registerTypeAdapterFactory(polymorphicTypeAdapter)
|
||||
.registerTypeHierarchyAdapter(Uri::class.java, UriTypeAdapter().nullSafe())
|
||||
.registerTypeHierarchyAdapter(Namespace::class.java, NamespaceTypeAdapter().nullSafe())
|
||||
.registerTypeAdapter(WikiSite::class.java, WikiSiteTypeAdapter().nullSafe())
|
||||
.registerTypeAdapterFactory(RequiredFieldsCheckOnReadTypeAdapterFactory())
|
||||
.registerTypeAdapterFactory(PostProcessingTypeAdapter())
|
||||
}
|
||||
|
||||
val defaultGson: Gson by lazy { DEFAULT_GSON_BUILDER.create() }
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata;
|
||||
|
||||
public class WikidataConstants {
|
||||
public static final String PLACE_OBJECT = "place";
|
||||
public static final String BOOKMARKS_ITEMS = "bookmarks.items";
|
||||
public static final String SELECTED_NEARBY_PLACE = "selected.nearby.place";
|
||||
public static final String SELECTED_NEARBY_PLACE_CATEGORY = "selected.nearby.place.category";
|
||||
|
||||
public static final String MW_API_PREFIX = "w/api.php?format=json&formatversion=2&errorformat=plaintext&";
|
||||
public static final String WIKIPEDIA_URL = "https://wikipedia.org/";
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package fr.free.nrw.commons.wikidata
|
||||
|
||||
object WikidataConstants {
|
||||
const val PLACE_OBJECT: String = "place"
|
||||
const val BOOKMARKS_ITEMS: String = "bookmarks.items"
|
||||
const val SELECTED_NEARBY_PLACE: String = "selected.nearby.place"
|
||||
const val SELECTED_NEARBY_PLACE_CATEGORY: String = "selected.nearby.place.category"
|
||||
|
||||
const val MW_API_PREFIX: String = "w/api.php?format=json&formatversion=2&errorformat=plaintext&"
|
||||
const val WIKIPEDIA_URL: String = "https://wikipedia.org/"
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata;
|
||||
|
||||
public abstract class WikidataEditListener {
|
||||
|
||||
protected WikidataP18EditListener wikidataP18EditListener;
|
||||
|
||||
public abstract void onSuccessfulWikidataEdit();
|
||||
|
||||
public void setAuthenticationStateListener(WikidataP18EditListener wikidataP18EditListener) {
|
||||
this.wikidataP18EditListener = wikidataP18EditListener;
|
||||
}
|
||||
|
||||
public interface WikidataP18EditListener {
|
||||
void onWikidataEditSuccessful();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package fr.free.nrw.commons.wikidata
|
||||
|
||||
abstract class WikidataEditListener {
|
||||
var authenticationStateListener: WikidataP18EditListener? = null
|
||||
|
||||
abstract fun onSuccessfulWikidataEdit()
|
||||
|
||||
interface WikidataP18EditListener {
|
||||
fun onWikidataEditSuccessful()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata;
|
||||
|
||||
/**
|
||||
* Listener for wikidata edits
|
||||
*/
|
||||
public class WikidataEditListenerImpl extends WikidataEditListener {
|
||||
|
||||
public WikidataEditListenerImpl() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when wikidata P18 edit is successful. If there's an active listener, then it is fired
|
||||
*/
|
||||
@Override
|
||||
public void onSuccessfulWikidataEdit() {
|
||||
if (wikidataP18EditListener != null) {
|
||||
wikidataP18EditListener.onWikidataEditSuccessful();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package fr.free.nrw.commons.wikidata
|
||||
|
||||
/**
|
||||
* Listener for wikidata edits
|
||||
*/
|
||||
class WikidataEditListenerImpl : WikidataEditListener() {
|
||||
/**
|
||||
* Fired when wikidata P18 edit is successful. If there's an active listener, then it is fired
|
||||
*/
|
||||
override fun onSuccessfulWikidataEdit() {
|
||||
authenticationStateListener?.onWikidataEditSuccessful()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata;
|
||||
|
||||
|
||||
import static fr.free.nrw.commons.media.MediaClientKt.PAGE_ID_PREFIX;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import androidx.annotation.Nullable;
|
||||
import com.google.gson.Gson;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.contributions.Contribution;
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore;
|
||||
import fr.free.nrw.commons.upload.UploadResult;
|
||||
import fr.free.nrw.commons.upload.WikidataItem;
|
||||
import fr.free.nrw.commons.upload.WikidataPlace;
|
||||
import fr.free.nrw.commons.utils.ConfigUtils;
|
||||
import fr.free.nrw.commons.utils.ViewUtil;
|
||||
import fr.free.nrw.commons.wikidata.model.DataValue;
|
||||
import fr.free.nrw.commons.wikidata.model.DataValue.ValueString;
|
||||
import fr.free.nrw.commons.wikidata.model.EditClaim;
|
||||
import fr.free.nrw.commons.wikidata.model.RemoveClaim;
|
||||
import fr.free.nrw.commons.wikidata.model.SnakPartial;
|
||||
import fr.free.nrw.commons.wikidata.model.StatementPartial;
|
||||
import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue;
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse;
|
||||
import io.reactivex.Observable;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import javax.inject.Singleton;
|
||||
import timber.log.Timber;
|
||||
|
||||
/**
|
||||
* This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki
|
||||
* Apis to make the necessary calls, log the edits and fire listeners on successful edits
|
||||
*/
|
||||
@Singleton
|
||||
public class WikidataEditService {
|
||||
|
||||
public static final String COMMONS_APP_TAG = "wikimedia-commons-app";
|
||||
|
||||
private final Context context;
|
||||
private final WikidataEditListener wikidataEditListener;
|
||||
private final JsonKvStore directKvStore;
|
||||
private final WikiBaseClient wikiBaseClient;
|
||||
private final WikidataClient wikidataClient;
|
||||
private final Gson gson;
|
||||
|
||||
@Inject
|
||||
public WikidataEditService(final Context context,
|
||||
final WikidataEditListener wikidataEditListener,
|
||||
@Named("default_preferences") final JsonKvStore directKvStore,
|
||||
final WikiBaseClient wikiBaseClient,
|
||||
final WikidataClient wikidataClient, final Gson gson) {
|
||||
this.context = context;
|
||||
this.wikidataEditListener = wikidataEditListener;
|
||||
this.directKvStore = directKvStore;
|
||||
this.wikiBaseClient = wikiBaseClient;
|
||||
this.wikidataClient = wikidataClient;
|
||||
this.gson = gson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the wikibase entity by adding DEPICTS property. Adding DEPICTS property requires call
|
||||
* to the wikibase API to set tag against the entity.
|
||||
*/
|
||||
@SuppressLint("CheckResult")
|
||||
private Observable<Boolean> addDepictsProperty(
|
||||
final String fileEntityId,
|
||||
final List<String> depictedItems
|
||||
) {
|
||||
final EditClaim data = editClaim(
|
||||
ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10")
|
||||
// Wikipedia:Sandbox (Q10)
|
||||
: depictedItems
|
||||
);
|
||||
|
||||
return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data))
|
||||
.doOnNext(success -> {
|
||||
if (success) {
|
||||
Timber.d("DEPICTS property was set successfully for %s", fileEntityId);
|
||||
} else {
|
||||
Timber.d("Unable to set DEPICTS property for %s", fileEntityId);
|
||||
}
|
||||
})
|
||||
.doOnError(throwable -> {
|
||||
Timber.e(throwable, "Error occurred while setting DEPICTS property");
|
||||
ViewUtil.showLongToast(context, throwable.toString());
|
||||
})
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes depicts ID as a parameter and create a uploadable data with the Id
|
||||
* and send the data for POST operation
|
||||
*
|
||||
* @param fileEntityId ID of the file
|
||||
* @param depictedItems IDs of the selected depict item
|
||||
* @return Observable<Boolean>
|
||||
*/
|
||||
@SuppressLint("CheckResult")
|
||||
public Observable<Boolean> updateDepictsProperty(
|
||||
final String fileEntityId,
|
||||
final List<String> depictedItems
|
||||
) {
|
||||
final String entityId = PAGE_ID_PREFIX + fileEntityId;
|
||||
final List<String> claimIds = getDepictionsClaimIds(entityId);
|
||||
|
||||
final RemoveClaim data = removeClaim( /* Please consider removeClaim scenario for BetaDebug */
|
||||
ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10")
|
||||
// Wikipedia:Sandbox (Q10)
|
||||
: claimIds
|
||||
);
|
||||
|
||||
return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data))
|
||||
.doOnError(throwable -> {
|
||||
Timber.e(throwable, "Error occurred while removing existing claims for DEPICTS property");
|
||||
ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure));
|
||||
}).switchMap(success-> {
|
||||
if(success) {
|
||||
Timber.d("DEPICTS property was deleted successfully");
|
||||
return addDepictsProperty(fileEntityId, depictedItems);
|
||||
} else {
|
||||
Timber.d("Unable to delete DEPICTS property");
|
||||
return Observable.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private List<String> getDepictionsClaimIds(final String entityId) {
|
||||
return wikiBaseClient.getClaimIdsByProperty(entityId, WikidataProperties.DEPICTS.getPropertyName())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.blockingFirst();
|
||||
}
|
||||
|
||||
private EditClaim editClaim(final List<String> entityIds) {
|
||||
return EditClaim.from(entityIds, WikidataProperties.DEPICTS.getPropertyName());
|
||||
}
|
||||
|
||||
private RemoveClaim removeClaim(final List<String> claimIds) {
|
||||
return RemoveClaim.from(claimIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a success toast when the edit is made successfully
|
||||
*/
|
||||
private void showSuccessToast(final String wikiItemName) {
|
||||
final String successStringTemplate = context.getString(R.string.successful_wikidata_edit);
|
||||
final String successMessage = String
|
||||
.format(Locale.getDefault(), successStringTemplate, wikiItemName);
|
||||
ViewUtil.showLongToast(context, successMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds label to Wikidata using the fileEntityId and the edit token, obtained from
|
||||
* csrfTokenClient
|
||||
*
|
||||
* @param fileEntityId
|
||||
* @return
|
||||
*/
|
||||
@SuppressLint("CheckResult")
|
||||
private Observable<Boolean> addCaption(final long fileEntityId, final String languageCode,
|
||||
final String captionValue) {
|
||||
return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue)
|
||||
.doOnNext(mwPostResponse -> onAddCaptionResponse(fileEntityId, mwPostResponse))
|
||||
.doOnError(throwable -> {
|
||||
Timber.e(throwable, "Error occurred while setting Captions");
|
||||
ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure));
|
||||
})
|
||||
.map(mwPostResponse -> mwPostResponse != null);
|
||||
}
|
||||
|
||||
private void onAddCaptionResponse(Long fileEntityId, MwPostResponse response) {
|
||||
if (response != null) {
|
||||
Timber.d("Caption successfully set, revision id = %s", response);
|
||||
} else {
|
||||
Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId);
|
||||
}
|
||||
}
|
||||
|
||||
public Long createClaim(@Nullable final WikidataPlace wikidataPlace, final String fileName,
|
||||
final Map<String, String> captions) {
|
||||
if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) {
|
||||
Timber
|
||||
.d("Image location and nearby place location mismatched, so Wikidata item won't be edited");
|
||||
return null;
|
||||
}
|
||||
return addImageAndMediaLegends(wikidataPlace, fileName, captions);
|
||||
}
|
||||
|
||||
public Long addImageAndMediaLegends(final WikidataItem wikidataItem, final String fileName,
|
||||
final Map<String, String> captions) {
|
||||
final SnakPartial p18 = new SnakPartial("value",
|
||||
WikidataProperties.IMAGE.getPropertyName(),
|
||||
new ValueString(fileName.replace("File:", "")));
|
||||
|
||||
final List<SnakPartial> snaks = new ArrayList<>();
|
||||
for (final Map.Entry<String, String> entry : captions.entrySet()) {
|
||||
snaks.add(new SnakPartial("value",
|
||||
WikidataProperties.MEDIA_LEGENDS.getPropertyName(), new DataValue.MonoLingualText(
|
||||
new WikiBaseMonolingualTextValue(entry.getValue(), entry.getKey()))));
|
||||
}
|
||||
|
||||
final String id = wikidataItem.getId() + "$" + UUID.randomUUID().toString();
|
||||
final StatementPartial claim = new StatementPartial(p18, "statement", "normal", id,
|
||||
Collections.singletonMap(WikidataProperties.MEDIA_LEGENDS.getPropertyName(), snaks),
|
||||
Arrays.asList(WikidataProperties.MEDIA_LEGENDS.getPropertyName()));
|
||||
|
||||
return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle();
|
||||
}
|
||||
|
||||
public void handleImageClaimResult(final WikidataItem wikidataItem, final Long revisionId) {
|
||||
if (revisionId != null) {
|
||||
if (wikidataEditListener != null) {
|
||||
wikidataEditListener.onSuccessfulWikidataEdit();
|
||||
}
|
||||
showSuccessToast(wikidataItem.getName());
|
||||
} else {
|
||||
Timber.d("Unable to make wiki data edit for entity %s", wikidataItem);
|
||||
ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure));
|
||||
}
|
||||
}
|
||||
|
||||
public Observable<Boolean> addDepictionsAndCaptions(
|
||||
final UploadResult uploadResult,
|
||||
final Contribution contribution
|
||||
) {
|
||||
return wikiBaseClient.getFileEntityId(uploadResult)
|
||||
.doOnError(throwable -> {
|
||||
Timber
|
||||
.e(throwable, "Error occurred while getting EntityID to set DEPICTS property");
|
||||
ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure));
|
||||
})
|
||||
.switchMap(fileEntityId -> {
|
||||
if (fileEntityId != null) {
|
||||
Timber.d("EntityId for image was received successfully: %s", fileEntityId);
|
||||
return Observable.concat(
|
||||
depictionEdits(contribution, fileEntityId),
|
||||
captionEdits(contribution, fileEntityId)
|
||||
);
|
||||
} else {
|
||||
Timber.d("Error acquiring EntityId for image: %s", uploadResult);
|
||||
return Observable.empty();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private Observable<Boolean> captionEdits(Contribution contribution, Long fileEntityId) {
|
||||
return Observable.fromIterable(contribution.getMedia().getCaptions().entrySet())
|
||||
.concatMap(entry -> addCaption(fileEntityId, entry.getKey(), entry.getValue()));
|
||||
}
|
||||
|
||||
private Observable<Boolean> depictionEdits(Contribution contribution, Long fileEntityId) {
|
||||
final List<String> depictIDs = new ArrayList<>();
|
||||
for (final WikidataItem wikidataItem :
|
||||
contribution.getDepictedItems()) {
|
||||
depictIDs.add(wikidataItem.getId());
|
||||
}
|
||||
return addDepictsProperty(fileEntityId.toString(), depictIDs);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
package fr.free.nrw.commons.wikidata
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
import fr.free.nrw.commons.R
|
||||
import fr.free.nrw.commons.contributions.Contribution
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore
|
||||
import fr.free.nrw.commons.media.PAGE_ID_PREFIX
|
||||
import fr.free.nrw.commons.upload.UploadResult
|
||||
import fr.free.nrw.commons.upload.WikidataItem
|
||||
import fr.free.nrw.commons.upload.WikidataPlace
|
||||
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
|
||||
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
|
||||
import fr.free.nrw.commons.wikidata.WikidataProperties.DEPICTS
|
||||
import fr.free.nrw.commons.wikidata.WikidataProperties.IMAGE
|
||||
import fr.free.nrw.commons.wikidata.WikidataProperties.MEDIA_LEGENDS
|
||||
import fr.free.nrw.commons.wikidata.model.DataValue.MonoLingualText
|
||||
import fr.free.nrw.commons.wikidata.model.DataValue.ValueString
|
||||
import fr.free.nrw.commons.wikidata.model.EditClaim
|
||||
import fr.free.nrw.commons.wikidata.model.RemoveClaim
|
||||
import fr.free.nrw.commons.wikidata.model.SnakPartial
|
||||
import fr.free.nrw.commons.wikidata.model.StatementPartial
|
||||
import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
import java.util.Arrays
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
import java.util.Objects
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
||||
/**
|
||||
* This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki
|
||||
* Apis to make the necessary calls, log the edits and fire listeners on successful edits
|
||||
*/
|
||||
@Singleton
|
||||
class WikidataEditService @Inject constructor(
|
||||
private val context: Context,
|
||||
private val wikidataEditListener: WikidataEditListener?,
|
||||
@param:Named("default_preferences") private val directKvStore: JsonKvStore,
|
||||
private val wikiBaseClient: WikiBaseClient,
|
||||
private val wikidataClient: WikidataClient, private val gson: Gson
|
||||
) {
|
||||
@SuppressLint("CheckResult")
|
||||
private fun addDepictsProperty(
|
||||
fileEntityId: String,
|
||||
depictedItems: List<String>
|
||||
): Observable<Boolean> {
|
||||
val data = EditClaim.from(
|
||||
if (isBetaFlavour) listOf("Q10") else depictedItems, DEPICTS.propertyName
|
||||
)
|
||||
|
||||
return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data))
|
||||
.doOnNext { success: Boolean ->
|
||||
if (success) {
|
||||
Timber.d("DEPICTS property was set successfully for %s", fileEntityId)
|
||||
} else {
|
||||
Timber.d("Unable to set DEPICTS property for %s", fileEntityId)
|
||||
}
|
||||
}
|
||||
.doOnError { throwable: Throwable ->
|
||||
Timber.e(throwable, "Error occurred while setting DEPICTS property")
|
||||
showLongToast(context, throwable.toString())
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
fun updateDepictsProperty(
|
||||
fileEntityId: String?,
|
||||
depictedItems: List<String>
|
||||
): Observable<Boolean> {
|
||||
val entityId: String = PAGE_ID_PREFIX + fileEntityId
|
||||
val claimIds = getDepictionsClaimIds(entityId)
|
||||
|
||||
/* Please consider removeClaim scenario for BetaDebug */
|
||||
val data = RemoveClaim.from(if (isBetaFlavour) listOf("Q10") else claimIds)
|
||||
|
||||
return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data))
|
||||
.doOnError { throwable: Throwable? ->
|
||||
Timber.e(
|
||||
throwable,
|
||||
"Error occurred while removing existing claims for DEPICTS property"
|
||||
)
|
||||
showLongToast(
|
||||
context,
|
||||
context.getString(R.string.wikidata_edit_failure)
|
||||
)
|
||||
}.switchMap { success: Boolean ->
|
||||
if (success) {
|
||||
Timber.d("DEPICTS property was deleted successfully")
|
||||
return@switchMap addDepictsProperty(fileEntityId!!, depictedItems)
|
||||
} else {
|
||||
Timber.d("Unable to delete DEPICTS property")
|
||||
return@switchMap Observable.empty<Boolean>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private fun getDepictionsClaimIds(entityId: String): List<String> {
|
||||
return wikiBaseClient.getClaimIdsByProperty(entityId, DEPICTS.propertyName)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.blockingFirst()
|
||||
}
|
||||
|
||||
private fun showSuccessToast(wikiItemName: String) {
|
||||
val successStringTemplate = context.getString(R.string.successful_wikidata_edit)
|
||||
val successMessage = String.format(Locale.getDefault(), successStringTemplate, wikiItemName)
|
||||
showLongToast(context, successMessage)
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private fun addCaption(
|
||||
fileEntityId: Long, languageCode: String,
|
||||
captionValue: String
|
||||
): Observable<Boolean> {
|
||||
return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue)
|
||||
.doOnNext { mwPostResponse: MwPostResponse? ->
|
||||
onAddCaptionResponse(
|
||||
fileEntityId,
|
||||
mwPostResponse
|
||||
)
|
||||
}
|
||||
.doOnError { throwable: Throwable? ->
|
||||
Timber.e(throwable, "Error occurred while setting Captions")
|
||||
showLongToast(
|
||||
context,
|
||||
context.getString(R.string.wikidata_edit_failure)
|
||||
)
|
||||
}
|
||||
.map(Objects::nonNull)
|
||||
}
|
||||
|
||||
private fun onAddCaptionResponse(fileEntityId: Long, response: MwPostResponse?) {
|
||||
if (response != null) {
|
||||
Timber.d("Caption successfully set, revision id = %s", response)
|
||||
} else {
|
||||
Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId)
|
||||
}
|
||||
}
|
||||
|
||||
fun createClaim(
|
||||
wikidataPlace: WikidataPlace?, fileName: String,
|
||||
captions: Map<String, String>
|
||||
): Long? {
|
||||
if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) {
|
||||
Timber.d(
|
||||
"Image location and nearby place location mismatched, so Wikidata item won't be edited"
|
||||
)
|
||||
return null
|
||||
}
|
||||
return addImageAndMediaLegends(wikidataPlace!!, fileName, captions)
|
||||
}
|
||||
|
||||
fun addImageAndMediaLegends(
|
||||
wikidataItem: WikidataItem, fileName: String,
|
||||
captions: Map<String, String>
|
||||
): Long {
|
||||
val p18 = SnakPartial(
|
||||
"value",
|
||||
IMAGE.propertyName,
|
||||
ValueString(fileName.replace("File:", ""))
|
||||
)
|
||||
|
||||
val snaks: MutableList<SnakPartial> = ArrayList()
|
||||
for ((key, value) in captions) {
|
||||
snaks.add(
|
||||
SnakPartial(
|
||||
"value",
|
||||
MEDIA_LEGENDS.propertyName, MonoLingualText(
|
||||
WikiBaseMonolingualTextValue(value!!, key!!)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val id = wikidataItem.id + "$" + UUID.randomUUID().toString()
|
||||
val claim = StatementPartial(
|
||||
p18, "statement", "normal", id, Collections.singletonMap<String, List<SnakPartial>>(
|
||||
MEDIA_LEGENDS.propertyName, snaks
|
||||
), Arrays.asList(MEDIA_LEGENDS.propertyName)
|
||||
)
|
||||
|
||||
return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle()
|
||||
}
|
||||
|
||||
fun handleImageClaimResult(wikidataItem: WikidataItem, revisionId: Long?) {
|
||||
if (revisionId != null) {
|
||||
wikidataEditListener?.onSuccessfulWikidataEdit()
|
||||
showSuccessToast(wikidataItem.name)
|
||||
} else {
|
||||
Timber.d("Unable to make wiki data edit for entity %s", wikidataItem)
|
||||
showLongToast(context, context.getString(R.string.wikidata_edit_failure))
|
||||
}
|
||||
}
|
||||
|
||||
fun addDepictionsAndCaptions(
|
||||
uploadResult: UploadResult,
|
||||
contribution: Contribution
|
||||
): Observable<Boolean> {
|
||||
return wikiBaseClient.getFileEntityId(uploadResult)
|
||||
.doOnError { throwable: Throwable? ->
|
||||
Timber.e(
|
||||
throwable,
|
||||
"Error occurred while getting EntityID to set DEPICTS property"
|
||||
)
|
||||
showLongToast(
|
||||
context,
|
||||
context.getString(R.string.wikidata_edit_failure)
|
||||
)
|
||||
}
|
||||
.switchMap { fileEntityId: Long? ->
|
||||
if (fileEntityId != null) {
|
||||
Timber.d("EntityId for image was received successfully: %s", fileEntityId)
|
||||
return@switchMap Observable.concat<Boolean>(
|
||||
depictionEdits(contribution, fileEntityId),
|
||||
captionEdits(contribution, fileEntityId)
|
||||
)
|
||||
} else {
|
||||
Timber.d("Error acquiring EntityId for image: %s", uploadResult)
|
||||
return@switchMap Observable.empty<Boolean>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun captionEdits(contribution: Contribution, fileEntityId: Long): Observable<Boolean> {
|
||||
return Observable.fromIterable(contribution.media.captions.entries)
|
||||
.concatMap { addCaption(fileEntityId, it.key, it.value) }
|
||||
}
|
||||
|
||||
private fun depictionEdits(
|
||||
contribution: Contribution,
|
||||
fileEntityId: Long
|
||||
): Observable<Boolean> = addDepictsProperty(fileEntityId.toString(), buildList {
|
||||
for ((_, _, _, _, _, _, id) in contribution.depictedItems) {
|
||||
add(id)
|
||||
}
|
||||
})
|
||||
|
||||
companion object {
|
||||
const val COMMONS_APP_TAG: String = "wikimedia-commons-app"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata.json;
|
||||
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
|
||||
import fr.free.nrw.commons.wikidata.model.page.Namespace;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class NamespaceTypeAdapter extends TypeAdapter<Namespace> {
|
||||
|
||||
@Override
|
||||
public void write(JsonWriter out, Namespace namespace) throws IOException {
|
||||
out.value(namespace.code());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Namespace read(JsonReader in) throws IOException {
|
||||
if (in.peek() == JsonToken.STRING) {
|
||||
// Prior to 3210ce44, we marshaled Namespace as the name string of the enum, instead of
|
||||
// the code number. This introduces a backwards-compatible check for the string value.
|
||||
// TODO: remove after April 2017, when all older namespaces have been deserialized.
|
||||
return Namespace.valueOf(in.nextString());
|
||||
}
|
||||
return Namespace.of(in.nextInt());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package fr.free.nrw.commons.wikidata.json
|
||||
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.google.gson.stream.JsonToken
|
||||
import com.google.gson.stream.JsonWriter
|
||||
import fr.free.nrw.commons.wikidata.model.page.Namespace
|
||||
import java.io.IOException
|
||||
|
||||
class NamespaceTypeAdapter : TypeAdapter<Namespace>() {
|
||||
@Throws(IOException::class)
|
||||
override fun write(out: JsonWriter, namespace: Namespace) {
|
||||
out.value(namespace.code().toLong())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(reader: JsonReader): Namespace {
|
||||
if (reader.peek() == JsonToken.STRING) {
|
||||
// Prior to 3210ce44, we marshaled Namespace as the name string of the enum, instead of
|
||||
// the code number. This introduces a backwards-compatible check for the string value.
|
||||
// TODO: remove after April 2017, when all older namespaces have been deserialized.
|
||||
return Namespace.valueOf(reader.nextString())
|
||||
}
|
||||
return Namespace.of(reader.nextInt())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata.json;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.TypeAdapterFactory;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class PostProcessingTypeAdapter implements TypeAdapterFactory {
|
||||
public interface PostProcessable {
|
||||
void postProcess();
|
||||
}
|
||||
|
||||
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
|
||||
final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
|
||||
|
||||
return new TypeAdapter<T>() {
|
||||
public void write(JsonWriter out, T value) throws IOException {
|
||||
delegate.write(out, value);
|
||||
}
|
||||
|
||||
public T read(JsonReader in) throws IOException {
|
||||
T obj = delegate.read(in);
|
||||
if (obj instanceof PostProcessable) {
|
||||
((PostProcessable)obj).postProcess();
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package fr.free.nrw.commons.wikidata.json
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.TypeAdapterFactory
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.google.gson.stream.JsonWriter
|
||||
import java.io.IOException
|
||||
|
||||
class PostProcessingTypeAdapter : TypeAdapterFactory {
|
||||
interface PostProcessable {
|
||||
fun postProcess()
|
||||
}
|
||||
|
||||
override fun <T> create(gson: Gson, type: TypeToken<T>): TypeAdapter<T> {
|
||||
val delegate = gson.getDelegateAdapter(this, type)
|
||||
|
||||
return object : TypeAdapter<T>() {
|
||||
@Throws(IOException::class)
|
||||
override fun write(out: JsonWriter, value: T) {
|
||||
delegate.write(out, value)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(reader: JsonReader): T {
|
||||
val obj = delegate.read(reader)
|
||||
if (obj is PostProcessable) {
|
||||
(obj as PostProcessable).postProcess()
|
||||
}
|
||||
return obj
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata.json;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.collection.ArraySet;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.TypeAdapterFactory;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
|
||||
import fr.free.nrw.commons.wikidata.json.annotations.Required;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* TypeAdapterFactory that provides TypeAdapters that return null values for objects that are
|
||||
* missing fields annotated with @Required.
|
||||
*
|
||||
* BEWARE: This means that a List or other Collection of objects that have @Required fields can
|
||||
* contain null elements after deserialization!
|
||||
*
|
||||
* TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements
|
||||
* annotation and another corresponding TypeAdapter(Factory).
|
||||
*/
|
||||
public class RequiredFieldsCheckOnReadTypeAdapterFactory implements TypeAdapterFactory {
|
||||
@Nullable @Override public final <T> TypeAdapter<T> create(@NonNull Gson gson, @NonNull TypeToken<T> typeToken) {
|
||||
Class<?> rawType = typeToken.getRawType();
|
||||
Set<Field> requiredFields = collectRequiredFields(rawType);
|
||||
|
||||
if (requiredFields.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setFieldsAccessible(requiredFields, true);
|
||||
return new Adapter<>(gson.getDelegateAdapter(this, typeToken), requiredFields);
|
||||
}
|
||||
|
||||
@NonNull private Set<Field> collectRequiredFields(@NonNull Class<?> clazz) {
|
||||
Field[] fields = clazz.getDeclaredFields();
|
||||
Set<Field> required = new ArraySet<>();
|
||||
for (Field field : fields) {
|
||||
if (field.isAnnotationPresent(Required.class)) {
|
||||
required.add(field);
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableSet(required);
|
||||
}
|
||||
|
||||
private void setFieldsAccessible(Iterable<Field> fields, boolean accessible) {
|
||||
for (Field field : fields) {
|
||||
field.setAccessible(accessible);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Adapter<T> extends TypeAdapter<T> {
|
||||
@NonNull private final TypeAdapter<T> delegate;
|
||||
@NonNull private final Set<Field> requiredFields;
|
||||
|
||||
private Adapter(@NonNull TypeAdapter<T> delegate, @NonNull final Set<Field> requiredFields) {
|
||||
this.delegate = delegate;
|
||||
this.requiredFields = requiredFields;
|
||||
}
|
||||
|
||||
@Override public void write(JsonWriter out, T value) throws IOException {
|
||||
delegate.write(out, value);
|
||||
}
|
||||
|
||||
@Override @Nullable public T read(JsonReader in) throws IOException {
|
||||
T deserialized = delegate.read(in);
|
||||
return allRequiredFieldsPresent(deserialized, requiredFields) ? deserialized : null;
|
||||
}
|
||||
|
||||
private boolean allRequiredFieldsPresent(@NonNull T deserialized,
|
||||
@NonNull Set<Field> required) {
|
||||
for (Field field : required) {
|
||||
try {
|
||||
if (field.get(deserialized) == null) {
|
||||
return false;
|
||||
}
|
||||
} catch (IllegalArgumentException | IllegalAccessException e) {
|
||||
throw new JsonParseException(e);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package fr.free.nrw.commons.wikidata.json
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParseException
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.TypeAdapterFactory
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.google.gson.stream.JsonWriter
|
||||
import fr.free.nrw.commons.wikidata.json.annotations.Required
|
||||
import java.io.IOException
|
||||
import java.lang.reflect.Field
|
||||
|
||||
/**
|
||||
* TypeAdapterFactory that provides TypeAdapters that return null values for objects that are
|
||||
* missing fields annotated with @Required.
|
||||
*
|
||||
* BEWARE: This means that a List or other Collection of objects that have @Required fields can
|
||||
* contain null elements after deserialization!
|
||||
*
|
||||
* TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements
|
||||
* annotation and another corresponding TypeAdapter(Factory).
|
||||
*/
|
||||
class RequiredFieldsCheckOnReadTypeAdapterFactory : TypeAdapterFactory {
|
||||
override fun <T> create(gson: Gson, typeToken: TypeToken<T>): TypeAdapter<T>? {
|
||||
val rawType: Class<*> = typeToken.rawType
|
||||
val requiredFields = collectRequiredFields(rawType)
|
||||
|
||||
if (requiredFields.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (field in requiredFields) {
|
||||
field.isAccessible = true
|
||||
}
|
||||
|
||||
return Adapter(gson.getDelegateAdapter(this, typeToken), requiredFields)
|
||||
}
|
||||
|
||||
private fun collectRequiredFields(clazz: Class<*>): Set<Field> = buildSet {
|
||||
for (field in clazz.declaredFields) {
|
||||
if (field.isAnnotationPresent(Required::class.java)) add(field)
|
||||
}
|
||||
}
|
||||
|
||||
private class Adapter<T>(
|
||||
private val delegate: TypeAdapter<T>,
|
||||
private val requiredFields: Set<Field>
|
||||
) : TypeAdapter<T>() {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun write(out: JsonWriter, value: T?) =
|
||||
delegate.write(out, value)
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(reader: JsonReader): T? =
|
||||
if (allRequiredFieldsPresent(delegate.read(reader), requiredFields))
|
||||
delegate.read(reader)
|
||||
else
|
||||
null
|
||||
|
||||
fun allRequiredFieldsPresent(deserialized: T, required: Set<Field>): Boolean {
|
||||
for (field in required) {
|
||||
try {
|
||||
if (field[deserialized] == null) return false
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw JsonParseException(e)
|
||||
} catch (e: IllegalAccessException) {
|
||||
throw JsonParseException(e)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata.json;
|
||||
|
||||
/*
|
||||
* Copyright (C) 2011 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import android.util.Log;
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.TypeAdapterFactory;
|
||||
import com.google.gson.internal.Streams;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
|
||||
/**
|
||||
* Adapts values whose runtime type may differ from their declaration type. This
|
||||
* is necessary when a field's type is not the same type that GSON should create
|
||||
* when deserializing that field. For example, consider these types:
|
||||
* <pre> {@code
|
||||
* abstract class Shape {
|
||||
* int x;
|
||||
* int y;
|
||||
* }
|
||||
* class Circle extends Shape {
|
||||
* int radius;
|
||||
* }
|
||||
* class Rectangle extends Shape {
|
||||
* int width;
|
||||
* int height;
|
||||
* }
|
||||
* class Diamond extends Shape {
|
||||
* int width;
|
||||
* int height;
|
||||
* }
|
||||
* class Drawing {
|
||||
* Shape bottomShape;
|
||||
* Shape topShape;
|
||||
* }
|
||||
* }</pre>
|
||||
* <p>Without additional type information, the serialized JSON is ambiguous. Is
|
||||
* the bottom shape in this drawing a rectangle or a diamond? <pre> {@code
|
||||
* {
|
||||
* "bottomShape": {
|
||||
* "width": 10,
|
||||
* "height": 5,
|
||||
* "x": 0,
|
||||
* "y": 0
|
||||
* },
|
||||
* "topShape": {
|
||||
* "radius": 2,
|
||||
* "x": 4,
|
||||
* "y": 1
|
||||
* }
|
||||
* }}</pre>
|
||||
* This class addresses this problem by adding type information to the
|
||||
* serialized JSON and honoring that type information when the JSON is
|
||||
* deserialized: <pre> {@code
|
||||
* {
|
||||
* "bottomShape": {
|
||||
* "type": "Diamond",
|
||||
* "width": 10,
|
||||
* "height": 5,
|
||||
* "x": 0,
|
||||
* "y": 0
|
||||
* },
|
||||
* "topShape": {
|
||||
* "type": "Circle",
|
||||
* "radius": 2,
|
||||
* "x": 4,
|
||||
* "y": 1
|
||||
* }
|
||||
* }}</pre>
|
||||
* Both the type field name ({@code "type"}) and the type labels ({@code
|
||||
* "Rectangle"}) are configurable.
|
||||
*
|
||||
* <h3>Registering Types</h3>
|
||||
* Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field
|
||||
* name to the {@link #of} factory method. If you don't supply an explicit type
|
||||
* field name, {@code "type"} will be used. <pre> {@code
|
||||
* RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory
|
||||
* = RuntimeTypeAdapterFactory.of(Shape.class, "type");
|
||||
* }</pre>
|
||||
* Next register all of your subtypes. Every subtype must be explicitly
|
||||
* registered. This protects your application from injection attacks. If you
|
||||
* don't supply an explicit type label, the type's simple name will be used.
|
||||
* <pre> {@code
|
||||
* shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
|
||||
* shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
|
||||
* shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
|
||||
* }</pre>
|
||||
* Finally, register the type adapter factory in your application's GSON builder:
|
||||
* <pre> {@code
|
||||
* Gson gson = new GsonBuilder()
|
||||
* .registerTypeAdapterFactory(shapeAdapterFactory)
|
||||
* .create();
|
||||
* }</pre>
|
||||
* Like {@code GsonBuilder}, this API supports chaining: <pre> {@code
|
||||
* RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
|
||||
* .registerSubtype(Rectangle.class)
|
||||
* .registerSubtype(Circle.class)
|
||||
* .registerSubtype(Diamond.class);
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>Serialization and deserialization</h3>
|
||||
* In order to serialize and deserialize a polymorphic object,
|
||||
* you must specify the base type explicitly.
|
||||
* <pre> {@code
|
||||
* Diamond diamond = new Diamond();
|
||||
* String json = gson.toJson(diamond, Shape.class);
|
||||
* }</pre>
|
||||
* And then:
|
||||
* <pre> {@code
|
||||
* Shape shape = gson.fromJson(json, Shape.class);
|
||||
* }</pre>
|
||||
*/
|
||||
public final class RuntimeTypeAdapterFactory<T> implements TypeAdapterFactory {
|
||||
private final Class<?> baseType;
|
||||
private final String typeFieldName;
|
||||
private final Map<String, Class<?>> labelToSubtype = new LinkedHashMap<String, Class<?>>();
|
||||
private final Map<Class<?>, String> subtypeToLabel = new LinkedHashMap<Class<?>, String>();
|
||||
private final boolean maintainType;
|
||||
|
||||
private RuntimeTypeAdapterFactory(Class<?> baseType, String typeFieldName, boolean maintainType) {
|
||||
if (typeFieldName == null || baseType == null) {
|
||||
throw new NullPointerException();
|
||||
}
|
||||
this.baseType = baseType;
|
||||
this.typeFieldName = typeFieldName;
|
||||
this.maintainType = maintainType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new runtime type adapter using for {@code baseType} using {@code
|
||||
* typeFieldName} as the type field name. Type field names are case sensitive.
|
||||
* {@code maintainType} flag decide if the type will be stored in pojo or not.
|
||||
*/
|
||||
public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName, boolean maintainType) {
|
||||
return new RuntimeTypeAdapterFactory<T>(baseType, typeFieldName, maintainType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new runtime type adapter using for {@code baseType} using {@code
|
||||
* typeFieldName} as the type field name. Type field names are case sensitive.
|
||||
*/
|
||||
public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName) {
|
||||
return new RuntimeTypeAdapterFactory<T>(baseType, typeFieldName, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new runtime type adapter for {@code baseType} using {@code "type"} as
|
||||
* the type field name.
|
||||
*/
|
||||
public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType) {
|
||||
return new RuntimeTypeAdapterFactory<T>(baseType, "type", false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers {@code type} identified by {@code label}. Labels are case
|
||||
* sensitive.
|
||||
*
|
||||
* @throws IllegalArgumentException if either {@code type} or {@code label}
|
||||
* have already been registered on this type adapter.
|
||||
*/
|
||||
public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type, String label) {
|
||||
if (type == null || label == null) {
|
||||
throw new NullPointerException();
|
||||
}
|
||||
if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) {
|
||||
throw new IllegalArgumentException("types and labels must be unique");
|
||||
}
|
||||
labelToSubtype.put(label, type);
|
||||
subtypeToLabel.put(type, label);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers {@code type} identified by its {@link Class#getSimpleName simple
|
||||
* name}. Labels are case sensitive.
|
||||
*
|
||||
* @throws IllegalArgumentException if either {@code type} or its simple name
|
||||
* have already been registered on this type adapter.
|
||||
*/
|
||||
public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type) {
|
||||
return registerSubtype(type, type.getSimpleName());
|
||||
}
|
||||
|
||||
public <R> TypeAdapter<R> create(Gson gson, TypeToken<R> type) {
|
||||
if (type.getRawType() != baseType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, TypeAdapter<?>> labelToDelegate
|
||||
= new LinkedHashMap<String, TypeAdapter<?>>();
|
||||
final Map<Class<?>, TypeAdapter<?>> subtypeToDelegate
|
||||
= new LinkedHashMap<Class<?>, TypeAdapter<?>>();
|
||||
for (Map.Entry<String, Class<?>> entry : labelToSubtype.entrySet()) {
|
||||
TypeAdapter<?> delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue()));
|
||||
labelToDelegate.put(entry.getKey(), delegate);
|
||||
subtypeToDelegate.put(entry.getValue(), delegate);
|
||||
}
|
||||
|
||||
return new TypeAdapter<R>() {
|
||||
@Override public R read(JsonReader in) throws IOException {
|
||||
JsonElement jsonElement = Streams.parse(in);
|
||||
JsonElement labelJsonElement;
|
||||
if (maintainType) {
|
||||
labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName);
|
||||
} else {
|
||||
labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName);
|
||||
}
|
||||
|
||||
if (labelJsonElement == null) {
|
||||
throw new JsonParseException("cannot deserialize " + baseType
|
||||
+ " because it does not define a field named " + typeFieldName);
|
||||
}
|
||||
String label = labelJsonElement.getAsString();
|
||||
@SuppressWarnings("unchecked") // registration requires that subtype extends T
|
||||
TypeAdapter<R> delegate = (TypeAdapter<R>) labelToDelegate.get(label);
|
||||
if (delegate == null) {
|
||||
|
||||
Log.e("RuntimeTypeAdapter", "cannot deserialize " + baseType + " subtype named "
|
||||
+ label + "; did you forget to register a subtype? " +jsonElement);
|
||||
return null;
|
||||
}
|
||||
return delegate.fromJsonTree(jsonElement);
|
||||
}
|
||||
|
||||
@Override public void write(JsonWriter out, R value) throws IOException {
|
||||
Class<?> srcType = value.getClass();
|
||||
String label = subtypeToLabel.get(srcType);
|
||||
@SuppressWarnings("unchecked") // registration requires that subtype extends T
|
||||
TypeAdapter<R> delegate = (TypeAdapter<R>) subtypeToDelegate.get(srcType);
|
||||
if (delegate == null) {
|
||||
throw new JsonParseException("cannot serialize " + srcType.getName()
|
||||
+ "; did you forget to register a subtype?");
|
||||
}
|
||||
JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject();
|
||||
|
||||
if (maintainType) {
|
||||
Streams.write(jsonObject, out);
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject clone = new JsonObject();
|
||||
|
||||
if (jsonObject.has(typeFieldName)) {
|
||||
throw new JsonParseException("cannot serialize " + srcType.getName()
|
||||
+ " because it already defines a field named " + typeFieldName);
|
||||
}
|
||||
clone.add(typeFieldName, new JsonPrimitive(label));
|
||||
|
||||
for (Map.Entry<String, JsonElement> e : jsonObject.entrySet()) {
|
||||
clone.add(e.getKey(), e.getValue());
|
||||
}
|
||||
Streams.write(clone, out);
|
||||
}
|
||||
}.nullSafe();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
package fr.free.nrw.commons.wikidata.json
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParseException
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.TypeAdapterFactory
|
||||
import com.google.gson.internal.Streams
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.google.gson.stream.JsonWriter
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
|
||||
/*
|
||||
* Copyright (C) 2011 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Adapts values whose runtime type may differ from their declaration type. This
|
||||
* is necessary when a field's type is not the same type that GSON should create
|
||||
* when deserializing that field. For example, consider these types:
|
||||
* <pre> `abstract class Shape {
|
||||
* int x;
|
||||
* int y;
|
||||
* }
|
||||
* class Circle extends Shape {
|
||||
* int radius;
|
||||
* }
|
||||
* class Rectangle extends Shape {
|
||||
* int width;
|
||||
* int height;
|
||||
* }
|
||||
* class Diamond extends Shape {
|
||||
* int width;
|
||||
* int height;
|
||||
* }
|
||||
* class Drawing {
|
||||
* Shape bottomShape;
|
||||
* Shape topShape;
|
||||
* }
|
||||
`</pre> *
|
||||
*
|
||||
* Without additional type information, the serialized JSON is ambiguous. Is
|
||||
* the bottom shape in this drawing a rectangle or a diamond? <pre> `{
|
||||
* "bottomShape": {
|
||||
* "width": 10,
|
||||
* "height": 5,
|
||||
* "x": 0,
|
||||
* "y": 0
|
||||
* },
|
||||
* "topShape": {
|
||||
* "radius": 2,
|
||||
* "x": 4,
|
||||
* "y": 1
|
||||
* }
|
||||
* }`</pre>
|
||||
* This class addresses this problem by adding type information to the
|
||||
* serialized JSON and honoring that type information when the JSON is
|
||||
* deserialized: <pre> `{
|
||||
* "bottomShape": {
|
||||
* "type": "Diamond",
|
||||
* "width": 10,
|
||||
* "height": 5,
|
||||
* "x": 0,
|
||||
* "y": 0
|
||||
* },
|
||||
* "topShape": {
|
||||
* "type": "Circle",
|
||||
* "radius": 2,
|
||||
* "x": 4,
|
||||
* "y": 1
|
||||
* }
|
||||
* }`</pre>
|
||||
* Both the type field name (`"type"`) and the type labels (`"Rectangle"`) are configurable.
|
||||
*
|
||||
* <h3>Registering Types</h3>
|
||||
* Create a `RuntimeTypeAdapterFactory` by passing the base type and type field
|
||||
* name to the [.of] factory method. If you don't supply an explicit type
|
||||
* field name, `"type"` will be used. <pre> `RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory
|
||||
* = RuntimeTypeAdapterFactory.of(Shape.class, "type");
|
||||
`</pre> *
|
||||
* Next register all of your subtypes. Every subtype must be explicitly
|
||||
* registered. This protects your application from injection attacks. If you
|
||||
* don't supply an explicit type label, the type's simple name will be used.
|
||||
* <pre> `shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
|
||||
* shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
|
||||
* shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
|
||||
`</pre> *
|
||||
* Finally, register the type adapter factory in your application's GSON builder:
|
||||
* <pre> `Gson gson = new GsonBuilder()
|
||||
* .registerTypeAdapterFactory(shapeAdapterFactory)
|
||||
* .create();
|
||||
`</pre> *
|
||||
* Like `GsonBuilder`, this API supports chaining: <pre> `RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
|
||||
* .registerSubtype(Rectangle.class)
|
||||
* .registerSubtype(Circle.class)
|
||||
* .registerSubtype(Diamond.class);
|
||||
`</pre> *
|
||||
*
|
||||
* <h3>Serialization and deserialization</h3>
|
||||
* In order to serialize and deserialize a polymorphic object,
|
||||
* you must specify the base type explicitly.
|
||||
* <pre> `Diamond diamond = new Diamond();
|
||||
* String json = gson.toJson(diamond, Shape.class);
|
||||
`</pre> *
|
||||
* And then:
|
||||
* <pre> `Shape shape = gson.fromJson(json, Shape.class);
|
||||
`</pre> *
|
||||
*/
|
||||
class RuntimeTypeAdapterFactory<T>(
|
||||
baseType: Class<*>?,
|
||||
typeFieldName: String?,
|
||||
maintainType: Boolean
|
||||
) : TypeAdapterFactory {
|
||||
|
||||
private val baseType: Class<*>
|
||||
private val typeFieldName: String
|
||||
private val labelToSubtype = mutableMapOf<String, Class<*>>()
|
||||
private val subtypeToLabel = mutableMapOf<Class<*>, String>()
|
||||
private val maintainType: Boolean
|
||||
|
||||
init {
|
||||
if (typeFieldName == null || baseType == null) {
|
||||
throw NullPointerException()
|
||||
}
|
||||
this.baseType = baseType
|
||||
this.typeFieldName = typeFieldName
|
||||
this.maintainType = maintainType
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers `type` identified by `label`. Labels are case
|
||||
* sensitive.
|
||||
*
|
||||
* @throws IllegalArgumentException if either `type` or `label`
|
||||
* have already been registered on this type adapter.
|
||||
*/
|
||||
fun registerSubtype(type: Class<out T>?, label: String?): RuntimeTypeAdapterFactory<T> {
|
||||
if (type == null || label == null) {
|
||||
throw NullPointerException()
|
||||
}
|
||||
require(!(subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label))) {
|
||||
"types and labels must be unique"
|
||||
}
|
||||
|
||||
labelToSubtype[label] = type
|
||||
subtypeToLabel[type] = label
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers `type` identified by its [simple][Class.getSimpleName]. Labels are case sensitive.
|
||||
*
|
||||
* @throws IllegalArgumentException if either `type` or its simple name
|
||||
* have already been registered on this type adapter.
|
||||
*/
|
||||
fun registerSubtype(type: Class<out T>): RuntimeTypeAdapterFactory<T> {
|
||||
return registerSubtype(type, type.simpleName)
|
||||
}
|
||||
|
||||
override fun <R : Any> create(gson: Gson, type: TypeToken<R>): TypeAdapter<R>? {
|
||||
if (type.rawType != baseType) {
|
||||
return null
|
||||
}
|
||||
|
||||
val labelToDelegate = mutableMapOf<String, TypeAdapter<*>>()
|
||||
val subtypeToDelegate = mutableMapOf<Class<*>, TypeAdapter<*>>()
|
||||
for ((key, value) in labelToSubtype) {
|
||||
val delegate = gson.getDelegateAdapter(
|
||||
this, TypeToken.get(
|
||||
value
|
||||
)
|
||||
)
|
||||
labelToDelegate[key] = delegate
|
||||
subtypeToDelegate[value] = delegate
|
||||
}
|
||||
|
||||
return object : TypeAdapter<R>() {
|
||||
@Throws(IOException::class)
|
||||
override fun read(reader: JsonReader): R? {
|
||||
val jsonElement = Streams.parse(reader)
|
||||
val labelJsonElement = if (maintainType) {
|
||||
jsonElement.asJsonObject[typeFieldName]
|
||||
} else {
|
||||
jsonElement.asJsonObject.remove(typeFieldName)
|
||||
}
|
||||
|
||||
if (labelJsonElement == null) {
|
||||
throw JsonParseException(
|
||||
"cannot deserialize $baseType because it does not define a field named $typeFieldName"
|
||||
)
|
||||
}
|
||||
val label = labelJsonElement.asString
|
||||
val delegate = labelToDelegate[label] as TypeAdapter<R>?
|
||||
if (delegate == null) {
|
||||
Timber.tag("RuntimeTypeAdapter").e(
|
||||
"cannot deserialize $baseType subtype named $label; did you forget to register a subtype? $jsonElement"
|
||||
)
|
||||
return null
|
||||
}
|
||||
return delegate.fromJsonTree(jsonElement)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun write(out: JsonWriter, value: R) {
|
||||
val srcType: Class<*> = value::class.java.javaClass
|
||||
val delegate =
|
||||
subtypeToDelegate[srcType] as TypeAdapter<R?>? ?: throw JsonParseException(
|
||||
"cannot serialize ${srcType.name}; did you forget to register a subtype?"
|
||||
)
|
||||
|
||||
val jsonObject = delegate.toJsonTree(value).asJsonObject
|
||||
if (maintainType) {
|
||||
Streams.write(jsonObject, out)
|
||||
return
|
||||
}
|
||||
|
||||
if (jsonObject.has(typeFieldName)) {
|
||||
throw JsonParseException(
|
||||
"cannot serialize ${srcType.name} because it already defines a field named $typeFieldName"
|
||||
)
|
||||
}
|
||||
val clone = JsonObject()
|
||||
val label = subtypeToLabel[srcType]
|
||||
clone.add(typeFieldName, JsonPrimitive(label))
|
||||
for ((key, value1) in jsonObject.entrySet()) {
|
||||
clone.add(key, value1)
|
||||
}
|
||||
Streams.write(clone, out)
|
||||
}
|
||||
}.nullSafe()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive.
|
||||
* `maintainType` flag decide if the type will be stored in pojo or not.
|
||||
*/
|
||||
fun <T> of(
|
||||
baseType: Class<T>,
|
||||
typeFieldName: String,
|
||||
maintainType: Boolean
|
||||
): RuntimeTypeAdapterFactory<T> =
|
||||
RuntimeTypeAdapterFactory(baseType, typeFieldName, maintainType)
|
||||
|
||||
/**
|
||||
* Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive.
|
||||
*/
|
||||
fun <T> of(baseType: Class<T>, typeFieldName: String): RuntimeTypeAdapterFactory<T> =
|
||||
RuntimeTypeAdapterFactory(baseType, typeFieldName, false)
|
||||
|
||||
/**
|
||||
* Creates a new runtime type adapter for `baseType` using `"type"` as
|
||||
* the type field name.
|
||||
*/
|
||||
fun <T> of(baseType: Class<T>): RuntimeTypeAdapterFactory<T> =
|
||||
RuntimeTypeAdapterFactory(baseType, "type", false)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata.json;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class UriTypeAdapter extends TypeAdapter<Uri> {
|
||||
@Override
|
||||
public void write(JsonWriter out, Uri value) throws IOException {
|
||||
out.value(value.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri read(JsonReader in) throws IOException {
|
||||
String url = in.nextString();
|
||||
return Uri.parse(url);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package fr.free.nrw.commons.wikidata.json
|
||||
|
||||
import android.net.Uri
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.google.gson.stream.JsonWriter
|
||||
import java.io.IOException
|
||||
|
||||
class UriTypeAdapter : TypeAdapter<Uri>() {
|
||||
@Throws(IOException::class)
|
||||
override fun write(out: JsonWriter, value: Uri) {
|
||||
out.value(value.toString())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(reader: JsonReader): Uri {
|
||||
return Uri.parse(reader.nextString())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata.json;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
|
||||
import fr.free.nrw.commons.wikidata.model.WikiSite;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class WikiSiteTypeAdapter extends TypeAdapter<WikiSite> {
|
||||
private static final String DOMAIN = "domain";
|
||||
private static final String LANGUAGE_CODE = "languageCode";
|
||||
|
||||
@Override public void write(JsonWriter out, WikiSite value) throws IOException {
|
||||
out.beginObject();
|
||||
out.name(DOMAIN);
|
||||
out.value(value.url());
|
||||
|
||||
out.name(LANGUAGE_CODE);
|
||||
out.value(value.languageCode());
|
||||
out.endObject();
|
||||
}
|
||||
|
||||
@Override public WikiSite read(JsonReader in) throws IOException {
|
||||
// todo: legacy; remove in June 2018
|
||||
if (in.peek() == JsonToken.STRING) {
|
||||
return new WikiSite(Uri.parse(in.nextString()));
|
||||
}
|
||||
|
||||
String domain = null;
|
||||
String languageCode = null;
|
||||
in.beginObject();
|
||||
while (in.hasNext()) {
|
||||
String field = in.nextName();
|
||||
String val = in.nextString();
|
||||
switch (field) {
|
||||
case DOMAIN:
|
||||
domain = val;
|
||||
break;
|
||||
case LANGUAGE_CODE:
|
||||
languageCode = val;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
in.endObject();
|
||||
|
||||
if (domain == null) {
|
||||
throw new JsonParseException("Missing domain");
|
||||
}
|
||||
|
||||
// todo: legacy; remove in June 2018
|
||||
if (languageCode == null) {
|
||||
return new WikiSite(domain);
|
||||
}
|
||||
return new WikiSite(domain, languageCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package fr.free.nrw.commons.wikidata.json
|
||||
|
||||
import android.net.Uri
|
||||
import com.google.gson.JsonParseException
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.google.gson.stream.JsonToken
|
||||
import com.google.gson.stream.JsonWriter
|
||||
import fr.free.nrw.commons.wikidata.model.WikiSite
|
||||
import java.io.IOException
|
||||
|
||||
class WikiSiteTypeAdapter : TypeAdapter<WikiSite>() {
|
||||
@Throws(IOException::class)
|
||||
override fun write(out: JsonWriter, value: WikiSite) {
|
||||
out.beginObject()
|
||||
out.name(DOMAIN)
|
||||
out.value(value.url())
|
||||
|
||||
out.name(LANGUAGE_CODE)
|
||||
out.value(value.languageCode())
|
||||
out.endObject()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(reader: JsonReader): WikiSite {
|
||||
// todo: legacy; remove reader June 2018
|
||||
if (reader.peek() == JsonToken.STRING) {
|
||||
return WikiSite(Uri.parse(reader.nextString()))
|
||||
}
|
||||
|
||||
var domain: String? = null
|
||||
var languageCode: String? = null
|
||||
reader.beginObject()
|
||||
while (reader.hasNext()) {
|
||||
val field = reader.nextName()
|
||||
val value = reader.nextString()
|
||||
when (field) {
|
||||
DOMAIN -> domain = value
|
||||
LANGUAGE_CODE -> languageCode = value
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
reader.endObject()
|
||||
|
||||
if (domain == null) {
|
||||
throw JsonParseException("Missing domain")
|
||||
}
|
||||
|
||||
// todo: legacy; remove reader June 2018
|
||||
return if (languageCode == null) {
|
||||
WikiSite(domain)
|
||||
} else {
|
||||
WikiSite(domain, languageCode)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DOMAIN = "domain"
|
||||
private const val LANGUAGE_CODE = "languageCode"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata.json.annotations;
|
||||
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import static java.lang.annotation.ElementType.FIELD;
|
||||
|
||||
/**
|
||||
* Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return
|
||||
* an instantiated object.
|
||||
*
|
||||
* E.g.: @NonNull @Required private String title;
|
||||
*/
|
||||
@Documented
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(FIELD)
|
||||
public @interface Required {
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package fr.free.nrw.commons.wikidata.json.annotations
|
||||
|
||||
|
||||
/**
|
||||
* Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return
|
||||
* an instantiated object.
|
||||
*
|
||||
* E.g.: @NonNull @Required private String title;
|
||||
*/
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FIELD)
|
||||
annotation class Required
|
||||
|
|
@ -148,7 +148,7 @@ public class Notification {
|
|||
return null;
|
||||
}
|
||||
if (primaryLink == null && primary instanceof JsonObject) {
|
||||
primaryLink = GsonUtil.getDefaultGson().fromJson(primary, Link.class);
|
||||
primaryLink = GsonUtil.INSTANCE.getDefaultGson().fromJson(primary, Link.class);
|
||||
}
|
||||
return primaryLink;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
package fr.free.nrw.commons.wikidata.mwapi;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
public class UserInfo {
|
||||
@NonNull private String name;
|
||||
@NonNull private int id;
|
||||
|
||||
//Block information
|
||||
private int blockid;
|
||||
private String blockedby;
|
||||
private int blockedbyid;
|
||||
private String blockreason;
|
||||
private String blocktimestamp;
|
||||
private String blockexpiry;
|
||||
|
||||
// Object type is any JSON type.
|
||||
@SuppressWarnings("MismatchedQueryAndUpdateOfCollection")
|
||||
@Nullable private Map<String, ?> options;
|
||||
|
||||
public int id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String blockexpiry() {
|
||||
if (blockexpiry != null)
|
||||
return blockexpiry;
|
||||
else return "";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package fr.free.nrw.commons.wikidata.mwapi
|
||||
|
||||
data class UserInfo(
|
||||
val name: String = "",
|
||||
val id: Int = 0,
|
||||
|
||||
//Block information
|
||||
val blockid: Int = 0,
|
||||
val blockedby: String? = null,
|
||||
val blockedbyid: Int = 0,
|
||||
val blockreason: String? = null,
|
||||
val blocktimestamp: String? = null,
|
||||
val blockexpiry: String? = null,
|
||||
|
||||
// Object type is any JSON type.
|
||||
val options: Map<String, *>? = null
|
||||
) {
|
||||
fun id(): Int = id
|
||||
|
||||
fun blockexpiry(): String = blockexpiry ?: ""
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
* Okkerem
|
||||
* Oyuncu
|
||||
* Rapsar
|
||||
* RuzDD
|
||||
* SaldırganSincap
|
||||
* Sayginer
|
||||
* Sezgin İbiş
|
||||
|
|
@ -146,6 +147,7 @@
|
|||
<string name="categories_search_text_hint">Kategori ara</string>
|
||||
<string name="depicts_search_text_hint">Medyanızın tasvir ettiği ögeleri arayın (dağ, Tac Mahal, vb.)</string>
|
||||
<string name="menu_save_categories">Kaydet</string>
|
||||
<string name="menu_overflow_desc">Taşma menüsü</string>
|
||||
<string name="refresh_button">Yenile</string>
|
||||
<string name="display_list_button">Liste</string>
|
||||
<string name="contributions_subtitle_zero">!Henüz yükleme yok)</string>
|
||||
|
|
@ -800,6 +802,7 @@
|
|||
<string name="please_enter_some_comments">Lütfen bir yorum girin</string>
|
||||
<string name="talk">Tartışma</string>
|
||||
<string name="write_something_about_the_item">\' %1$s \' öğesi hakkında bir şeyler yazın. Herkes tarafından görülebilir olacaktır.</string>
|
||||
<string name="does_not_exist_anymore_no_picture_can_ever_be_taken_of_it">\'%1$s\' artık yok, dolayısı ile resmi çekilemez.</string>
|
||||
<string name="other_problem_or_information_please_explain_below">Diğer sorun veya bilgi (lütfen aşağıda açıklayınız).</string>
|
||||
<string name="feedback_destination_note">Geri bildiriminiz aşağıdaki wiki sayfasına gönderilir: <a href=\"https://commons.wikimedia.org/wiki/Commons:Mobile_app/Feedback\">Commons:Mobile app/Feedback</a></string>
|
||||
<string name="are_you_sure_that_you_want_cancel_all_the_uploads">Tüm yüklemeleri iptal etmek istediğinizden emin misiniz?</string>
|
||||
|
|
@ -807,5 +810,10 @@
|
|||
<string name="uploads">Yüklemeler</string>
|
||||
<string name="pending">Beklemede</string>
|
||||
<string name="failed">Başarısız</string>
|
||||
<string name="custom_selector_delete">Sil</string>
|
||||
<string name="custom_selector_cancel">İptal</string>
|
||||
<string name="custom_selector_folder_deleted_success">%1$s klasörü başarıyla silindi</string>
|
||||
<string name="custom_selector_folder_deleted_failure">%1$s klasörü silinemedi</string>
|
||||
<string name="green_pin">Bu yerin zaten bir resmi var.</string>
|
||||
<string name="grey_pin">Şimdi bu yerin bir resime sahip olup olmadığı denetleniyor.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ public abstract class MockWebServerTest {
|
|||
.baseUrl(url)
|
||||
.callbackExecutor(new ImmediateExecutor())
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson()))
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonUtil.INSTANCE.getDefaultGson()))
|
||||
.build()
|
||||
.create(clazz);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,13 +49,13 @@ class CampaignsPresenterTest {
|
|||
campaignsSingle = Single.just(campaignResponseDTO)
|
||||
campaignsPresenter = CampaignsPresenter(okHttpJsonApiClient, testScheduler, testScheduler)
|
||||
campaignsPresenter.onAttachView(view)
|
||||
Mockito.`when`(okHttpJsonApiClient.campaigns).thenReturn(campaignsSingle)
|
||||
Mockito.`when`(okHttpJsonApiClient.getCampaigns()).thenReturn(campaignsSingle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getCampaignsTestNoCampaigns() {
|
||||
campaignsPresenter.getCampaigns()
|
||||
verify(okHttpJsonApiClient).campaigns
|
||||
verify(okHttpJsonApiClient).getCampaigns()
|
||||
testScheduler.triggerActions()
|
||||
verify(view).showCampaigns(null)
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ class CampaignsPresenterTest {
|
|||
Mockito.`when`(campaign.endDate).thenReturn(endDateString)
|
||||
Mockito.`when`(campaign.startDate).thenReturn(startDateString)
|
||||
Mockito.`when`(campaignResponseDTO.campaigns).thenReturn(campaigns)
|
||||
verify(okHttpJsonApiClient).campaigns
|
||||
verify(okHttpJsonApiClient).getCampaigns()
|
||||
testScheduler.triggerActions()
|
||||
verify(view).showCampaigns(campaign)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
package fr.free.nrw.commons.filepicker;
|
||||
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.OpenableColumns;
|
||||
import androidx.core.content.FileProvider;
|
||||
import org.robolectric.annotation.Implementation;
|
||||
import org.robolectric.annotation.Implements;
|
||||
|
||||
@Implements(FileProvider.class)
|
||||
public class ShadowFileProvider {
|
||||
|
||||
@Implementation
|
||||
public Cursor query(final Uri uri, final String[] projection, final String selection,
|
||||
final String[] selectionArgs,
|
||||
final String sortOrder) {
|
||||
|
||||
if (uri == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final String[] columns = {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
|
||||
final Object[] values = {"dummy", 500};
|
||||
final MatrixCursor cursor = new MatrixCursor(columns, 1);
|
||||
|
||||
if (!uri.equals(Uri.EMPTY)) {
|
||||
cursor.addRow(values);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package fr.free.nrw.commons.filepicker
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.content.FileProvider
|
||||
import org.robolectric.annotation.Implementation
|
||||
import org.robolectric.annotation.Implements
|
||||
|
||||
@Implements(FileProvider::class)
|
||||
class ShadowFileProvider {
|
||||
|
||||
@Implementation
|
||||
fun query(
|
||||
uri: Uri?,
|
||||
projection: Array<String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?,
|
||||
sortOrder: String?
|
||||
): Cursor? {
|
||||
|
||||
if (uri == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val columns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
||||
val values = arrayOf<Any>("dummy", 500)
|
||||
val cursor = MatrixCursor(columns, 1)
|
||||
|
||||
if (uri != Uri.EMPTY) {
|
||||
cursor.addRow(values)
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
|
|
@ -30,8 +30,7 @@ class UserClientTest {
|
|||
|
||||
@Test
|
||||
fun isUserBlockedFromCommonsForInfinitelyBlockedUser() {
|
||||
val userInfo = Mockito.mock(UserInfo::class.java)
|
||||
Mockito.`when`(userInfo.blockexpiry()).thenReturn("infinite")
|
||||
val userInfo = UserInfo(blockexpiry = "infinite")
|
||||
val mwQueryResult = Mockito.mock(MwQueryResult::class.java)
|
||||
Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo)
|
||||
val mockResponse = Mockito.mock(MwQueryResponse::class.java)
|
||||
|
|
@ -49,8 +48,7 @@ class UserClientTest {
|
|||
val currentDate = Date()
|
||||
val expiredDate = Date(currentDate.time + 10000)
|
||||
|
||||
val userInfo = Mockito.mock(UserInfo::class.java)
|
||||
Mockito.`when`(userInfo.blockexpiry()).thenReturn(DateUtil.iso8601DateFormat(expiredDate))
|
||||
val userInfo = UserInfo(blockexpiry = DateUtil.iso8601DateFormat(expiredDate))
|
||||
val mwQueryResult = Mockito.mock(MwQueryResult::class.java)
|
||||
Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo)
|
||||
val mockResponse = Mockito.mock(MwQueryResponse::class.java)
|
||||
|
|
@ -65,8 +63,7 @@ class UserClientTest {
|
|||
|
||||
@Test
|
||||
fun isUserBlockedFromCommonsForNeverBlockedUser() {
|
||||
val userInfo = Mockito.mock(UserInfo::class.java)
|
||||
Mockito.`when`(userInfo.blockexpiry()).thenReturn("")
|
||||
val userInfo = UserInfo(blockexpiry = "")
|
||||
val mwQueryResult = Mockito.mock(MwQueryResult::class.java)
|
||||
Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo)
|
||||
val mockResponse = Mockito.mock(MwQueryResponse::class.java)
|
||||
|
|
|
|||
|
|
@ -325,7 +325,7 @@ class NearbyParentFragmentUnitTest {
|
|||
@Throws(Exception::class)
|
||||
fun testOnDestroy() {
|
||||
fragment.onDestroy()
|
||||
verify(wikidataEditListener).setAuthenticationStateListener(null)
|
||||
verify(wikidataEditListener).authenticationStateListener = null
|
||||
}
|
||||
|
||||
@Test @Ignore
|
||||
|
|
|
|||
|
|
@ -120,26 +120,16 @@ class NotificationClientTest {
|
|||
) = Notification().apply {
|
||||
setId(notificationId)
|
||||
|
||||
setTimestamp(
|
||||
Notification.Timestamp().apply {
|
||||
setUtciso8601(timestamp)
|
||||
},
|
||||
)
|
||||
setTimestamp(Notification.Timestamp().apply { setUtciso8601(timestamp) })
|
||||
|
||||
contents =
|
||||
Notification.Contents().apply {
|
||||
setCompactHeader(compactHeader)
|
||||
contents = Notification.Contents().apply {
|
||||
setCompactHeader(compactHeader)
|
||||
|
||||
links =
|
||||
Notification.Links().apply {
|
||||
setPrimary(
|
||||
GsonUtil.getDefaultGson().toJsonTree(
|
||||
Notification.Link().apply {
|
||||
setUrl(primaryUrl)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
links = Notification.Links().apply {
|
||||
setPrimary(GsonUtil.defaultGson.toJsonTree(
|
||||
Notification.Link().apply { setUrl(primaryUrl) }
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class UploadPresenterTest {
|
|||
`when`(repository.buildContributions()).thenReturn(Observable.just(contribution))
|
||||
uploadableFiles.add(uploadableFile)
|
||||
`when`(view.uploadableFiles).thenReturn(uploadableFiles)
|
||||
`when`(uploadableFile.filePath).thenReturn("data://test")
|
||||
`when`(uploadableFile.getFilePath()).thenReturn("data://test")
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue