mirror of
				https://github.com/commons-app/apps-android-commons.git
				synced 2025-10-31 14:53:59 +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 |                 return | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             okHttpJsonApiClient.campaigns |             okHttpJsonApiClient.getCampaigns() | ||||||
|                 .observeOn(mainThreadScheduler) |                 .observeOn(mainThreadScheduler) | ||||||
|                 .subscribeOn(ioScheduler) |                 .subscribeOn(ioScheduler) | ||||||
|                 .doOnSubscribe { disposable = it } |                 .doOnSubscribe { disposable = it } | ||||||
|                 .subscribe({ campaignResponseDTO -> |                 .subscribe({ campaignResponseDTO -> | ||||||
|                     val campaigns = campaignResponseDTO.campaigns?.toMutableList() |                     val campaigns = campaignResponseDTO?.campaigns?.toMutableList() | ||||||
|                     if (campaigns.isNullOrEmpty()) { |                     if (campaigns.isNullOrEmpty()) { | ||||||
|                         Timber.e("The campaigns list is empty") |                         Timber.e("The campaigns list is empty") | ||||||
|                         view!!.showCampaigns(null) |                         view!!.showCampaigns(null) | ||||||
|  |  | ||||||
|  | @ -170,14 +170,13 @@ class NetworkingModule { | ||||||
|     @Named(NAMED_WIKI_DATA_WIKI_SITE) |     @Named(NAMED_WIKI_DATA_WIKI_SITE) | ||||||
|     fun provideWikidataWikiSite(): WikiSite = WikiSite(BuildConfig.WIKIDATA_URL) |     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. |      * 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 |      * @return returns a singleton Gson instance | ||||||
|      */ |      */ | ||||||
|     @Provides |     @Provides | ||||||
|     @Singleton |     @Singleton | ||||||
|     fun provideGson(): Gson = GsonUtil.getDefaultGson() |     fun provideGson(): Gson = GsonUtil.defaultGson | ||||||
| 
 | 
 | ||||||
|     @Provides |     @Provides | ||||||
|     @Singleton |     @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.ContributionController | ||||||
| import fr.free.nrw.commons.contributions.MainActivity | import fr.free.nrw.commons.contributions.MainActivity | ||||||
| import fr.free.nrw.commons.di.ApplicationlessInjection | 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.kvstore.JsonKvStore | ||||||
| import fr.free.nrw.commons.location.LocationServiceManager | import fr.free.nrw.commons.location.LocationServiceManager | ||||||
| import fr.free.nrw.commons.logging.CommonsLogSender | import fr.free.nrw.commons.logging.CommonsLogSender | ||||||
|  | @ -83,9 +84,17 @@ class SettingsFragment : PreferenceFragmentCompat() { | ||||||
| 
 | 
 | ||||||
|     private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> = |     private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> = | ||||||
|         registerForActivityResult(StartActivityForResult()) { result -> |         registerForActivityResult(StartActivityForResult()) { result -> | ||||||
|         contributionController.handleActivityResultWithCallback(requireActivity()) { callbacks -> |         contributionController.handleActivityResultWithCallback( | ||||||
|             contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks) |             requireActivity(), | ||||||
|         } |             object: FilePicker.HandleActivityResult { | ||||||
|  |                 override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) { | ||||||
|  |                     contributionController.onPictureReturnedFromCamera( | ||||||
|  |                         result, | ||||||
|  |                         requireActivity(), | ||||||
|  |                         callbacks | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |         }) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  |  | ||||||
|  | @ -194,7 +194,7 @@ class FileProcessor | ||||||
|             requireNotNull(imageCoordinates.decimalCoords) |             requireNotNull(imageCoordinates.decimalCoords) | ||||||
|             compositeDisposable.add( |             compositeDisposable.add( | ||||||
|                 apiCall |                 apiCall | ||||||
|                     .request(imageCoordinates.decimalCoords) |                     .request(imageCoordinates.decimalCoords!!) | ||||||
|                     .subscribeOn(Schedulers.io()) |                     .subscribeOn(Schedulers.io()) | ||||||
|                     .observeOn(Schedulers.io()) |                     .observeOn(Schedulers.io()) | ||||||
|                     .subscribe( |                     .subscribe( | ||||||
|  | @ -220,7 +220,7 @@ class FileProcessor | ||||||
|                 .concatMap { |                 .concatMap { | ||||||
|                     Observable.fromCallable { |                     Observable.fromCallable { | ||||||
|                         okHttpJsonApiClient.getNearbyPlaces( |                         okHttpJsonApiClient.getNearbyPlaces( | ||||||
|                             imageCoordinates.latLng, |                             imageCoordinates.latLng!!, | ||||||
|                             Locale.getDefault().language, |                             Locale.getDefault().language, | ||||||
|                             it, |                             it, | ||||||
|                         ) |                         ) | ||||||
|  |  | ||||||
|  | @ -496,14 +496,14 @@ class UploadWorker( | ||||||
| 
 | 
 | ||||||
|                 withContext(Dispatchers.Main) { |                 withContext(Dispatchers.Main) { | ||||||
|                     wikidataEditService.handleImageClaimResult( |                     wikidataEditService.handleImageClaimResult( | ||||||
|                         contribution.wikidataPlace, |                         contribution.wikidataPlace!!, | ||||||
|                         revisionID, |                         revisionID, | ||||||
|                     ) |                     ) | ||||||
|                 } |                 } | ||||||
|             } else { |             } else { | ||||||
|                 withContext(Dispatchers.Main) { |                 withContext(Dispatchers.Main) { | ||||||
|                     wikidataEditService.handleImageClaimResult( |                     wikidataEditService.handleImageClaimResult( | ||||||
|                         contribution.wikidataPlace, |                         contribution.wikidataPlace!!, | ||||||
|                         null, |                         null, | ||||||
|                     ) |                     ) | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  | @ -63,7 +63,7 @@ class CustomSelectorUtils { | ||||||
|                 fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) |                 fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) | ||||||
|                 val sha1 = |                 val sha1 = | ||||||
|                     fileUtilsWrapper.getSHA1( |                     fileUtilsWrapper.getSHA1( | ||||||
|                         fileUtilsWrapper.getFileInputStream(uploadableFile.filePath), |                         fileUtilsWrapper.getFileInputStream(uploadableFile.getFilePath()), | ||||||
|                     ) |                     ) | ||||||
|                 uploadableFile.file.delete() |                 uploadableFile.file.delete() | ||||||
|                 sha1 |                 sha1 | ||||||
|  |  | ||||||
|  | @ -10,11 +10,10 @@ class CommonsServiceFactory( | ||||||
| ) { | ) { | ||||||
|     val builder: Retrofit.Builder by lazy { |     val builder: Retrofit.Builder by lazy { | ||||||
|         // All instances of retrofit share this configuration, but create it lazily |         // All instances of retrofit share this configuration, but create it lazily | ||||||
|         Retrofit |         Retrofit.Builder() | ||||||
|             .Builder() |  | ||||||
|             .client(okHttpClient) |             .client(okHttpClient) | ||||||
|             .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) |             .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) | ||||||
|             .addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson())) |             .addConverterFactory(GsonConverterFactory.create(GsonUtil.defaultGson)) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     val retrofitCache: MutableMap<String, Retrofit> = mutableMapOf() |     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; |                 return null; | ||||||
|             } |             } | ||||||
|             if (primaryLink == null && primary instanceof JsonObject) { |             if (primaryLink == null && primary instanceof JsonObject) { | ||||||
|                 primaryLink = GsonUtil.getDefaultGson().fromJson(primary, Link.class); |                 primaryLink = GsonUtil.INSTANCE.getDefaultGson().fromJson(primary, Link.class); | ||||||
|             } |             } | ||||||
|             return primaryLink; |             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 | * Okkerem | ||||||
| * Oyuncu | * Oyuncu | ||||||
| * Rapsar | * Rapsar | ||||||
|  | * RuzDD | ||||||
| * SaldırganSincap | * SaldırganSincap | ||||||
| * Sayginer | * Sayginer | ||||||
| * Sezgin İbiş | * Sezgin İbiş | ||||||
|  | @ -146,6 +147,7 @@ | ||||||
|   <string name="categories_search_text_hint">Kategori ara</string> |   <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="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_save_categories">Kaydet</string> | ||||||
|  |   <string name="menu_overflow_desc">Taşma menüsü</string> | ||||||
|   <string name="refresh_button">Yenile</string> |   <string name="refresh_button">Yenile</string> | ||||||
|   <string name="display_list_button">Liste</string> |   <string name="display_list_button">Liste</string> | ||||||
|   <string name="contributions_subtitle_zero">!Henüz yükleme yok)</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="please_enter_some_comments">Lütfen bir yorum girin</string> | ||||||
|   <string name="talk">Tartışma</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="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="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="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> |   <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="uploads">Yüklemeler</string> | ||||||
|   <string name="pending">Beklemede</string> |   <string name="pending">Beklemede</string> | ||||||
|   <string name="failed">Başarısız</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="green_pin">Bu yerin zaten bir resmi var.</string> | ||||||
|  |   <string name="grey_pin">Şimdi bu yerin bir resime sahip olup olmadığı denetleniyor.</string> | ||||||
| </resources> | </resources> | ||||||
|  |  | ||||||
|  | @ -69,7 +69,7 @@ public abstract class MockWebServerTest { | ||||||
|                 .baseUrl(url) |                 .baseUrl(url) | ||||||
|                 .callbackExecutor(new ImmediateExecutor()) |                 .callbackExecutor(new ImmediateExecutor()) | ||||||
|                 .client(okHttpClient) |                 .client(okHttpClient) | ||||||
|                 .addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson())) |                 .addConverterFactory(GsonConverterFactory.create(GsonUtil.INSTANCE.getDefaultGson())) | ||||||
|                 .build() |                 .build() | ||||||
|                 .create(clazz); |                 .create(clazz); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -49,13 +49,13 @@ class CampaignsPresenterTest { | ||||||
|         campaignsSingle = Single.just(campaignResponseDTO) |         campaignsSingle = Single.just(campaignResponseDTO) | ||||||
|         campaignsPresenter = CampaignsPresenter(okHttpJsonApiClient, testScheduler, testScheduler) |         campaignsPresenter = CampaignsPresenter(okHttpJsonApiClient, testScheduler, testScheduler) | ||||||
|         campaignsPresenter.onAttachView(view) |         campaignsPresenter.onAttachView(view) | ||||||
|         Mockito.`when`(okHttpJsonApiClient.campaigns).thenReturn(campaignsSingle) |         Mockito.`when`(okHttpJsonApiClient.getCampaigns()).thenReturn(campaignsSingle) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun getCampaignsTestNoCampaigns() { |     fun getCampaignsTestNoCampaigns() { | ||||||
|         campaignsPresenter.getCampaigns() |         campaignsPresenter.getCampaigns() | ||||||
|         verify(okHttpJsonApiClient).campaigns |         verify(okHttpJsonApiClient).getCampaigns() | ||||||
|         testScheduler.triggerActions() |         testScheduler.triggerActions() | ||||||
|         verify(view).showCampaigns(null) |         verify(view).showCampaigns(null) | ||||||
|     } |     } | ||||||
|  | @ -77,7 +77,7 @@ class CampaignsPresenterTest { | ||||||
|         Mockito.`when`(campaign.endDate).thenReturn(endDateString) |         Mockito.`when`(campaign.endDate).thenReturn(endDateString) | ||||||
|         Mockito.`when`(campaign.startDate).thenReturn(startDateString) |         Mockito.`when`(campaign.startDate).thenReturn(startDateString) | ||||||
|         Mockito.`when`(campaignResponseDTO.campaigns).thenReturn(campaigns) |         Mockito.`when`(campaignResponseDTO.campaigns).thenReturn(campaigns) | ||||||
|         verify(okHttpJsonApiClient).campaigns |         verify(okHttpJsonApiClient).getCampaigns() | ||||||
|         testScheduler.triggerActions() |         testScheduler.triggerActions() | ||||||
|         verify(view).showCampaigns(campaign) |         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 |     @Test | ||||||
|     fun isUserBlockedFromCommonsForInfinitelyBlockedUser() { |     fun isUserBlockedFromCommonsForInfinitelyBlockedUser() { | ||||||
|         val userInfo = Mockito.mock(UserInfo::class.java) |         val userInfo = UserInfo(blockexpiry = "infinite") | ||||||
|         Mockito.`when`(userInfo.blockexpiry()).thenReturn("infinite") |  | ||||||
|         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) |         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) | ||||||
|         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) |         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) | ||||||
|         val mockResponse = Mockito.mock(MwQueryResponse::class.java) |         val mockResponse = Mockito.mock(MwQueryResponse::class.java) | ||||||
|  | @ -49,8 +48,7 @@ class UserClientTest { | ||||||
|         val currentDate = Date() |         val currentDate = Date() | ||||||
|         val expiredDate = Date(currentDate.time + 10000) |         val expiredDate = Date(currentDate.time + 10000) | ||||||
| 
 | 
 | ||||||
|         val userInfo = Mockito.mock(UserInfo::class.java) |         val userInfo = UserInfo(blockexpiry = DateUtil.iso8601DateFormat(expiredDate)) | ||||||
|         Mockito.`when`(userInfo.blockexpiry()).thenReturn(DateUtil.iso8601DateFormat(expiredDate)) |  | ||||||
|         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) |         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) | ||||||
|         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) |         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) | ||||||
|         val mockResponse = Mockito.mock(MwQueryResponse::class.java) |         val mockResponse = Mockito.mock(MwQueryResponse::class.java) | ||||||
|  | @ -65,8 +63,7 @@ class UserClientTest { | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun isUserBlockedFromCommonsForNeverBlockedUser() { |     fun isUserBlockedFromCommonsForNeverBlockedUser() { | ||||||
|         val userInfo = Mockito.mock(UserInfo::class.java) |         val userInfo = UserInfo(blockexpiry = "") | ||||||
|         Mockito.`when`(userInfo.blockexpiry()).thenReturn("") |  | ||||||
|         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) |         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) | ||||||
|         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) |         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) | ||||||
|         val mockResponse = Mockito.mock(MwQueryResponse::class.java) |         val mockResponse = Mockito.mock(MwQueryResponse::class.java) | ||||||
|  |  | ||||||
|  | @ -325,7 +325,7 @@ class NearbyParentFragmentUnitTest { | ||||||
|     @Throws(Exception::class) |     @Throws(Exception::class) | ||||||
|     fun testOnDestroy() { |     fun testOnDestroy() { | ||||||
|         fragment.onDestroy() |         fragment.onDestroy() | ||||||
|         verify(wikidataEditListener).setAuthenticationStateListener(null) |         verify(wikidataEditListener).authenticationStateListener = null | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Test @Ignore |     @Test @Ignore | ||||||
|  |  | ||||||
|  | @ -120,26 +120,16 @@ class NotificationClientTest { | ||||||
|     ) = Notification().apply { |     ) = Notification().apply { | ||||||
|         setId(notificationId) |         setId(notificationId) | ||||||
| 
 | 
 | ||||||
|         setTimestamp( |         setTimestamp(Notification.Timestamp().apply { setUtciso8601(timestamp) }) | ||||||
|             Notification.Timestamp().apply { |  | ||||||
|                 setUtciso8601(timestamp) |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
| 
 | 
 | ||||||
|         contents = |         contents = Notification.Contents().apply { | ||||||
|             Notification.Contents().apply { |             setCompactHeader(compactHeader) | ||||||
|                 setCompactHeader(compactHeader) |  | ||||||
| 
 | 
 | ||||||
|                 links = |             links = Notification.Links().apply { | ||||||
|                     Notification.Links().apply { |                 setPrimary(GsonUtil.defaultGson.toJsonTree( | ||||||
|                         setPrimary( |                     Notification.Link().apply { setUrl(primaryUrl) } | ||||||
|                             GsonUtil.getDefaultGson().toJsonTree( |                 )) | ||||||
|                                 Notification.Link().apply { |  | ||||||
|                                     setUrl(primaryUrl) |  | ||||||
|                                 }, |  | ||||||
|                             ), |  | ||||||
|                         ) |  | ||||||
|                     } |  | ||||||
|             } |             } | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -62,7 +62,7 @@ class UploadPresenterTest { | ||||||
|         `when`(repository.buildContributions()).thenReturn(Observable.just(contribution)) |         `when`(repository.buildContributions()).thenReturn(Observable.just(contribution)) | ||||||
|         uploadableFiles.add(uploadableFile) |         uploadableFiles.add(uploadableFile) | ||||||
|         `when`(view.uploadableFiles).thenReturn(uploadableFiles) |         `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
	
	 Nicolas Raoul
						Nicolas Raoul