mirror of
				https://github.com/commons-app/apps-android-commons.git
				synced 2025-10-31 14:53:59 +01:00 
			
		
		
		
	Merge branch 'Migrate-Feedback-Module-from-java-to-kt' of https://github.com/neeldoshii/apps-android-commons into Migrate-Feedback-Module-from-java-to-kt
				
					
				
			This commit is contained in:
		
						commit
						0026ca5b5c
					
				
					 80 changed files with 3821 additions and 4285 deletions
				
			
		|  | @ -47,7 +47,7 @@ dependencies { | |||
| 
 | ||||
|     implementation 'com.jakewharton.timber:timber:4.7.1' | ||||
|     implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0' | ||||
|     implementation "com.google.android.material:material:1.9.0" | ||||
|     implementation "com.google.android.material:material:1.12.0" | ||||
|     implementation 'com.karumi:dexter:5.0.0' | ||||
|     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' | ||||
| 
 | ||||
|  |  | |||
|  | @ -105,7 +105,7 @@ class AboutActivityTest { | |||
|     fun testLaunchTranslate() { | ||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click()) | ||||
|         Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click()) | ||||
|         val langCode = CommonsApplication.instance.languageLookUpTable!!.codes[0] | ||||
|         val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0] | ||||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|  |  | |||
|  | @ -52,12 +52,12 @@ class CampaignsPresenter @Inject constructor( | |||
|                 return | ||||
|             } | ||||
| 
 | ||||
|             okHttpJsonApiClient.campaigns | ||||
|             okHttpJsonApiClient.getCampaigns() | ||||
|                 .observeOn(mainThreadScheduler) | ||||
|                 .subscribeOn(ioScheduler) | ||||
|                 .doOnSubscribe { disposable = it } | ||||
|                 .subscribe({ campaignResponseDTO -> | ||||
|                     val campaigns = campaignResponseDTO.campaigns?.toMutableList() | ||||
|                     val campaigns = campaignResponseDTO?.campaigns?.toMutableList() | ||||
|                     if (campaigns.isNullOrEmpty()) { | ||||
|                         Timber.e("The campaigns list is empty") | ||||
|                         view!!.showCampaigns(null) | ||||
|  |  | |||
|  | @ -44,7 +44,6 @@ import okhttp3.logging.HttpLoggingInterceptor | |||
| import okhttp3.logging.HttpLoggingInterceptor.Level | ||||
| import timber.log.Timber | ||||
| import java.io.File | ||||
| import java.util.Locale | ||||
| import java.util.concurrent.TimeUnit | ||||
| import javax.inject.Named | ||||
| import javax.inject.Singleton | ||||
|  | @ -170,14 +169,13 @@ class NetworkingModule { | |||
|     @Named(NAMED_WIKI_DATA_WIKI_SITE) | ||||
|     fun provideWikidataWikiSite(): WikiSite = WikiSite(BuildConfig.WIKIDATA_URL) | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Gson objects are very heavy. The app should ideally be using just one instance of it instead of creating new instances everywhere. | ||||
|      * @return returns a singleton Gson instance | ||||
|      */ | ||||
|     @Provides | ||||
|     @Singleton | ||||
|     fun provideGson(): Gson = GsonUtil.getDefaultGson() | ||||
|     fun provideGson(): Gson = GsonUtil.defaultGson | ||||
| 
 | ||||
|     @Provides | ||||
|     @Singleton | ||||
|  | @ -294,9 +292,8 @@ class NetworkingModule { | |||
|     @Provides | ||||
|     @Singleton | ||||
|     @Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) | ||||
|     fun provideLanguageWikipediaSite(): WikiSite { | ||||
|         return WikiSite.forLanguageCode(Locale.getDefault().language) | ||||
|     } | ||||
|     fun provideLanguageWikipediaSite(): WikiSite = | ||||
|         WikiSite.forDefaultLocaleLanguageCode() | ||||
| 
 | ||||
|     companion object { | ||||
|         private const val WIKIDATA_SPARQL_QUERY_URL = "https://query.wikidata.org/sparql" | ||||
|  |  | |||
|  | @ -6,9 +6,9 @@ import android.animation.ValueAnimator | |||
| import android.content.Intent | ||||
| import android.graphics.BitmapFactory | ||||
| import android.graphics.Matrix | ||||
| //noinspection ExifInterface TODO Issue : #5994 | ||||
| import android.media.ExifInterface | ||||
| import android.os.Bundle | ||||
| import android.util.Log | ||||
| import android.view.animation.AccelerateDecelerateInterpolator | ||||
| import android.widget.ImageView | ||||
| import android.widget.Toast | ||||
|  | @ -20,6 +20,7 @@ import androidx.lifecycle.ViewModelProvider | |||
| import fr.free.nrw.commons.databinding.ActivityEditBinding | ||||
| import timber.log.Timber | ||||
| import java.io.File | ||||
| import kotlin.math.ceil | ||||
| 
 | ||||
| /** | ||||
|  * An activity class for editing and rotating images using LLJTran with EXIF attribute preservation. | ||||
|  | @ -42,8 +43,11 @@ class EditActivity : AppCompatActivity() { | |||
|         supportActionBar?.title = "" | ||||
|         val intent = intent | ||||
|         imageUri = intent.getStringExtra("image") ?: "" | ||||
|         vm = ViewModelProvider(this).get(EditViewModel::class.java) | ||||
|         vm = ViewModelProvider(this)[EditViewModel::class.java] | ||||
|         val sourceExif = imageUri.toUri().path?.let { ExifInterface(it) } | ||||
|         //TODO(Deprecation : 'TAG_APERTURE: String' is deprecated. Deprecated in Java) Issue : #6001 | ||||
|         // TODO(Deprecation : 'TAG_ISO: String' is deprecated. Deprecated in Java) Issue : #6001 | ||||
|         @Suppress("DEPRECATION") | ||||
|         val exifTags = | ||||
|             arrayOf( | ||||
|                 ExifInterface.TAG_APERTURE, | ||||
|  | @ -88,8 +92,7 @@ class EditActivity : AppCompatActivity() { | |||
|     private fun init() { | ||||
|         binding.iv.adjustViewBounds = true | ||||
|         binding.iv.scaleType = ImageView.ScaleType.MATRIX | ||||
|         binding.iv.post( | ||||
|             Runnable { | ||||
|         binding.iv.post { | ||||
|             val options = BitmapFactory.Options() | ||||
|             options.inJustDecodeBounds = true | ||||
|             BitmapFactory.decodeFile(imageUri, options) | ||||
|  | @ -118,8 +121,7 @@ class EditActivity : AppCompatActivity() { | |||
|                 binding.iv.layoutParams.height = (scale * bitmapHeight).toInt() | ||||
|                 binding.iv.imageMatrix = scaleMatrix(scale, scale) | ||||
|             } | ||||
|             }, | ||||
|         ) | ||||
|         } | ||||
|         binding.rotateBtn.setOnClickListener { | ||||
|             animateImageHeight() | ||||
|         } | ||||
|  | @ -143,15 +145,15 @@ class EditActivity : AppCompatActivity() { | |||
|         val drawableWidth: Float = | ||||
|             binding.iv | ||||
|                 .getDrawable() | ||||
|                 .getIntrinsicWidth() | ||||
|                 .intrinsicWidth | ||||
|                 .toFloat() | ||||
|         val drawableHeight: Float = | ||||
|             binding.iv | ||||
|                 .getDrawable() | ||||
|                 .getIntrinsicHeight() | ||||
|                 .intrinsicHeight | ||||
|                 .toFloat() | ||||
|         val viewWidth: Float = binding.iv.getMeasuredWidth().toFloat() | ||||
|         val viewHeight: Float = binding.iv.getMeasuredHeight().toFloat() | ||||
|         val viewWidth: Float = binding.iv.measuredWidth.toFloat() | ||||
|         val viewHeight: Float = binding.iv.measuredHeight.toFloat() | ||||
|         val rotation = imageRotation % 360 | ||||
|         val newRotation = rotation + 90 | ||||
| 
 | ||||
|  | @ -162,16 +164,23 @@ class EditActivity : AppCompatActivity() { | |||
|         Timber.d("Rotation $rotation") | ||||
|         Timber.d("new Rotation $newRotation") | ||||
| 
 | ||||
|         if (rotation == 0 || rotation == 180) { | ||||
|         when (rotation) { | ||||
|             0, 180 -> { | ||||
|                 imageScale = viewWidth / drawableWidth | ||||
|                 newImageScale = viewWidth / drawableHeight | ||||
|                 newViewHeight = (drawableWidth * newImageScale).toInt() | ||||
|         } else if (rotation == 90 || rotation == 270) { | ||||
|             } | ||||
|             90, 270 -> { | ||||
|                 imageScale = viewWidth / drawableHeight | ||||
|                 newImageScale = viewWidth / drawableWidth | ||||
|                 newViewHeight = (drawableHeight * newImageScale).toInt() | ||||
|         } else { | ||||
|             throw UnsupportedOperationException("rotation can 0, 90, 180 or 270. \${rotation} is unsupported") | ||||
|             } | ||||
|             else -> { | ||||
|                 throw | ||||
|                 UnsupportedOperationException( | ||||
|                     "rotation can 0, 90, 180 or 270. \${rotation} is unsupported" | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L) | ||||
|  | @ -204,7 +213,7 @@ class EditActivity : AppCompatActivity() { | |||
|                 (complementaryAnimVal * viewHeight + animVal * newViewHeight).toInt() | ||||
|             val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale | ||||
|             val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation | ||||
|             binding.iv.getLayoutParams().height = animatedHeight | ||||
|             binding.iv.layoutParams.height = animatedHeight | ||||
|             val matrix: Matrix = | ||||
|                 rotationMatrix( | ||||
|                     animatedRotation, | ||||
|  | @ -218,8 +227,8 @@ class EditActivity : AppCompatActivity() { | |||
|                 drawableHeight / 2, | ||||
|             ) | ||||
|             matrix.postTranslate( | ||||
|                 -(drawableWidth - binding.iv.getMeasuredWidth()) / 2, | ||||
|                 -(drawableHeight - binding.iv.getMeasuredHeight()) / 2, | ||||
|                 -(drawableWidth - binding.iv.measuredWidth) / 2, | ||||
|                 -(drawableHeight - binding.iv.measuredHeight) / 2, | ||||
|             ) | ||||
|             binding.iv.setImageMatrix(matrix) | ||||
|             binding.iv.requestLayout() | ||||
|  | @ -267,9 +276,9 @@ class EditActivity : AppCompatActivity() { | |||
|      */ | ||||
|     private fun copyExifData(editedImageExif: ExifInterface?) { | ||||
|         for (attr in sourceExifAttributeList) { | ||||
|             Log.d("Tag is  ${attr.first}", "Value is ${attr.second}") | ||||
|             Timber.d("Value is ${attr.second}") | ||||
|             editedImageExif!!.setAttribute(attr.first, attr.second) | ||||
|             Log.d("Tag is ${attr.first}", "Value is ${attr.second}") | ||||
|             Timber.d("Value is ${attr.second}") | ||||
|         } | ||||
| 
 | ||||
|         editedImageExif?.saveAttributes() | ||||
|  | @ -298,9 +307,10 @@ class EditActivity : AppCompatActivity() { | |||
|         var scaleFactor = 1 | ||||
| 
 | ||||
|         if (originalWidth > maxSize || originalHeight > maxSize) { | ||||
|             // Calculate the largest power of 2 that is less than or equal to the desired width and height | ||||
|             val widthRatio = Math.ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt() | ||||
|             val heightRatio = Math.ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt() | ||||
|             // Calculate the largest power of 2 that is less than or equal to the desired | ||||
|             // width and height | ||||
|             val widthRatio = ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt() | ||||
|             val heightRatio = ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt() | ||||
| 
 | ||||
|             scaleFactor = if (widthRatio > heightRatio) widthRatio else heightRatio | ||||
|         } | ||||
|  |  | |||
|  | @ -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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -111,10 +111,18 @@ class MoreBottomSheetFragment : BottomSheetDialogFragment() { | |||
|     private fun setUserName() { | ||||
|         val store = BasicKvStore(requireContext(), getUserName()) | ||||
|         val level = store.getString("userAchievementsLevel", "0") | ||||
|         binding?.moreProfile?.text = if (level == "0") { | ||||
|             "${getUserName()} (${getString(R.string.see_your_achievements)})" | ||||
|         if (level == "0"){ | ||||
|             binding?.moreProfile?.text = getString( | ||||
|                 R.string.profileLevel, | ||||
|                 getUserName(), | ||||
|                 getString(R.string.see_your_achievements) // Second argument | ||||
|             ) | ||||
|         } else { | ||||
|             "${getUserName()} (${getString(R.string.level)} $level)" | ||||
|             binding?.moreProfile?.text = getString( | ||||
|                 R.string.profileLevel, | ||||
|                 getUserName(), | ||||
|                 level | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -16,7 +16,6 @@ import androidx.annotation.NonNull; | |||
| import androidx.core.content.FileProvider; | ||||
| import androidx.fragment.app.Fragment; | ||||
| import androidx.fragment.app.FragmentManager; | ||||
| import com.google.android.material.tabs.TabLayout; | ||||
| import fr.free.nrw.commons.R; | ||||
| import fr.free.nrw.commons.Utils; | ||||
| import fr.free.nrw.commons.ViewPagerAdapter; | ||||
|  |  | |||
|  | @ -1,492 +0,0 @@ | |||
| package fr.free.nrw.commons.profile.achievements; | ||||
| 
 | ||||
| import android.accounts.Account; | ||||
| import android.content.Context; | ||||
| import android.net.Uri; | ||||
| import android.os.Bundle; | ||||
| import android.util.DisplayMetrics; | ||||
| import android.view.LayoutInflater; | ||||
| import android.view.View; | ||||
| import android.view.ViewGroup; | ||||
| import android.widget.Toast; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.appcompat.view.ContextThemeWrapper; | ||||
| import androidx.constraintlayout.widget.ConstraintLayout; | ||||
| import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; | ||||
| import fr.free.nrw.commons.R; | ||||
| import fr.free.nrw.commons.Utils; | ||||
| import fr.free.nrw.commons.auth.SessionManager; | ||||
| import fr.free.nrw.commons.databinding.FragmentAchievementsBinding; | ||||
| import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; | ||||
| import fr.free.nrw.commons.kvstore.BasicKvStore; | ||||
| import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; | ||||
| import fr.free.nrw.commons.utils.ConfigUtils; | ||||
| import fr.free.nrw.commons.utils.DialogUtil; | ||||
| import fr.free.nrw.commons.utils.ViewUtil; | ||||
| import fr.free.nrw.commons.profile.ProfileActivity; | ||||
| import io.reactivex.android.schedulers.AndroidSchedulers; | ||||
| import io.reactivex.disposables.CompositeDisposable; | ||||
| import io.reactivex.schedulers.Schedulers; | ||||
| import java.util.Locale; | ||||
| import java.util.Objects; | ||||
| import javax.inject.Inject; | ||||
| import org.apache.commons.lang3.StringUtils; | ||||
| import timber.log.Timber; | ||||
| 
 | ||||
| /** | ||||
|  * fragment for sharing feedback on uploaded activity | ||||
|  */ | ||||
| public class AchievementsFragment extends CommonsDaggerSupportFragment { | ||||
| 
 | ||||
|     private static final double BADGE_IMAGE_WIDTH_RATIO = 0.4; | ||||
|     private static final double BADGE_IMAGE_HEIGHT_RATIO = 0.3; | ||||
| 
 | ||||
|     /** | ||||
|      * Help link URLs | ||||
|      */ | ||||
|     private static final String IMAGES_UPLOADED_URL = "https://commons.wikimedia.org/wiki/Commons:Project_scope"; | ||||
|     private static final String IMAGES_REVERT_URL = "https://commons.wikimedia.org/wiki/Commons:Deletion_policy#Reasons_for_deletion"; | ||||
|     private static final String IMAGES_USED_URL = "https://en.wikipedia.org/wiki/Wikipedia:Manual_of_Style/Images"; | ||||
|     private static final String IMAGES_NEARBY_PLACES_URL = "https://www.wikidata.org/wiki/Property:P18"; | ||||
|     private static final String IMAGES_FEATURED_URL = "https://commons.wikimedia.org/wiki/Commons:Featured_pictures"; | ||||
|     private static final String QUALITY_IMAGE_URL = "https://commons.wikimedia.org/wiki/Commons:Quality_images"; | ||||
|     private static final String THANKS_URL = "https://www.mediawiki.org/wiki/Extension:Thanks"; | ||||
| 
 | ||||
|     private LevelController.LevelInfo levelInfo; | ||||
| 
 | ||||
|     @Inject | ||||
|     SessionManager sessionManager; | ||||
| 
 | ||||
|     @Inject | ||||
|     OkHttpJsonApiClient okHttpJsonApiClient; | ||||
| 
 | ||||
|     private FragmentAchievementsBinding binding; | ||||
| 
 | ||||
|     private CompositeDisposable compositeDisposable = new CompositeDisposable(); | ||||
| 
 | ||||
|     // To keep track of the number of wiki edits made by a user | ||||
|     private int numberOfEdits = 0; | ||||
| 
 | ||||
|     private String userName; | ||||
| 
 | ||||
|     @Override | ||||
|     public void onCreate(@Nullable final Bundle savedInstanceState) { | ||||
|         super.onCreate(savedInstanceState); | ||||
|         if (getArguments() != null) { | ||||
|             userName = getArguments().getString(ProfileActivity.KEY_USERNAME); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This method helps in the creation Achievement screen and | ||||
|      * dynamically set the size of imageView | ||||
|      * | ||||
|      * @param savedInstanceState Data bundle | ||||
|      */ | ||||
|     @Override | ||||
|     public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { | ||||
|         binding = FragmentAchievementsBinding.inflate(inflater, container, false); | ||||
|         View rootView = binding.getRoot(); | ||||
| 
 | ||||
|         binding.achievementInfo.setOnClickListener(view -> showInfoDialog()); | ||||
|         binding.imagesUploadInfo.setOnClickListener(view -> showUploadInfo()); | ||||
|         binding.imagesRevertedInfo.setOnClickListener(view -> showRevertedInfo()); | ||||
|         binding.imagesUsedByWikiInfo.setOnClickListener(view -> showUsedByWikiInfo()); | ||||
|         binding.imagesNearbyInfo.setOnClickListener(view -> showImagesViaNearbyInfo()); | ||||
|         binding.imagesFeaturedInfo.setOnClickListener(view -> showFeaturedImagesInfo()); | ||||
|         binding.thanksReceivedInfo.setOnClickListener(view -> showThanksReceivedInfo()); | ||||
|         binding.qualityImagesInfo.setOnClickListener(view -> showQualityImagesInfo()); | ||||
| 
 | ||||
|         // DisplayMetrics used to fetch the size of the screen | ||||
|         DisplayMetrics displayMetrics = new DisplayMetrics(); | ||||
|         getActivity().getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); | ||||
|         int height = displayMetrics.heightPixels; | ||||
|         int width = displayMetrics.widthPixels; | ||||
| 
 | ||||
|         // Used for the setting the size of imageView at runtime | ||||
|         ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) | ||||
|             binding.achievementBadgeImage.getLayoutParams(); | ||||
|         params.height = (int) (height * BADGE_IMAGE_HEIGHT_RATIO); | ||||
|         params.width = (int) (width * BADGE_IMAGE_WIDTH_RATIO); | ||||
|         binding.achievementBadgeImage.requestLayout(); | ||||
|         binding.progressBar.setVisibility(View.VISIBLE); | ||||
| 
 | ||||
|         setHasOptionsMenu(true); | ||||
| 
 | ||||
|         // Set the initial value of WikiData edits to 0 | ||||
|         binding.wikidataEdits.setText("0"); | ||||
|         if(sessionManager.getUserName() == null || sessionManager.getUserName().equals(userName)){ | ||||
|             binding.tvAchievementsOfUser.setVisibility(View.GONE); | ||||
|         }else{ | ||||
|             binding.tvAchievementsOfUser.setVisibility(View.VISIBLE); | ||||
|             binding.tvAchievementsOfUser.setText(getString(R.string.achievements_of_user,userName)); | ||||
|         } | ||||
| 
 | ||||
|         // Achievements currently unimplemented in Beta flavor. Skip all API calls. | ||||
|         if(ConfigUtils.isBetaFlavour()) { | ||||
|             binding.progressBar.setVisibility(View.GONE); | ||||
|             binding.imagesUsedByWikiText.setText(R.string.no_image); | ||||
|             binding.imagesRevertedText.setText(R.string.no_image_reverted); | ||||
|             binding.imagesUploadTextParam.setText(R.string.no_image_uploaded); | ||||
|             binding.wikidataEdits.setText("0"); | ||||
|             binding.imageFeatured.setText("0"); | ||||
|             binding.qualityImages.setText("0"); | ||||
|             binding.achievementLevel.setText("0"); | ||||
|             setMenuVisibility(true); | ||||
|             return rootView; | ||||
|         } | ||||
|         setWikidataEditCount(); | ||||
|         setAchievements(); | ||||
|         return rootView; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void onDestroyView() { | ||||
|         binding = null; | ||||
|         super.onDestroyView(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void setMenuVisibility(boolean visible) { | ||||
|         super.setMenuVisibility(visible); | ||||
| 
 | ||||
|         // Whenever this fragment is revealed in a menu, | ||||
|         // notify Beta users the page data is unavailable | ||||
|         if(ConfigUtils.isBetaFlavour() && visible) { | ||||
|             Context ctx = null; | ||||
|             if(getContext() != null) { | ||||
|                 ctx = getContext(); | ||||
|             } else if(getView() != null && getView().getContext() != null) { | ||||
|                 ctx = getView().getContext(); | ||||
|             } | ||||
|             if(ctx != null) { | ||||
|                 Toast.makeText(ctx, | ||||
|                     R.string.achievements_unavailable_beta, | ||||
|                     Toast.LENGTH_LONG).show(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * To invoke the AlertDialog on clicking info button | ||||
|      */ | ||||
|     protected void showInfoDialog(){ | ||||
|         launchAlert( | ||||
|             getResources().getString(R.string.Achievements), | ||||
|             getResources().getString(R.string.achievements_info_message)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * To call the API to get results in form Single<JSONObject> | ||||
|      * which then calls parseJson when results are fetched | ||||
|      */ | ||||
|     private void setAchievements() { | ||||
|         binding.progressBar.setVisibility(View.VISIBLE); | ||||
|         if (checkAccount()) { | ||||
|             try{ | ||||
| 
 | ||||
|                 compositeDisposable.add(okHttpJsonApiClient | ||||
|                     .getAchievements(Objects.requireNonNull(userName)) | ||||
|                     .subscribeOn(Schedulers.io()) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .subscribe( | ||||
|                         response -> { | ||||
|                             if (response != null) { | ||||
|                                 setUploadCount(Achievements.from(response)); | ||||
|                             } else { | ||||
|                                 Timber.d("success"); | ||||
|                                 binding.layoutImageReverts.setVisibility(View.INVISIBLE); | ||||
|                                 binding.achievementBadgeImage.setVisibility(View.INVISIBLE); | ||||
|                                 // If the number of edits made by the user are more than 150,000 | ||||
|                                 // in some cases such high number of wiki edit counts cause the | ||||
|                                 // achievements calculator to fail in some cases, for more details | ||||
|                                 // refer Issue: #3295 | ||||
|                                 if (numberOfEdits <= 150000) { | ||||
|                                     showSnackBarWithRetry(false); | ||||
|                                 } else { | ||||
|                                     showSnackBarWithRetry(true); | ||||
|                                 } | ||||
|                             } | ||||
|                         }, | ||||
|                         t -> { | ||||
|                             Timber.e(t, "Fetching achievements statistics failed"); | ||||
|                             if (numberOfEdits <= 150000) { | ||||
|                                 showSnackBarWithRetry(false); | ||||
|                             } else { | ||||
|                                 showSnackBarWithRetry(true); | ||||
|                             } | ||||
|                         } | ||||
|                     )); | ||||
|             } | ||||
|             catch (Exception e){ | ||||
|                 Timber.d(e+"success"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * To call the API to fetch the count of wiki data edits | ||||
|      *  in the form of JavaRx Single object<JSONobject> | ||||
|      */ | ||||
|     private void setWikidataEditCount() { | ||||
|         if (StringUtils.isBlank(userName)) { | ||||
|             return; | ||||
|         } | ||||
|         compositeDisposable.add(okHttpJsonApiClient | ||||
|             .getWikidataEdits(userName) | ||||
|             .subscribeOn(Schedulers.io()) | ||||
|             .observeOn(AndroidSchedulers.mainThread()) | ||||
|             .subscribe(edits -> { | ||||
|                 numberOfEdits = edits; | ||||
|                 binding.wikidataEdits.setText(String.valueOf(edits)); | ||||
|             }, e -> { | ||||
|                 Timber.e("Error:" + e); | ||||
|             })); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Shows a snack bar which has an action button which on click dismisses the snackbar and invokes the | ||||
|      * listener passed | ||||
|      * @param tooManyAchievements if this value is true it means that the number of achievements of the | ||||
|      * user are so high that it wrecks havoc with the Achievements calculator due to which request may time | ||||
|      * out. Well this is the Ultimate Achievement | ||||
|      */ | ||||
|     private void showSnackBarWithRetry(boolean tooManyAchievements) { | ||||
|         if (tooManyAchievements) { | ||||
|             binding.progressBar.setVisibility(View.GONE); | ||||
|             ViewUtil.showDismissibleSnackBar(getActivity().findViewById(android.R.id.content), | ||||
|                 R.string.achievements_fetch_failed_ultimate_achievement, R.string.retry, view -> setAchievements()); | ||||
|         } else { | ||||
|             binding.progressBar.setVisibility(View.GONE); | ||||
|             ViewUtil.showDismissibleSnackBar(getActivity().findViewById(android.R.id.content), | ||||
|                 R.string.achievements_fetch_failed, R.string.retry, view -> setAchievements()); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Shows a generic error toast when error occurs while loading achievements or uploads | ||||
|      */ | ||||
|     private void onError() { | ||||
|         ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.error_occurred)); | ||||
|         binding.progressBar.setVisibility(View.GONE); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * used to the count of images uploaded by user | ||||
|      */ | ||||
|     private void setUploadCount(Achievements achievements) { | ||||
|         if (checkAccount()) { | ||||
|             compositeDisposable.add(okHttpJsonApiClient | ||||
|                 .getUploadCount(Objects.requireNonNull(userName)) | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe( | ||||
|                     uploadCount -> setAchievementsUploadCount(achievements, uploadCount), | ||||
|                     t -> { | ||||
|                         Timber.e(t, "Fetching upload count failed"); | ||||
|                         onError(); | ||||
|                     } | ||||
|                 )); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * used to set achievements upload count and call hideProgressbar | ||||
|      * @param uploadCount | ||||
|      */ | ||||
|     private void setAchievementsUploadCount(Achievements achievements, int uploadCount) { | ||||
|         // Create a new instance of Achievements with updated imagesUploaded | ||||
|         Achievements updatedAchievements = new Achievements( | ||||
|             achievements.getUniqueUsedImages(), | ||||
|             achievements.getArticlesUsingImages(), | ||||
|             achievements.getThanksReceived(), | ||||
|             achievements.getFeaturedImages(), | ||||
|             achievements.getQualityImages(), | ||||
|             uploadCount,  // Update imagesUploaded with new value | ||||
|             achievements.getRevertCount() | ||||
|         ); | ||||
| 
 | ||||
|         hideProgressBar(updatedAchievements); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * used to the uploaded images progressbar | ||||
|      * @param uploadCount | ||||
|      */ | ||||
|     private void setUploadProgress(int uploadCount){ | ||||
|         if (uploadCount==0){ | ||||
|             setZeroAchievements(); | ||||
|         }else { | ||||
|             binding.imagesUploadedProgressbar.setVisibility(View.VISIBLE); | ||||
|             binding.imagesUploadedProgressbar.setProgress | ||||
|                 (100*uploadCount/levelInfo.getMaxUploadCount()); | ||||
|             binding.tvUploadedImages.setText | ||||
|                 (uploadCount + "/" + levelInfo.getMaxUploadCount()); | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private void setZeroAchievements() { | ||||
|         String message = !Objects.equals(sessionManager.getUserName(), userName) ? | ||||
|             getString(R.string.no_achievements_yet, userName) : | ||||
|             getString(R.string.you_have_no_achievements_yet); | ||||
|         DialogUtil.showAlertDialog(getActivity(), | ||||
|             null, | ||||
|             message, | ||||
|             getString(R.string.ok), | ||||
|             () -> {}, | ||||
|             true); | ||||
| //        binding.imagesUploadedProgressbar.setVisibility(View.INVISIBLE); | ||||
| //        binding.imageRevertsProgressbar.setVisibility(View.INVISIBLE); | ||||
| //        binding.imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE); | ||||
|         binding.achievementBadgeImage.setVisibility(View.INVISIBLE); | ||||
|         binding.imagesUsedByWikiText.setText(R.string.no_image); | ||||
|         binding.imagesRevertedText.setText(R.string.no_image_reverted); | ||||
|         binding.imagesUploadTextParam.setText(R.string.no_image_uploaded); | ||||
|         binding.achievementBadgeImage.setVisibility(View.INVISIBLE); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * used to set the non revert image percentage | ||||
|      * @param notRevertPercentage | ||||
|      */ | ||||
|     private void setImageRevertPercentage(int notRevertPercentage){ | ||||
|         binding.imageRevertsProgressbar.setVisibility(View.VISIBLE); | ||||
|         binding.imageRevertsProgressbar.setProgress(notRevertPercentage); | ||||
|         final String revertPercentage = Integer.toString(notRevertPercentage); | ||||
|         binding.tvRevertedImages.setText(revertPercentage + "%"); | ||||
|         binding.imagesRevertLimitText.setText(getResources().getString(R.string.achievements_revert_limit_message)+ levelInfo.getMinNonRevertPercentage() + "%"); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Used the inflate the fetched statistics of the images uploaded by user | ||||
|      * and assign badge and level. Also stores the achievements level of the user in BasicKvStore to display in menu | ||||
|      * @param achievements | ||||
|      */ | ||||
|     private void inflateAchievements(Achievements achievements) { | ||||
| //        binding.imagesUsedByWikiProgressBar.setVisibility(View.VISIBLE); | ||||
|         binding.thanksReceived.setText(String.valueOf(achievements.getThanksReceived())); | ||||
|         binding.imagesUsedByWikiProgressBar.setProgress | ||||
|             (100 * achievements.getUniqueUsedImages() / levelInfo.getMaxUniqueImages()); | ||||
|         binding.tvWikiPb.setText(achievements.getUniqueUsedImages() + "/" | ||||
|             + levelInfo.getMaxUniqueImages()); | ||||
|         binding.imageFeatured.setText(String.valueOf(achievements.getFeaturedImages())); | ||||
|         binding.qualityImages.setText(String.valueOf(achievements.getQualityImages())); | ||||
|         String levelUpInfoString = getString(R.string.level).toUpperCase(Locale.ROOT); | ||||
|         levelUpInfoString += " " + levelInfo.getLevelNumber(); | ||||
|         binding.achievementLevel.setText(levelUpInfoString); | ||||
|         binding.achievementBadgeImage.setImageDrawable(VectorDrawableCompat.create(getResources(), R.drawable.badge, | ||||
|             new ContextThemeWrapper(getActivity(), levelInfo.getLevelStyle()).getTheme())); | ||||
|         binding.achievementBadgeText.setText(Integer.toString(levelInfo.getLevelNumber())); | ||||
|         BasicKvStore store = new BasicKvStore(this.getContext(), userName); | ||||
|         store.putString("userAchievementsLevel", Integer.toString(levelInfo.getLevelNumber())); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * to hide progressbar | ||||
|      */ | ||||
|     private void hideProgressBar(Achievements achievements) { | ||||
|         if (binding.progressBar != null) { | ||||
|             levelInfo = LevelController.LevelInfo.from(achievements.getImagesUploaded(), | ||||
|                 achievements.getUniqueUsedImages(), | ||||
|                 achievements.getNotRevertPercentage()); | ||||
|             inflateAchievements(achievements); | ||||
|             setUploadProgress(achievements.getImagesUploaded()); | ||||
|             setImageRevertPercentage(achievements.getNotRevertPercentage()); | ||||
|             binding.progressBar.setVisibility(View.GONE); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     protected void showUploadInfo(){ | ||||
|         launchAlertWithHelpLink( | ||||
|             getResources().getString(R.string.images_uploaded), | ||||
|             getResources().getString(R.string.images_uploaded_explanation), | ||||
|             IMAGES_UPLOADED_URL); | ||||
|     } | ||||
| 
 | ||||
|     protected void showRevertedInfo(){ | ||||
|         launchAlertWithHelpLink( | ||||
|             getResources().getString(R.string.image_reverts), | ||||
|             getResources().getString(R.string.images_reverted_explanation), | ||||
|             IMAGES_REVERT_URL); | ||||
|     } | ||||
| 
 | ||||
|     protected void showUsedByWikiInfo(){ | ||||
|         launchAlertWithHelpLink( | ||||
|             getResources().getString(R.string.images_used_by_wiki), | ||||
|             getResources().getString(R.string.images_used_explanation), | ||||
|             IMAGES_USED_URL); | ||||
|     } | ||||
| 
 | ||||
|     protected void showImagesViaNearbyInfo(){ | ||||
|         launchAlertWithHelpLink( | ||||
|             getResources().getString(R.string.statistics_wikidata_edits), | ||||
|             getResources().getString(R.string.images_via_nearby_explanation), | ||||
|             IMAGES_NEARBY_PLACES_URL); | ||||
|     } | ||||
| 
 | ||||
|     protected void showFeaturedImagesInfo(){ | ||||
|         launchAlertWithHelpLink( | ||||
|             getResources().getString(R.string.statistics_featured), | ||||
|             getResources().getString(R.string.images_featured_explanation), | ||||
|             IMAGES_FEATURED_URL); | ||||
|     } | ||||
| 
 | ||||
|     protected void showThanksReceivedInfo(){ | ||||
|         launchAlertWithHelpLink( | ||||
|             getResources().getString(R.string.statistics_thanks), | ||||
|             getResources().getString(R.string.thanks_received_explanation), | ||||
|             THANKS_URL); | ||||
|     } | ||||
| 
 | ||||
|     public void showQualityImagesInfo() { | ||||
|         launchAlertWithHelpLink( | ||||
|             getResources().getString(R.string.statistics_quality), | ||||
|             getResources().getString(R.string.quality_images_info), | ||||
|             QUALITY_IMAGE_URL); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * takes title and message as input to display alerts | ||||
|      * @param title | ||||
|      * @param message | ||||
|      */ | ||||
|     private void launchAlert(String title, String message){ | ||||
|         DialogUtil.showAlertDialog(getActivity(), | ||||
|             title, | ||||
|             message, | ||||
|             getString(R.string.ok), | ||||
|             () -> {}, | ||||
|             true); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      *  Launch Alert with a READ MORE button and clicking it open a custom webpage | ||||
|      */ | ||||
|     private void launchAlertWithHelpLink(String title, String message, String helpLinkUrl) { | ||||
|         DialogUtil.showAlertDialog(getActivity(), | ||||
|             title, | ||||
|             message, | ||||
|             getString(R.string.ok), | ||||
|             getString(R.string.read_help_link), | ||||
|             () -> {}, | ||||
|             () -> Utils.handleWebUrl(requireContext(), Uri.parse(helpLinkUrl)), | ||||
|             null, | ||||
|             true); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * check to ensure that user is logged in | ||||
|      * @return | ||||
|      */ | ||||
|     private boolean checkAccount(){ | ||||
|         Account currentAccount = sessionManager.getCurrentAccount(); | ||||
|         if (currentAccount == null) { | ||||
|             Timber.d("Current account is null"); | ||||
|             ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.user_not_logged_in)); | ||||
|             sessionManager.forceLogin(getActivity()); | ||||
|             return false; | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,566 @@ | |||
| package fr.free.nrw.commons.profile.achievements | ||||
| 
 | ||||
| import android.net.Uri | ||||
| import android.os.Bundle | ||||
| import android.util.DisplayMetrics | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.view.ViewTreeObserver | ||||
| import android.widget.Toast | ||||
| import androidx.appcompat.view.ContextThemeWrapper | ||||
| import androidx.constraintlayout.widget.ConstraintLayout | ||||
| import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat | ||||
| import com.google.android.material.badge.BadgeDrawable | ||||
| import com.google.android.material.badge.BadgeUtils | ||||
| import com.google.android.material.badge.ExperimentalBadgeUtils | ||||
| import fr.free.nrw.commons.R | ||||
| import fr.free.nrw.commons.Utils | ||||
| import fr.free.nrw.commons.auth.SessionManager | ||||
| import fr.free.nrw.commons.databinding.FragmentAchievementsBinding | ||||
| import fr.free.nrw.commons.di.CommonsDaggerSupportFragment | ||||
| import fr.free.nrw.commons.kvstore.BasicKvStore | ||||
| import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient | ||||
| import fr.free.nrw.commons.profile.ProfileActivity | ||||
| import fr.free.nrw.commons.profile.achievements.LevelController.LevelInfo.Companion.from | ||||
| import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour | ||||
| import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog | ||||
| import fr.free.nrw.commons.utils.ViewUtil.showDismissibleSnackBar | ||||
| import fr.free.nrw.commons.utils.ViewUtil.showLongToast | ||||
| import io.reactivex.android.schedulers.AndroidSchedulers | ||||
| import io.reactivex.schedulers.Schedulers | ||||
| import org.apache.commons.lang3.StringUtils | ||||
| import timber.log.Timber | ||||
| import java.util.Objects | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| class AchievementsFragment : CommonsDaggerSupportFragment(){ | ||||
|     private lateinit var levelInfo: LevelController.LevelInfo | ||||
| 
 | ||||
|     @Inject | ||||
|     lateinit var sessionManager: SessionManager | ||||
| 
 | ||||
|     @Inject | ||||
|     lateinit var okHttpJsonApiClient: OkHttpJsonApiClient | ||||
| 
 | ||||
|     private var _binding: FragmentAchievementsBinding? = null | ||||
|     private val binding get() = _binding!! | ||||
|     // To keep track of the number of wiki edits made by a user | ||||
|     private var numberOfEdits: Int = 0 | ||||
| 
 | ||||
|     private var userName: String? = null | ||||
| 
 | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         arguments?.let { | ||||
|             userName = it.getString(ProfileActivity.KEY_USERNAME) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|     ): View { | ||||
|         _binding = FragmentAchievementsBinding.inflate(inflater, container, false) | ||||
| 
 | ||||
|         binding.achievementInfo.setOnClickListener { showInfoDialog() } | ||||
|         binding.imagesUploadInfoIcon.setOnClickListener { showUploadInfo() } | ||||
|         binding.imagesRevertedInfoIcon.setOnClickListener { showRevertedInfo() } | ||||
|         binding.imagesUsedByWikiInfoIcon.setOnClickListener { showUsedByWikiInfo() } | ||||
|         binding.wikidataEditsIcon.setOnClickListener { showImagesViaNearbyInfo() } | ||||
|         binding.featuredImageIcon.setOnClickListener { showFeaturedImagesInfo() } | ||||
|         binding.thanksImageIcon.setOnClickListener { showThanksReceivedInfo() } | ||||
|         binding.qualityImageIcon.setOnClickListener { showQualityImagesInfo() } | ||||
| 
 | ||||
|         // DisplayMetrics used to fetch the size of the screen | ||||
|         val displayMetrics = DisplayMetrics() | ||||
|         requireActivity().windowManager.defaultDisplay.getMetrics(displayMetrics) | ||||
|         val height = displayMetrics.heightPixels | ||||
|         val width = displayMetrics.widthPixels | ||||
| 
 | ||||
|         // Used for the setting the size of imageView at runtime | ||||
|         // TODO REMOVE | ||||
|         val params = binding.achievementBadgeImage.layoutParams as ConstraintLayout.LayoutParams | ||||
|         params.height = (height * BADGE_IMAGE_HEIGHT_RATIO).toInt() | ||||
|         params.width = (width * BADGE_IMAGE_WIDTH_RATIO).toInt() | ||||
|         binding.achievementBadgeImage.requestLayout() | ||||
|         binding.progressBar.visibility = View.VISIBLE | ||||
| 
 | ||||
|         setHasOptionsMenu(true) | ||||
|         if (sessionManager.userName == null || sessionManager.userName == userName) { | ||||
|             binding.tvAchievementsOfUser.visibility = View.GONE | ||||
|         } else { | ||||
|             binding.tvAchievementsOfUser.visibility = View.VISIBLE | ||||
|             binding.tvAchievementsOfUser.text = getString(R.string.achievements_of_user, userName) | ||||
|         } | ||||
|         if (isBetaFlavour) { | ||||
|             binding.layout.visibility = View.GONE | ||||
|             setMenuVisibility(true) | ||||
|             return binding.root | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         setWikidataEditCount() | ||||
|         setAchievements() | ||||
|         return binding.root | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     override fun onDestroyView() { | ||||
|         super.onDestroyView() | ||||
|         _binding = null | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     override fun setMenuVisibility(visible: Boolean) { | ||||
|         super.setMenuVisibility(visible) | ||||
| 
 | ||||
|         // Whenever this fragment is revealed in a menu, | ||||
|         // notify Beta users the page data is unavailable | ||||
|         if (isBetaFlavour && visible) { | ||||
|             val ctx = context ?: view?.context | ||||
|             ctx?.let { | ||||
|                 Toast.makeText(it, R.string.achievements_unavailable_beta, Toast.LENGTH_LONG).show() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * To invoke the AlertDialog on clicking info button | ||||
|      */ | ||||
|     fun showInfoDialog() { | ||||
|         launchAlert( | ||||
|             resources.getString(R.string.Achievements), | ||||
|             resources.getString(R.string.achievements_info_message) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * To call the API to get results in form Single<JSONObject> | ||||
|      * which then calls parseJson when results are fetched | ||||
|      */ | ||||
| 
 | ||||
|     private fun setAchievements() { | ||||
|         binding.progressBar.visibility = View.VISIBLE | ||||
|         if (checkAccount()) { | ||||
|             try { | ||||
|                 compositeDisposable.add( | ||||
|                     okHttpJsonApiClient | ||||
|                         .getAchievements(userName ?: return) | ||||
|                         .subscribeOn(Schedulers.io()) | ||||
|                         .observeOn(AndroidSchedulers.mainThread()) | ||||
|                         .subscribe( | ||||
|                             { response -> | ||||
|                                 if (response != null) { | ||||
|                                     setUploadCount(Achievements.from(response)) | ||||
|                                 } else { | ||||
|                                     Timber.d("Success") | ||||
|                                     // TODO Create a Method to Hide all the Statistics | ||||
| //                                    binding.layoutImageReverts.visibility = View.INVISIBLE | ||||
| //                                    binding.achievementBadgeImage.visibility = View.INVISIBLE | ||||
|                                     // If the number of edits made by the user are more than 150,000 | ||||
|                                     // in some cases such high number of wiki edit counts cause the | ||||
|                                     // achievements calculator to fail in some cases, for more details | ||||
|                                     // refer Issue: #3295 | ||||
|                                     if (numberOfEdits <= 150_000) { | ||||
|                                         showSnackBarWithRetry(false) | ||||
|                                     } else { | ||||
|                                         showSnackBarWithRetry(true) | ||||
|                                     } | ||||
|                                 } | ||||
|                             }, | ||||
|                             { throwable -> | ||||
|                                 Timber.e(throwable, "Fetching achievements statistics failed") | ||||
|                                 if (numberOfEdits <= 150_000) { | ||||
|                                     showSnackBarWithRetry(false) | ||||
|                                 } else { | ||||
|                                     showSnackBarWithRetry(true) | ||||
|                                 } | ||||
|                             } | ||||
|                         ) | ||||
|                 ) | ||||
|             } catch (e: Exception) { | ||||
|                 Timber.d("Exception: ${e.message}") | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * To call the API to fetch the count of wiki data edits | ||||
|      * in the form of JavaRx Single object<JSONobject> | ||||
|     </JSONobject> */ | ||||
| 
 | ||||
|     private fun setWikidataEditCount() { | ||||
|         if (StringUtils.isBlank(userName)) { | ||||
|             return | ||||
|         } | ||||
|         compositeDisposable.add( | ||||
|             okHttpJsonApiClient | ||||
|                 .getWikidataEdits(userName) | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe({ edits: Int -> | ||||
|                     numberOfEdits = edits | ||||
|                     showBadgesWithCount(view = binding.wikidataEditsIcon, count = edits) | ||||
|                 }, { e: Throwable -> | ||||
|                     Timber.e("Error:$e") | ||||
|                 }) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Shows a snack bar which has an action button which on click dismisses the snackbar and invokes the | ||||
|      * listener passed | ||||
|      * @param tooManyAchievements if this value is true it means that the number of achievements of the | ||||
|      * user are so high that it wrecks havoc with the Achievements calculator due to which request may time | ||||
|      * out. Well this is the Ultimate Achievement | ||||
|      */ | ||||
|     private fun showSnackBarWithRetry(tooManyAchievements: Boolean) { | ||||
|         if (tooManyAchievements) { | ||||
|             if (view == null) { | ||||
|                 return | ||||
|             } | ||||
|             else { | ||||
|                 binding.progressBar.visibility = View.GONE | ||||
|                 showDismissibleSnackBar( | ||||
|                     requireView().findViewById(android.R.id.content), | ||||
|                     R.string.achievements_fetch_failed_ultimate_achievement, R.string.retry | ||||
|                 ) { setAchievements() } | ||||
|             } | ||||
| 
 | ||||
|         } else { | ||||
|             if (view == null) { | ||||
|                 return | ||||
|             } | ||||
|             binding.progressBar.visibility = View.GONE | ||||
|             showDismissibleSnackBar( | ||||
|                 requireView().findViewById(android.R.id.content), | ||||
|                 R.string.achievements_fetch_failed, R.string.retry | ||||
|             ) { setAchievements() } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Shows a generic error toast when error occurs while loading achievements or uploads | ||||
|      */ | ||||
|     private fun onError() { | ||||
|         showLongToast(requireActivity(), resources.getString(R.string.error_occurred)) | ||||
|         binding.progressBar.visibility = View.GONE | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * used to the count of images uploaded by user | ||||
|      */ | ||||
| 
 | ||||
|     private fun setUploadCount(achievements: Achievements) { | ||||
|         if (checkAccount()) { | ||||
|             compositeDisposable.add(okHttpJsonApiClient | ||||
|                 .getUploadCount(Objects.requireNonNull<String>(userName)) | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe( | ||||
|                     { uploadCount: Int? -> | ||||
|                         setAchievementsUploadCount( | ||||
|                             achievements, | ||||
|                             uploadCount ?:0 | ||||
|                         ) | ||||
|                     }, | ||||
|                     { t: Throwable? -> | ||||
|                         Timber.e(t, "Fetching upload count failed") | ||||
|                         onError() | ||||
|                     } | ||||
|                 )) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * used to set achievements upload count and call hideProgressbar | ||||
|      * @param uploadCount | ||||
|      */ | ||||
|     private fun setAchievementsUploadCount(achievements: Achievements, uploadCount: Int) { | ||||
|         // Create a new instance of Achievements with updated imagesUploaded | ||||
|         val updatedAchievements = Achievements( | ||||
|             achievements.uniqueUsedImages, | ||||
|             achievements.articlesUsingImages, | ||||
|             achievements.thanksReceived, | ||||
|             achievements.featuredImages, | ||||
|             achievements.qualityImages, | ||||
|             uploadCount,  // Update imagesUploaded with new value | ||||
|             achievements.revertCount | ||||
|         ) | ||||
| 
 | ||||
|         hideProgressBar(updatedAchievements) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * used to the uploaded images progressbar | ||||
|      * @param uploadCount | ||||
|      */ | ||||
|     private fun setUploadProgress(uploadCount: Int) { | ||||
|         if (uploadCount == 0) { | ||||
|             setZeroAchievements() | ||||
|         } else { | ||||
|             binding.imagesUploadedProgressbar.visibility = View.VISIBLE | ||||
|             binding.imagesUploadedProgressbar.progress = | ||||
|                 100 * uploadCount / levelInfo.maxUploadCount | ||||
|             binding.imageUploadedTVCount.text = uploadCount.toString() + "/" + levelInfo.maxUploadCount | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun setZeroAchievements() { | ||||
|         val message = if (sessionManager.userName != userName) { | ||||
|             getString(R.string.no_achievements_yet, userName ) | ||||
|         } else { | ||||
|             getString(R.string.you_have_no_achievements_yet) | ||||
|         } | ||||
|         showAlertDialog( | ||||
|             requireActivity(), | ||||
|             null, | ||||
|             message, | ||||
|             getString(R.string.ok), | ||||
|             {}, | ||||
|             true | ||||
|         ) | ||||
| 
 | ||||
| //        binding.imagesUploadedProgressbar.setVisibility(View.INVISIBLE); | ||||
| //        binding.imageRevertsProgressbar.setVisibility(View.INVISIBLE); | ||||
| //        binding.imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE); | ||||
|         //binding.achievementBadgeImage.visibility = View.INVISIBLE // TODO | ||||
|         binding.imagesUsedCount.setText(R.string.no_image) | ||||
|         binding.imagesRevertedText.setText(R.string.no_image_reverted) | ||||
|         binding.imagesUploadTextParam.setText(R.string.no_image_uploaded) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * used to set the non revert image percentage | ||||
|      * @param notRevertPercentage | ||||
|      */ | ||||
|     private fun setImageRevertPercentage(notRevertPercentage: Int) { | ||||
|         binding.imageRevertsProgressbar.visibility = View.VISIBLE | ||||
|         binding.imageRevertsProgressbar.progress = notRevertPercentage | ||||
|         val revertPercentage = notRevertPercentage.toString() | ||||
|         binding.imageRevertTVCount.text = "$revertPercentage%" | ||||
|         binding.imagesRevertLimitText.text = | ||||
|             resources.getString(R.string.achievements_revert_limit_message) + levelInfo.minNonRevertPercentage + "%" | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Used the inflate the fetched statistics of the images uploaded by user | ||||
|      * and assign badge and level. Also stores the achievements level of the user in BasicKvStore to display in menu | ||||
|      * @param achievements | ||||
|      */ | ||||
|     private fun inflateAchievements(achievements: Achievements) { | ||||
| 
 | ||||
|         // Thanks Received Badge | ||||
|         showBadgesWithCount(view = binding.thanksImageIcon, count =  achievements.thanksReceived) | ||||
| 
 | ||||
|         // Featured Images Badge | ||||
|         showBadgesWithCount(view = binding.featuredImageIcon, count =  achievements.featuredImages) | ||||
| 
 | ||||
|         // Quality Images Badge | ||||
|         showBadgesWithCount(view = binding.qualityImageIcon, count =  achievements.qualityImages) | ||||
| 
 | ||||
|         binding.imagesUsedByWikiProgressBar.progress = | ||||
|             100 * achievements.uniqueUsedImages / levelInfo.maxUniqueImages | ||||
|         binding.imagesUsedCount.text = (achievements.uniqueUsedImages.toString() + "/" | ||||
|                 + levelInfo.maxUniqueImages) | ||||
| 
 | ||||
|         binding.achievementLevel.text = getString(R.string.level,levelInfo.levelNumber) | ||||
|         binding.achievementBadgeImage.setImageDrawable( | ||||
|             VectorDrawableCompat.create( | ||||
|                 resources, R.drawable.badge, | ||||
|                 ContextThemeWrapper(activity, levelInfo.levelStyle).theme | ||||
|             ) | ||||
|         ) | ||||
|         binding.achievementBadgeText.text = levelInfo.levelNumber.toString() | ||||
|         val store = BasicKvStore(requireContext(), userName) | ||||
|         store.putString("userAchievementsLevel", levelInfo.levelNumber.toString()) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * This function is used to show badge on any view (button, imageView, etc) | ||||
|      * @param view The View on which the badge will be displayed eg (button, imageView, etc) | ||||
|      * @param count The number to be displayed inside the badge. | ||||
|      * @param backgroundColor The badge background color. Default is R.attr.colorPrimary | ||||
|      * @param badgeTextColor The badge text color. Default is R.attr.colorPrimary | ||||
|      * @param badgeGravity The position of the badge [TOP_END,TOP_START,BOTTOM_END,BOTTOM_START]. Default is TOP_END | ||||
|      * @return if the number is 0, then it will not create badge for it and hide the view | ||||
|      * @see https://developer.android.com/reference/com/google/android/material/badge/BadgeDrawable | ||||
|      */ | ||||
| 
 | ||||
|     private fun showBadgesWithCount( | ||||
|         view: View, | ||||
|         count: Int, | ||||
|         backgroundColor: Int = R.attr.colorPrimary, | ||||
|         badgeTextColor: Int = R.attr.textEnabled, | ||||
|         badgeGravity: Int = BadgeDrawable.TOP_END | ||||
|     ) { | ||||
|         //https://stackoverflow.com/a/67742035 | ||||
|         if (count == 0) { | ||||
|             view.visibility = View.GONE | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         view.viewTreeObserver.addOnGlobalLayoutListener(object : | ||||
|             ViewTreeObserver.OnGlobalLayoutListener { | ||||
|             /** | ||||
|              * Callback method to be invoked when the global layout state or the visibility of views | ||||
|              * within the view tree changes | ||||
|              */ | ||||
|             @ExperimentalBadgeUtils | ||||
|             override fun onGlobalLayout() { | ||||
|                 view.visibility = View.VISIBLE | ||||
|                 val badgeDrawable = BadgeDrawable.create(requireActivity()) | ||||
|                 badgeDrawable.number = count | ||||
|                 badgeDrawable.badgeGravity = badgeGravity | ||||
|                 badgeDrawable.badgeTextColor = badgeTextColor | ||||
|                 badgeDrawable.backgroundColor = backgroundColor | ||||
|                 BadgeUtils.attachBadgeDrawable(badgeDrawable, view) | ||||
|                 view.getViewTreeObserver().removeOnGlobalLayoutListener(this) | ||||
|             } | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * to hide progressbar | ||||
|      */ | ||||
|     private fun hideProgressBar(achievements: Achievements) { | ||||
|         if (binding.progressBar != null) { | ||||
|             levelInfo = from( | ||||
|                 achievements.imagesUploaded, | ||||
|                 achievements.uniqueUsedImages, | ||||
|                 achievements.notRevertPercentage | ||||
|             ) | ||||
|             inflateAchievements(achievements) | ||||
|             setUploadProgress(achievements.imagesUploaded) | ||||
|             setImageRevertPercentage(achievements.notRevertPercentage) | ||||
|             binding.progressBar.visibility = View.GONE | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun showUploadInfo() { | ||||
|         launchAlertWithHelpLink( | ||||
|             resources.getString(R.string.images_uploaded), | ||||
|             resources.getString(R.string.images_uploaded_explanation), | ||||
|             IMAGES_UPLOADED_URL | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fun showRevertedInfo() { | ||||
|         launchAlertWithHelpLink( | ||||
|             resources.getString(R.string.image_reverts), | ||||
|             resources.getString(R.string.images_reverted_explanation), | ||||
|             IMAGES_REVERT_URL | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fun showUsedByWikiInfo() { | ||||
|         launchAlertWithHelpLink( | ||||
|             resources.getString(R.string.images_used_by_wiki), | ||||
|             resources.getString(R.string.images_used_explanation), | ||||
|             IMAGES_USED_URL | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fun showImagesViaNearbyInfo() { | ||||
|         launchAlertWithHelpLink( | ||||
|             resources.getString(R.string.statistics_wikidata_edits), | ||||
|             resources.getString(R.string.images_via_nearby_explanation), | ||||
|             IMAGES_NEARBY_PLACES_URL | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fun showFeaturedImagesInfo() { | ||||
|         launchAlertWithHelpLink( | ||||
|             resources.getString(R.string.statistics_featured), | ||||
|             resources.getString(R.string.images_featured_explanation), | ||||
|             IMAGES_FEATURED_URL | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fun showThanksReceivedInfo() { | ||||
|         launchAlertWithHelpLink( | ||||
|             resources.getString(R.string.statistics_thanks), | ||||
|             resources.getString(R.string.thanks_received_explanation), | ||||
|             THANKS_URL | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fun showQualityImagesInfo() { | ||||
|         launchAlertWithHelpLink( | ||||
|             resources.getString(R.string.statistics_quality), | ||||
|             resources.getString(R.string.quality_images_info), | ||||
|             QUALITY_IMAGE_URL | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * takes title and message as input to display alerts | ||||
|      * @param title | ||||
|      * @param message | ||||
|      */ | ||||
|     private fun launchAlert(title: String, message: String) { | ||||
|         showAlertDialog( | ||||
|             requireActivity(), | ||||
|             title, | ||||
|             message, | ||||
|             getString(R.string.ok), | ||||
|             {}, | ||||
|             true | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Launch Alert with a READ MORE button and clicking it open a custom webpage | ||||
|      */ | ||||
|     private fun launchAlertWithHelpLink(title: String, message: String, helpLinkUrl: String) { | ||||
|         showAlertDialog( | ||||
|             requireActivity(), | ||||
|             title, | ||||
|             message, | ||||
|             getString(R.string.ok), | ||||
|             getString(R.string.read_help_link), | ||||
|             {}, | ||||
|             { Utils.handleWebUrl(requireContext(), Uri.parse(helpLinkUrl)) }, | ||||
|             null, | ||||
|             true | ||||
|         ) | ||||
|     } | ||||
|     /** | ||||
|      * check to ensure that user is logged in | ||||
|      * @return | ||||
|      */ | ||||
|     private fun checkAccount(): Boolean { | ||||
|         val currentAccount = sessionManager.currentAccount | ||||
|         if (currentAccount == null) { | ||||
|             Timber.d("Current account is null") | ||||
|             showLongToast(requireActivity(), resources.getString(R.string.user_not_logged_in)) | ||||
|             sessionManager.forceLogin(activity) | ||||
|             return false | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|     companion object{ | ||||
|         private const val BADGE_IMAGE_WIDTH_RATIO = 0.4 | ||||
|         private const val BADGE_IMAGE_HEIGHT_RATIO = 0.3 | ||||
| 
 | ||||
|         /** | ||||
|          * Help link URLs | ||||
|          */ | ||||
|         private const val IMAGES_UPLOADED_URL = "https://commons.wikimedia.org/wiki/Commons:Project_scope" | ||||
|         private const val IMAGES_REVERT_URL = "https://commons.wikimedia.org/wiki/Commons:Deletion_policy#Reasons_for_deletion" | ||||
|         private const val IMAGES_USED_URL = "https://en.wikipedia.org/wiki/Wikipedia:Manual_of_Style/Images" | ||||
|         private const val IMAGES_NEARBY_PLACES_URL = "https://www.wikidata.org/wiki/Property:P18" | ||||
|         private const val IMAGES_FEATURED_URL = "https://commons.wikimedia.org/wiki/Commons:Featured_pictures" | ||||
|         private const val QUALITY_IMAGE_URL = "https://commons.wikimedia.org/wiki/Commons:Quality_images" | ||||
|         private const val THANKS_URL = "https://www.mediawiki.org/wiki/Extension:Thanks" | ||||
|     } | ||||
| } | ||||
|  | @ -38,6 +38,7 @@ import fr.free.nrw.commons.campaigns.CampaignView | |||
| import fr.free.nrw.commons.contributions.ContributionController | ||||
| import fr.free.nrw.commons.contributions.MainActivity | ||||
| import fr.free.nrw.commons.di.ApplicationlessInjection | ||||
| import fr.free.nrw.commons.filepicker.FilePicker | ||||
| import fr.free.nrw.commons.kvstore.JsonKvStore | ||||
| import fr.free.nrw.commons.location.LocationServiceManager | ||||
| import fr.free.nrw.commons.logging.CommonsLogSender | ||||
|  | @ -83,9 +84,17 @@ class SettingsFragment : PreferenceFragmentCompat() { | |||
| 
 | ||||
|     private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> = | ||||
|         registerForActivityResult(StartActivityForResult()) { result -> | ||||
|         contributionController.handleActivityResultWithCallback(requireActivity()) { callbacks -> | ||||
|             contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks) | ||||
|         contributionController.handleActivityResultWithCallback( | ||||
|             requireActivity(), | ||||
|             object: FilePicker.HandleActivityResult { | ||||
|                 override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) { | ||||
|                     contributionController.onPictureReturnedFromCamera( | ||||
|                         result, | ||||
|                         requireActivity(), | ||||
|                         callbacks | ||||
|                     ) | ||||
|                 } | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
|  | @ -194,7 +194,7 @@ class FileProcessor | |||
|             requireNotNull(imageCoordinates.decimalCoords) | ||||
|             compositeDisposable.add( | ||||
|                 apiCall | ||||
|                     .request(imageCoordinates.decimalCoords) | ||||
|                     .request(imageCoordinates.decimalCoords!!) | ||||
|                     .subscribeOn(Schedulers.io()) | ||||
|                     .observeOn(Schedulers.io()) | ||||
|                     .subscribe( | ||||
|  | @ -220,7 +220,7 @@ class FileProcessor | |||
|                 .concatMap { | ||||
|                     Observable.fromCallable { | ||||
|                         okHttpJsonApiClient.getNearbyPlaces( | ||||
|                             imageCoordinates.latLng, | ||||
|                             imageCoordinates.latLng!!, | ||||
|                             Locale.getDefault().language, | ||||
|                             it, | ||||
|                         ) | ||||
|  |  | |||
|  | @ -496,14 +496,14 @@ class UploadWorker( | |||
| 
 | ||||
|                 withContext(Dispatchers.Main) { | ||||
|                     wikidataEditService.handleImageClaimResult( | ||||
|                         contribution.wikidataPlace, | ||||
|                         contribution.wikidataPlace!!, | ||||
|                         revisionID, | ||||
|                     ) | ||||
|                 } | ||||
|             } else { | ||||
|                 withContext(Dispatchers.Main) { | ||||
|                     wikidataEditService.handleImageClaimResult( | ||||
|                         contribution.wikidataPlace, | ||||
|                         contribution.wikidataPlace!!, | ||||
|                         null, | ||||
|                     ) | ||||
|                 } | ||||
|  |  | |||
|  | @ -63,7 +63,7 @@ class CustomSelectorUtils { | |||
|                 fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) | ||||
|                 val sha1 = | ||||
|                     fileUtilsWrapper.getSHA1( | ||||
|                         fileUtilsWrapper.getFileInputStream(uploadableFile.filePath), | ||||
|                         fileUtilsWrapper.getFileInputStream(uploadableFile.getFilePath()), | ||||
|                     ) | ||||
|                 uploadableFile.file.delete() | ||||
|                 sha1 | ||||
|  |  | |||
|  | @ -10,11 +10,10 @@ class CommonsServiceFactory( | |||
| ) { | ||||
|     val builder: Retrofit.Builder by lazy { | ||||
|         // All instances of retrofit share this configuration, but create it lazily | ||||
|         Retrofit | ||||
|             .Builder() | ||||
|         Retrofit.Builder() | ||||
|             .client(okHttpClient) | ||||
|             .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) | ||||
|             .addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson())) | ||||
|             .addConverterFactory(GsonConverterFactory.create(GsonUtil.defaultGson)) | ||||
|     } | ||||
| 
 | ||||
|     val retrofitCache: MutableMap<String, Retrofit> = mutableMapOf() | ||||
|  |  | |||
|  | @ -1,34 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata; | ||||
| 
 | ||||
| import android.net.Uri; | ||||
| import com.google.gson.Gson; | ||||
| import com.google.gson.GsonBuilder; | ||||
| import fr.free.nrw.commons.wikidata.json.RequiredFieldsCheckOnReadTypeAdapterFactory; | ||||
| import fr.free.nrw.commons.wikidata.model.DataValue; | ||||
| import fr.free.nrw.commons.wikidata.model.WikiSite; | ||||
| import fr.free.nrw.commons.wikidata.json.NamespaceTypeAdapter; | ||||
| import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter; | ||||
| import fr.free.nrw.commons.wikidata.json.UriTypeAdapter; | ||||
| import fr.free.nrw.commons.wikidata.json.WikiSiteTypeAdapter; | ||||
| import fr.free.nrw.commons.wikidata.model.page.Namespace; | ||||
| 
 | ||||
| public final class GsonUtil { | ||||
|     private static final String DATE_FORMAT = "MMM dd, yyyy HH:mm:ss"; | ||||
| 
 | ||||
|     private static final GsonBuilder DEFAULT_GSON_BUILDER = new GsonBuilder() | ||||
|             .setDateFormat(DATE_FORMAT) | ||||
|             .registerTypeAdapterFactory(DataValue.getPolymorphicTypeAdapter()) | ||||
|             .registerTypeHierarchyAdapter(Uri.class, new UriTypeAdapter().nullSafe()) | ||||
|             .registerTypeHierarchyAdapter(Namespace.class, new NamespaceTypeAdapter().nullSafe()) | ||||
|             .registerTypeAdapter(WikiSite.class, new WikiSiteTypeAdapter().nullSafe()) | ||||
|             .registerTypeAdapterFactory(new RequiredFieldsCheckOnReadTypeAdapterFactory()) | ||||
|             .registerTypeAdapterFactory(new PostProcessingTypeAdapter()); | ||||
| 
 | ||||
|     private static final Gson DEFAULT_GSON = DEFAULT_GSON_BUILDER.create(); | ||||
| 
 | ||||
|     public static Gson getDefaultGson() { | ||||
|         return DEFAULT_GSON; | ||||
|     } | ||||
| 
 | ||||
|     private GsonUtil() { } | ||||
| } | ||||
							
								
								
									
										29
									
								
								app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| package fr.free.nrw.commons.wikidata | ||||
| 
 | ||||
| import android.net.Uri | ||||
| import com.google.gson.Gson | ||||
| import com.google.gson.GsonBuilder | ||||
| import fr.free.nrw.commons.wikidata.json.NamespaceTypeAdapter | ||||
| import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter | ||||
| import fr.free.nrw.commons.wikidata.json.RequiredFieldsCheckOnReadTypeAdapterFactory | ||||
| import fr.free.nrw.commons.wikidata.json.UriTypeAdapter | ||||
| import fr.free.nrw.commons.wikidata.json.WikiSiteTypeAdapter | ||||
| import fr.free.nrw.commons.wikidata.model.DataValue.Companion.polymorphicTypeAdapter | ||||
| import fr.free.nrw.commons.wikidata.model.WikiSite | ||||
| import fr.free.nrw.commons.wikidata.model.page.Namespace | ||||
| 
 | ||||
| object GsonUtil { | ||||
|     private const val DATE_FORMAT = "MMM dd, yyyy HH:mm:ss" | ||||
| 
 | ||||
|     private val DEFAULT_GSON_BUILDER: GsonBuilder by lazy { | ||||
|         GsonBuilder().setDateFormat(DATE_FORMAT) | ||||
|             .registerTypeAdapterFactory(polymorphicTypeAdapter) | ||||
|             .registerTypeHierarchyAdapter(Uri::class.java, UriTypeAdapter().nullSafe()) | ||||
|             .registerTypeHierarchyAdapter(Namespace::class.java, NamespaceTypeAdapter().nullSafe()) | ||||
|             .registerTypeAdapter(WikiSite::class.java, WikiSiteTypeAdapter().nullSafe()) | ||||
|             .registerTypeAdapterFactory(RequiredFieldsCheckOnReadTypeAdapterFactory()) | ||||
|             .registerTypeAdapterFactory(PostProcessingTypeAdapter()) | ||||
|     } | ||||
| 
 | ||||
|     val defaultGson: Gson by lazy { DEFAULT_GSON_BUILDER.create() } | ||||
| } | ||||
|  | @ -1,11 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata; | ||||
| 
 | ||||
| public class WikidataConstants { | ||||
|     public static final String PLACE_OBJECT = "place"; | ||||
|     public static final String BOOKMARKS_ITEMS = "bookmarks.items"; | ||||
|     public static final String SELECTED_NEARBY_PLACE = "selected.nearby.place"; | ||||
|     public static final String SELECTED_NEARBY_PLACE_CATEGORY = "selected.nearby.place.category"; | ||||
| 
 | ||||
|     public static final String MW_API_PREFIX = "w/api.php?format=json&formatversion=2&errorformat=plaintext&"; | ||||
|     public static final String WIKIPEDIA_URL = "https://wikipedia.org/"; | ||||
| } | ||||
|  | @ -0,0 +1,11 @@ | |||
| package fr.free.nrw.commons.wikidata | ||||
| 
 | ||||
| object WikidataConstants { | ||||
|     const val PLACE_OBJECT: String = "place" | ||||
|     const val BOOKMARKS_ITEMS: String = "bookmarks.items" | ||||
|     const val SELECTED_NEARBY_PLACE: String = "selected.nearby.place" | ||||
|     const val SELECTED_NEARBY_PLACE_CATEGORY: String = "selected.nearby.place.category" | ||||
| 
 | ||||
|     const val MW_API_PREFIX: String = "w/api.php?format=json&formatversion=2&errorformat=plaintext&" | ||||
|     const val WIKIPEDIA_URL: String = "https://wikipedia.org/" | ||||
| } | ||||
|  | @ -1,16 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata; | ||||
| 
 | ||||
| public abstract class WikidataEditListener { | ||||
| 
 | ||||
|     protected WikidataP18EditListener wikidataP18EditListener; | ||||
| 
 | ||||
|     public abstract void onSuccessfulWikidataEdit(); | ||||
| 
 | ||||
|     public void setAuthenticationStateListener(WikidataP18EditListener wikidataP18EditListener) { | ||||
|         this.wikidataP18EditListener = wikidataP18EditListener; | ||||
|     } | ||||
| 
 | ||||
|     public interface WikidataP18EditListener { | ||||
|         void onWikidataEditSuccessful(); | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,11 @@ | |||
| package fr.free.nrw.commons.wikidata | ||||
| 
 | ||||
| abstract class WikidataEditListener { | ||||
|     var authenticationStateListener: WikidataP18EditListener? = null | ||||
| 
 | ||||
|     abstract fun onSuccessfulWikidataEdit() | ||||
| 
 | ||||
|     interface WikidataP18EditListener { | ||||
|         fun onWikidataEditSuccessful() | ||||
|     } | ||||
| } | ||||
|  | @ -1,20 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata; | ||||
| 
 | ||||
| /** | ||||
|  * Listener for wikidata edits | ||||
|  */ | ||||
| public class WikidataEditListenerImpl extends WikidataEditListener { | ||||
| 
 | ||||
|     public WikidataEditListenerImpl() { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fired when wikidata P18 edit is successful. If there's an active listener, then it is fired | ||||
|      */ | ||||
|     @Override | ||||
|     public void onSuccessfulWikidataEdit() { | ||||
|         if (wikidataP18EditListener != null) { | ||||
|             wikidataP18EditListener.onWikidataEditSuccessful(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,13 @@ | |||
| package fr.free.nrw.commons.wikidata | ||||
| 
 | ||||
| /** | ||||
|  * Listener for wikidata edits | ||||
|  */ | ||||
| class WikidataEditListenerImpl : WikidataEditListener() { | ||||
|     /** | ||||
|      * Fired when wikidata P18 edit is successful. If there's an active listener, then it is fired | ||||
|      */ | ||||
|     override fun onSuccessfulWikidataEdit() { | ||||
|         authenticationStateListener?.onWikidataEditSuccessful() | ||||
|     } | ||||
| } | ||||
|  | @ -1,271 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata; | ||||
| 
 | ||||
| 
 | ||||
| import static fr.free.nrw.commons.media.MediaClientKt.PAGE_ID_PREFIX; | ||||
| 
 | ||||
| import android.annotation.SuppressLint; | ||||
| import android.content.Context; | ||||
| import androidx.annotation.Nullable; | ||||
| import com.google.gson.Gson; | ||||
| import fr.free.nrw.commons.R; | ||||
| import fr.free.nrw.commons.contributions.Contribution; | ||||
| import fr.free.nrw.commons.kvstore.JsonKvStore; | ||||
| import fr.free.nrw.commons.upload.UploadResult; | ||||
| import fr.free.nrw.commons.upload.WikidataItem; | ||||
| import fr.free.nrw.commons.upload.WikidataPlace; | ||||
| import fr.free.nrw.commons.utils.ConfigUtils; | ||||
| import fr.free.nrw.commons.utils.ViewUtil; | ||||
| import fr.free.nrw.commons.wikidata.model.DataValue; | ||||
| import fr.free.nrw.commons.wikidata.model.DataValue.ValueString; | ||||
| import fr.free.nrw.commons.wikidata.model.EditClaim; | ||||
| import fr.free.nrw.commons.wikidata.model.RemoveClaim; | ||||
| import fr.free.nrw.commons.wikidata.model.SnakPartial; | ||||
| import fr.free.nrw.commons.wikidata.model.StatementPartial; | ||||
| import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue; | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse; | ||||
| import io.reactivex.Observable; | ||||
| import io.reactivex.schedulers.Schedulers; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
| import java.util.Locale; | ||||
| import java.util.Map; | ||||
| import java.util.UUID; | ||||
| import javax.inject.Inject; | ||||
| import javax.inject.Named; | ||||
| import javax.inject.Singleton; | ||||
| import timber.log.Timber; | ||||
| 
 | ||||
| /** | ||||
|  * This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki | ||||
|  * Apis to make the necessary calls, log the edits and fire listeners on successful edits | ||||
|  */ | ||||
| @Singleton | ||||
| public class WikidataEditService { | ||||
| 
 | ||||
|     public static final String COMMONS_APP_TAG = "wikimedia-commons-app"; | ||||
| 
 | ||||
|     private final Context context; | ||||
|     private final WikidataEditListener wikidataEditListener; | ||||
|     private final JsonKvStore directKvStore; | ||||
|     private final WikiBaseClient wikiBaseClient; | ||||
|     private final WikidataClient wikidataClient; | ||||
|     private final Gson gson; | ||||
| 
 | ||||
|     @Inject | ||||
|     public WikidataEditService(final Context context, | ||||
|         final WikidataEditListener wikidataEditListener, | ||||
|         @Named("default_preferences") final JsonKvStore directKvStore, | ||||
|         final WikiBaseClient wikiBaseClient, | ||||
|         final WikidataClient wikidataClient, final Gson gson) { | ||||
|         this.context = context; | ||||
|         this.wikidataEditListener = wikidataEditListener; | ||||
|         this.directKvStore = directKvStore; | ||||
|         this.wikiBaseClient = wikiBaseClient; | ||||
|         this.wikidataClient = wikidataClient; | ||||
|         this.gson = gson; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Edits the wikibase entity by adding DEPICTS property. Adding DEPICTS property requires call | ||||
|      * to the wikibase API to set tag against the entity. | ||||
|      */ | ||||
|     @SuppressLint("CheckResult") | ||||
|     private Observable<Boolean> addDepictsProperty( | ||||
|         final String fileEntityId, | ||||
|         final List<String> depictedItems | ||||
|     ) { | ||||
|         final EditClaim data = editClaim( | ||||
|             ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10") | ||||
|                 // Wikipedia:Sandbox (Q10) | ||||
|                 : depictedItems | ||||
|         ); | ||||
| 
 | ||||
|         return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data)) | ||||
|             .doOnNext(success -> { | ||||
|                 if (success) { | ||||
|                     Timber.d("DEPICTS property was set successfully for %s", fileEntityId); | ||||
|                 } else { | ||||
|                     Timber.d("Unable to set DEPICTS property for %s", fileEntityId); | ||||
|                 } | ||||
|             }) | ||||
|             .doOnError(throwable -> { | ||||
|                 Timber.e(throwable, "Error occurred while setting DEPICTS property"); | ||||
|                 ViewUtil.showLongToast(context, throwable.toString()); | ||||
|             }) | ||||
|             .subscribeOn(Schedulers.io()); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Takes depicts ID as a parameter and create a uploadable data with the Id | ||||
|      * and send the data for POST operation | ||||
|      * | ||||
|      * @param fileEntityId ID of the file | ||||
|      * @param depictedItems IDs of the selected depict item | ||||
|      * @return Observable<Boolean> | ||||
|      */ | ||||
|     @SuppressLint("CheckResult") | ||||
|     public Observable<Boolean> updateDepictsProperty( | ||||
|         final String fileEntityId, | ||||
|         final List<String> depictedItems | ||||
|     ) { | ||||
|         final String entityId = PAGE_ID_PREFIX + fileEntityId; | ||||
|         final List<String> claimIds = getDepictionsClaimIds(entityId); | ||||
| 
 | ||||
|         final RemoveClaim data = removeClaim( /* Please consider removeClaim scenario for BetaDebug */ | ||||
|             ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10") | ||||
|                 // Wikipedia:Sandbox (Q10) | ||||
|                 : claimIds | ||||
|         ); | ||||
| 
 | ||||
|         return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data)) | ||||
|             .doOnError(throwable -> { | ||||
|                 Timber.e(throwable, "Error occurred while removing existing claims for DEPICTS property"); | ||||
|                 ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); | ||||
|             }).switchMap(success-> { | ||||
|                 if(success) { | ||||
|                     Timber.d("DEPICTS property was deleted successfully"); | ||||
|                     return addDepictsProperty(fileEntityId, depictedItems); | ||||
|                 } else { | ||||
|                     Timber.d("Unable to delete DEPICTS property"); | ||||
|                     return Observable.empty(); | ||||
|                 } | ||||
|             }); | ||||
|     } | ||||
| 
 | ||||
|     @SuppressLint("CheckResult") | ||||
|     private List<String> getDepictionsClaimIds(final String entityId) { | ||||
|         return wikiBaseClient.getClaimIdsByProperty(entityId, WikidataProperties.DEPICTS.getPropertyName()) | ||||
|             .subscribeOn(Schedulers.io()) | ||||
|             .blockingFirst(); | ||||
|     } | ||||
| 
 | ||||
|     private EditClaim editClaim(final List<String> entityIds) { | ||||
|         return EditClaim.from(entityIds, WikidataProperties.DEPICTS.getPropertyName()); | ||||
|     } | ||||
| 
 | ||||
|     private RemoveClaim removeClaim(final List<String> claimIds) { | ||||
|         return RemoveClaim.from(claimIds); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show a success toast when the edit is made successfully | ||||
|      */ | ||||
|     private void showSuccessToast(final String wikiItemName) { | ||||
|         final String successStringTemplate = context.getString(R.string.successful_wikidata_edit); | ||||
|         final String successMessage = String | ||||
|             .format(Locale.getDefault(), successStringTemplate, wikiItemName); | ||||
|         ViewUtil.showLongToast(context, successMessage); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Adds label to Wikidata using the fileEntityId and the edit token, obtained from | ||||
|      * csrfTokenClient | ||||
|      * | ||||
|      * @param fileEntityId | ||||
|      * @return | ||||
|      */ | ||||
|     @SuppressLint("CheckResult") | ||||
|     private Observable<Boolean> addCaption(final long fileEntityId, final String languageCode, | ||||
|         final String captionValue) { | ||||
|         return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue) | ||||
|             .doOnNext(mwPostResponse -> onAddCaptionResponse(fileEntityId, mwPostResponse)) | ||||
|             .doOnError(throwable -> { | ||||
|                 Timber.e(throwable, "Error occurred while setting Captions"); | ||||
|                 ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); | ||||
|             }) | ||||
|             .map(mwPostResponse -> mwPostResponse != null); | ||||
|     } | ||||
| 
 | ||||
|     private void onAddCaptionResponse(Long fileEntityId, MwPostResponse response) { | ||||
|         if (response != null) { | ||||
|             Timber.d("Caption successfully set, revision id = %s", response); | ||||
|         } else { | ||||
|             Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public Long createClaim(@Nullable final WikidataPlace wikidataPlace, final String fileName, | ||||
|         final Map<String, String> captions) { | ||||
|         if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) { | ||||
|             Timber | ||||
|                 .d("Image location and nearby place location mismatched, so Wikidata item won't be edited"); | ||||
|             return null; | ||||
|         } | ||||
|         return addImageAndMediaLegends(wikidataPlace, fileName, captions); | ||||
|     } | ||||
| 
 | ||||
|     public Long addImageAndMediaLegends(final WikidataItem wikidataItem, final String fileName, | ||||
|         final Map<String, String> captions) { | ||||
|         final SnakPartial p18 = new SnakPartial("value", | ||||
|             WikidataProperties.IMAGE.getPropertyName(), | ||||
|             new ValueString(fileName.replace("File:", ""))); | ||||
| 
 | ||||
|         final List<SnakPartial> snaks = new ArrayList<>(); | ||||
|         for (final Map.Entry<String, String> entry : captions.entrySet()) { | ||||
|             snaks.add(new SnakPartial("value", | ||||
|                 WikidataProperties.MEDIA_LEGENDS.getPropertyName(), new DataValue.MonoLingualText( | ||||
|                 new WikiBaseMonolingualTextValue(entry.getValue(), entry.getKey())))); | ||||
|         } | ||||
| 
 | ||||
|         final String id = wikidataItem.getId() + "$" + UUID.randomUUID().toString(); | ||||
|         final StatementPartial claim = new StatementPartial(p18, "statement", "normal", id, | ||||
|             Collections.singletonMap(WikidataProperties.MEDIA_LEGENDS.getPropertyName(), snaks), | ||||
|             Arrays.asList(WikidataProperties.MEDIA_LEGENDS.getPropertyName())); | ||||
| 
 | ||||
|         return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle(); | ||||
|     } | ||||
| 
 | ||||
|     public void handleImageClaimResult(final WikidataItem wikidataItem, final Long revisionId) { | ||||
|         if (revisionId != null) { | ||||
|             if (wikidataEditListener != null) { | ||||
|                 wikidataEditListener.onSuccessfulWikidataEdit(); | ||||
|             } | ||||
|             showSuccessToast(wikidataItem.getName()); | ||||
|         } else { | ||||
|             Timber.d("Unable to make wiki data edit for entity %s", wikidataItem); | ||||
|             ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public Observable<Boolean> addDepictionsAndCaptions( | ||||
|         final UploadResult uploadResult, | ||||
|         final Contribution contribution | ||||
|     ) { | ||||
|         return wikiBaseClient.getFileEntityId(uploadResult) | ||||
|             .doOnError(throwable -> { | ||||
|                 Timber | ||||
|                     .e(throwable, "Error occurred while getting EntityID to set DEPICTS property"); | ||||
|                 ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); | ||||
|             }) | ||||
|             .switchMap(fileEntityId -> { | ||||
|                     if (fileEntityId != null) { | ||||
|                         Timber.d("EntityId for image was received successfully: %s", fileEntityId); | ||||
|                         return Observable.concat( | ||||
|                             depictionEdits(contribution, fileEntityId), | ||||
|                             captionEdits(contribution, fileEntityId) | ||||
|                         ); | ||||
|                     } else { | ||||
|                         Timber.d("Error acquiring EntityId for image: %s", uploadResult); | ||||
|                         return Observable.empty(); | ||||
|                     } | ||||
|                 } | ||||
|             ); | ||||
|     } | ||||
| 
 | ||||
|     private Observable<Boolean> captionEdits(Contribution contribution, Long fileEntityId) { | ||||
|         return Observable.fromIterable(contribution.getMedia().getCaptions().entrySet()) | ||||
|             .concatMap(entry -> addCaption(fileEntityId, entry.getKey(), entry.getValue())); | ||||
|     } | ||||
| 
 | ||||
|     private Observable<Boolean> depictionEdits(Contribution contribution, Long fileEntityId) { | ||||
|         final List<String> depictIDs = new ArrayList<>(); | ||||
|         for (final WikidataItem wikidataItem : | ||||
|             contribution.getDepictedItems()) { | ||||
|             depictIDs.add(wikidataItem.getId()); | ||||
|         } | ||||
|         return addDepictsProperty(fileEntityId.toString(), depictIDs); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | @ -0,0 +1,252 @@ | |||
| package fr.free.nrw.commons.wikidata | ||||
| 
 | ||||
| import android.annotation.SuppressLint | ||||
| import android.content.Context | ||||
| import com.google.gson.Gson | ||||
| import fr.free.nrw.commons.R | ||||
| import fr.free.nrw.commons.contributions.Contribution | ||||
| import fr.free.nrw.commons.kvstore.JsonKvStore | ||||
| import fr.free.nrw.commons.media.PAGE_ID_PREFIX | ||||
| import fr.free.nrw.commons.upload.UploadResult | ||||
| import fr.free.nrw.commons.upload.WikidataItem | ||||
| import fr.free.nrw.commons.upload.WikidataPlace | ||||
| import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour | ||||
| import fr.free.nrw.commons.utils.ViewUtil.showLongToast | ||||
| import fr.free.nrw.commons.wikidata.WikidataProperties.DEPICTS | ||||
| import fr.free.nrw.commons.wikidata.WikidataProperties.IMAGE | ||||
| import fr.free.nrw.commons.wikidata.WikidataProperties.MEDIA_LEGENDS | ||||
| import fr.free.nrw.commons.wikidata.model.DataValue.MonoLingualText | ||||
| import fr.free.nrw.commons.wikidata.model.DataValue.ValueString | ||||
| import fr.free.nrw.commons.wikidata.model.EditClaim | ||||
| import fr.free.nrw.commons.wikidata.model.RemoveClaim | ||||
| import fr.free.nrw.commons.wikidata.model.SnakPartial | ||||
| import fr.free.nrw.commons.wikidata.model.StatementPartial | ||||
| import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse | ||||
| import io.reactivex.Observable | ||||
| import io.reactivex.schedulers.Schedulers | ||||
| import timber.log.Timber | ||||
| import java.util.Arrays | ||||
| import java.util.Collections | ||||
| import java.util.Locale | ||||
| import java.util.Objects | ||||
| import java.util.UUID | ||||
| import javax.inject.Inject | ||||
| import javax.inject.Named | ||||
| import javax.inject.Singleton | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki | ||||
|  * Apis to make the necessary calls, log the edits and fire listeners on successful edits | ||||
|  */ | ||||
| @Singleton | ||||
| class WikidataEditService @Inject constructor( | ||||
|     private val context: Context, | ||||
|     private val wikidataEditListener: WikidataEditListener?, | ||||
|     @param:Named("default_preferences") private val directKvStore: JsonKvStore, | ||||
|     private val wikiBaseClient: WikiBaseClient, | ||||
|     private val wikidataClient: WikidataClient, private val gson: Gson | ||||
| ) { | ||||
|     @SuppressLint("CheckResult") | ||||
|     private fun addDepictsProperty( | ||||
|         fileEntityId: String, | ||||
|         depictedItems: List<String> | ||||
|     ): Observable<Boolean> { | ||||
|         val data = EditClaim.from( | ||||
|             if (isBetaFlavour) listOf("Q10") else depictedItems, DEPICTS.propertyName | ||||
|         ) | ||||
| 
 | ||||
|         return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data)) | ||||
|             .doOnNext { success: Boolean -> | ||||
|                 if (success) { | ||||
|                     Timber.d("DEPICTS property was set successfully for %s", fileEntityId) | ||||
|                 } else { | ||||
|                     Timber.d("Unable to set DEPICTS property for %s", fileEntityId) | ||||
|                 } | ||||
|             } | ||||
|             .doOnError { throwable: Throwable -> | ||||
|                 Timber.e(throwable, "Error occurred while setting DEPICTS property") | ||||
|                 showLongToast(context, throwable.toString()) | ||||
|             } | ||||
|             .subscribeOn(Schedulers.io()) | ||||
|     } | ||||
| 
 | ||||
|     @SuppressLint("CheckResult") | ||||
|     fun updateDepictsProperty( | ||||
|         fileEntityId: String?, | ||||
|         depictedItems: List<String> | ||||
|     ): Observable<Boolean> { | ||||
|         val entityId: String = PAGE_ID_PREFIX + fileEntityId | ||||
|         val claimIds = getDepictionsClaimIds(entityId) | ||||
| 
 | ||||
|         /* Please consider removeClaim scenario for BetaDebug */ | ||||
|         val data = RemoveClaim.from(if (isBetaFlavour) listOf("Q10") else claimIds) | ||||
| 
 | ||||
|         return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data)) | ||||
|             .doOnError { throwable: Throwable? -> | ||||
|                 Timber.e( | ||||
|                     throwable, | ||||
|                     "Error occurred while removing existing claims for DEPICTS property" | ||||
|                 ) | ||||
|                 showLongToast( | ||||
|                     context, | ||||
|                     context.getString(R.string.wikidata_edit_failure) | ||||
|                 ) | ||||
|             }.switchMap { success: Boolean -> | ||||
|                 if (success) { | ||||
|                     Timber.d("DEPICTS property was deleted successfully") | ||||
|                     return@switchMap addDepictsProperty(fileEntityId!!, depictedItems) | ||||
|                 } else { | ||||
|                     Timber.d("Unable to delete DEPICTS property") | ||||
|                     return@switchMap Observable.empty<Boolean>() | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
| 
 | ||||
|     @SuppressLint("CheckResult") | ||||
|     private fun getDepictionsClaimIds(entityId: String): List<String> { | ||||
|         return wikiBaseClient.getClaimIdsByProperty(entityId, DEPICTS.propertyName) | ||||
|             .subscribeOn(Schedulers.io()) | ||||
|             .blockingFirst() | ||||
|     } | ||||
| 
 | ||||
|     private fun showSuccessToast(wikiItemName: String) { | ||||
|         val successStringTemplate = context.getString(R.string.successful_wikidata_edit) | ||||
|         val successMessage = String.format(Locale.getDefault(), successStringTemplate, wikiItemName) | ||||
|         showLongToast(context, successMessage) | ||||
|     } | ||||
| 
 | ||||
|     @SuppressLint("CheckResult") | ||||
|     private fun addCaption( | ||||
|         fileEntityId: Long, languageCode: String, | ||||
|         captionValue: String | ||||
|     ): Observable<Boolean> { | ||||
|         return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue) | ||||
|             .doOnNext { mwPostResponse: MwPostResponse? -> | ||||
|                 onAddCaptionResponse( | ||||
|                     fileEntityId, | ||||
|                     mwPostResponse | ||||
|                 ) | ||||
|             } | ||||
|             .doOnError { throwable: Throwable? -> | ||||
|                 Timber.e(throwable, "Error occurred while setting Captions") | ||||
|                 showLongToast( | ||||
|                     context, | ||||
|                     context.getString(R.string.wikidata_edit_failure) | ||||
|                 ) | ||||
|             } | ||||
|             .map(Objects::nonNull) | ||||
|     } | ||||
| 
 | ||||
|     private fun onAddCaptionResponse(fileEntityId: Long, response: MwPostResponse?) { | ||||
|         if (response != null) { | ||||
|             Timber.d("Caption successfully set, revision id = %s", response) | ||||
|         } else { | ||||
|             Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun createClaim( | ||||
|         wikidataPlace: WikidataPlace?, fileName: String, | ||||
|         captions: Map<String, String> | ||||
|     ): Long? { | ||||
|         if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) { | ||||
|             Timber.d( | ||||
|                 "Image location and nearby place location mismatched, so Wikidata item won't be edited" | ||||
|             ) | ||||
|             return null | ||||
|         } | ||||
|         return addImageAndMediaLegends(wikidataPlace!!, fileName, captions) | ||||
|     } | ||||
| 
 | ||||
|     fun addImageAndMediaLegends( | ||||
|         wikidataItem: WikidataItem, fileName: String, | ||||
|         captions: Map<String, String> | ||||
|     ): Long { | ||||
|         val p18 = SnakPartial( | ||||
|             "value", | ||||
|             IMAGE.propertyName, | ||||
|             ValueString(fileName.replace("File:", "")) | ||||
|         ) | ||||
| 
 | ||||
|         val snaks: MutableList<SnakPartial> = ArrayList() | ||||
|         for ((key, value) in captions) { | ||||
|             snaks.add( | ||||
|                 SnakPartial( | ||||
|                     "value", | ||||
|                     MEDIA_LEGENDS.propertyName, MonoLingualText( | ||||
|                         WikiBaseMonolingualTextValue(value!!, key!!) | ||||
|                     ) | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
| 
 | ||||
|         val id = wikidataItem.id + "$" + UUID.randomUUID().toString() | ||||
|         val claim = StatementPartial( | ||||
|             p18, "statement", "normal", id, Collections.singletonMap<String, List<SnakPartial>>( | ||||
|                 MEDIA_LEGENDS.propertyName, snaks | ||||
|             ), Arrays.asList(MEDIA_LEGENDS.propertyName) | ||||
|         ) | ||||
| 
 | ||||
|         return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle() | ||||
|     } | ||||
| 
 | ||||
|     fun handleImageClaimResult(wikidataItem: WikidataItem, revisionId: Long?) { | ||||
|         if (revisionId != null) { | ||||
|             wikidataEditListener?.onSuccessfulWikidataEdit() | ||||
|             showSuccessToast(wikidataItem.name) | ||||
|         } else { | ||||
|             Timber.d("Unable to make wiki data edit for entity %s", wikidataItem) | ||||
|             showLongToast(context, context.getString(R.string.wikidata_edit_failure)) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun addDepictionsAndCaptions( | ||||
|         uploadResult: UploadResult, | ||||
|         contribution: Contribution | ||||
|     ): Observable<Boolean> { | ||||
|         return wikiBaseClient.getFileEntityId(uploadResult) | ||||
|             .doOnError { throwable: Throwable? -> | ||||
|                 Timber.e( | ||||
|                     throwable, | ||||
|                     "Error occurred while getting EntityID to set DEPICTS property" | ||||
|                 ) | ||||
|                 showLongToast( | ||||
|                     context, | ||||
|                     context.getString(R.string.wikidata_edit_failure) | ||||
|                 ) | ||||
|             } | ||||
|             .switchMap { fileEntityId: Long? -> | ||||
|                 if (fileEntityId != null) { | ||||
|                     Timber.d("EntityId for image was received successfully: %s", fileEntityId) | ||||
|                     return@switchMap Observable.concat<Boolean>( | ||||
|                         depictionEdits(contribution, fileEntityId), | ||||
|                         captionEdits(contribution, fileEntityId) | ||||
|                     ) | ||||
|                 } else { | ||||
|                     Timber.d("Error acquiring EntityId for image: %s", uploadResult) | ||||
|                     return@switchMap Observable.empty<Boolean>() | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
| 
 | ||||
|     private fun captionEdits(contribution: Contribution, fileEntityId: Long): Observable<Boolean> { | ||||
|         return Observable.fromIterable(contribution.media.captions.entries) | ||||
|             .concatMap { addCaption(fileEntityId, it.key, it.value) } | ||||
|     } | ||||
| 
 | ||||
|     private fun depictionEdits( | ||||
|         contribution: Contribution, | ||||
|         fileEntityId: Long | ||||
|     ): Observable<Boolean> = addDepictsProperty(fileEntityId.toString(), buildList { | ||||
|         for ((_, _, _, _, _, _, id) in contribution.depictedItems) { | ||||
|             add(id) | ||||
|         } | ||||
|     }) | ||||
| 
 | ||||
|     companion object { | ||||
|         const val COMMONS_APP_TAG: String = "wikimedia-commons-app" | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | @ -1,29 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata.json; | ||||
| 
 | ||||
| import com.google.gson.TypeAdapter; | ||||
| import com.google.gson.stream.JsonReader; | ||||
| import com.google.gson.stream.JsonToken; | ||||
| import com.google.gson.stream.JsonWriter; | ||||
| 
 | ||||
| import fr.free.nrw.commons.wikidata.model.page.Namespace; | ||||
| 
 | ||||
| import java.io.IOException; | ||||
| 
 | ||||
| public class NamespaceTypeAdapter extends TypeAdapter<Namespace> { | ||||
| 
 | ||||
|     @Override | ||||
|     public void write(JsonWriter out, Namespace namespace) throws IOException { | ||||
|         out.value(namespace.code()); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public Namespace read(JsonReader in) throws IOException { | ||||
|         if (in.peek() == JsonToken.STRING) { | ||||
|             // Prior to 3210ce44, we marshaled Namespace as the name string of the enum, instead of | ||||
|             // the code number. This introduces a backwards-compatible check for the string value. | ||||
|             // TODO: remove after April 2017, when all older namespaces have been deserialized. | ||||
|             return Namespace.valueOf(in.nextString()); | ||||
|         } | ||||
|         return Namespace.of(in.nextInt()); | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,26 @@ | |||
| package fr.free.nrw.commons.wikidata.json | ||||
| 
 | ||||
| import com.google.gson.TypeAdapter | ||||
| import com.google.gson.stream.JsonReader | ||||
| import com.google.gson.stream.JsonToken | ||||
| import com.google.gson.stream.JsonWriter | ||||
| import fr.free.nrw.commons.wikidata.model.page.Namespace | ||||
| import java.io.IOException | ||||
| 
 | ||||
| class NamespaceTypeAdapter : TypeAdapter<Namespace>() { | ||||
|     @Throws(IOException::class) | ||||
|     override fun write(out: JsonWriter, namespace: Namespace) { | ||||
|         out.value(namespace.code().toLong()) | ||||
|     } | ||||
| 
 | ||||
|     @Throws(IOException::class) | ||||
|     override fun read(reader: JsonReader): Namespace { | ||||
|         if (reader.peek() == JsonToken.STRING) { | ||||
|             // Prior to 3210ce44, we marshaled Namespace as the name string of the enum, instead of | ||||
|             // the code number. This introduces a backwards-compatible check for the string value. | ||||
|             // TODO: remove after April 2017, when all older namespaces have been deserialized. | ||||
|             return Namespace.valueOf(reader.nextString()) | ||||
|         } | ||||
|         return Namespace.of(reader.nextInt()) | ||||
|     } | ||||
| } | ||||
|  | @ -1,34 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata.json; | ||||
| 
 | ||||
| import com.google.gson.Gson; | ||||
| import com.google.gson.TypeAdapter; | ||||
| import com.google.gson.TypeAdapterFactory; | ||||
| import com.google.gson.reflect.TypeToken; | ||||
| import com.google.gson.stream.JsonReader; | ||||
| import com.google.gson.stream.JsonWriter; | ||||
| 
 | ||||
| import java.io.IOException; | ||||
| 
 | ||||
| public class PostProcessingTypeAdapter implements TypeAdapterFactory { | ||||
|     public interface PostProcessable { | ||||
|         void postProcess(); | ||||
|     } | ||||
| 
 | ||||
|     public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) { | ||||
|         final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type); | ||||
| 
 | ||||
|         return new TypeAdapter<T>() { | ||||
|             public void write(JsonWriter out, T value) throws IOException { | ||||
|                 delegate.write(out, value); | ||||
|             } | ||||
| 
 | ||||
|             public T read(JsonReader in) throws IOException { | ||||
|                 T obj = delegate.read(in); | ||||
|                 if (obj instanceof PostProcessable) { | ||||
|                     ((PostProcessable)obj).postProcess(); | ||||
|                 } | ||||
|                 return obj; | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,35 @@ | |||
| package fr.free.nrw.commons.wikidata.json | ||||
| 
 | ||||
| import com.google.gson.Gson | ||||
| import com.google.gson.TypeAdapter | ||||
| import com.google.gson.TypeAdapterFactory | ||||
| import com.google.gson.reflect.TypeToken | ||||
| import com.google.gson.stream.JsonReader | ||||
| import com.google.gson.stream.JsonWriter | ||||
| import java.io.IOException | ||||
| 
 | ||||
| class PostProcessingTypeAdapter : TypeAdapterFactory { | ||||
|     interface PostProcessable { | ||||
|         fun postProcess() | ||||
|     } | ||||
| 
 | ||||
|     override fun <T> create(gson: Gson, type: TypeToken<T>): TypeAdapter<T> { | ||||
|         val delegate = gson.getDelegateAdapter(this, type) | ||||
| 
 | ||||
|         return object : TypeAdapter<T>() { | ||||
|             @Throws(IOException::class) | ||||
|             override fun write(out: JsonWriter, value: T) { | ||||
|                 delegate.write(out, value) | ||||
|             } | ||||
| 
 | ||||
|             @Throws(IOException::class) | ||||
|             override fun read(reader: JsonReader): T { | ||||
|                 val obj = delegate.read(reader) | ||||
|                 if (obj is PostProcessable) { | ||||
|                     (obj as PostProcessable).postProcess() | ||||
|                 } | ||||
|                 return obj | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -1,94 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata.json; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.collection.ArraySet; | ||||
| 
 | ||||
| import com.google.gson.Gson; | ||||
| import com.google.gson.JsonParseException; | ||||
| import com.google.gson.TypeAdapter; | ||||
| import com.google.gson.TypeAdapterFactory; | ||||
| import com.google.gson.reflect.TypeToken; | ||||
| import com.google.gson.stream.JsonReader; | ||||
| import com.google.gson.stream.JsonWriter; | ||||
| 
 | ||||
| import fr.free.nrw.commons.wikidata.json.annotations.Required; | ||||
| 
 | ||||
| import java.io.IOException; | ||||
| import java.lang.reflect.Field; | ||||
| import java.util.Collections; | ||||
| import java.util.Set; | ||||
| 
 | ||||
| /** | ||||
|  * TypeAdapterFactory that provides TypeAdapters that return null values for objects that are | ||||
|  * missing fields annotated with @Required. | ||||
|  * | ||||
|  * BEWARE: This means that a List or other Collection of objects that have @Required fields can | ||||
|  * contain null elements after deserialization! | ||||
|  * | ||||
|  * TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements | ||||
|  * annotation and another corresponding TypeAdapter(Factory). | ||||
|  */ | ||||
| public class RequiredFieldsCheckOnReadTypeAdapterFactory implements TypeAdapterFactory { | ||||
|     @Nullable @Override public final <T> TypeAdapter<T> create(@NonNull Gson gson, @NonNull TypeToken<T> typeToken) { | ||||
|         Class<?> rawType = typeToken.getRawType(); | ||||
|         Set<Field> requiredFields = collectRequiredFields(rawType); | ||||
| 
 | ||||
|         if (requiredFields.isEmpty()) { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         setFieldsAccessible(requiredFields, true); | ||||
|         return new Adapter<>(gson.getDelegateAdapter(this, typeToken), requiredFields); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull private Set<Field> collectRequiredFields(@NonNull Class<?> clazz) { | ||||
|         Field[] fields = clazz.getDeclaredFields(); | ||||
|         Set<Field> required = new ArraySet<>(); | ||||
|         for (Field field : fields) { | ||||
|             if (field.isAnnotationPresent(Required.class)) { | ||||
|                 required.add(field); | ||||
|             } | ||||
|         } | ||||
|         return Collections.unmodifiableSet(required); | ||||
|     } | ||||
| 
 | ||||
|     private void setFieldsAccessible(Iterable<Field> fields, boolean accessible) { | ||||
|         for (Field field : fields) { | ||||
|             field.setAccessible(accessible); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static final class Adapter<T> extends TypeAdapter<T> { | ||||
|         @NonNull private final TypeAdapter<T> delegate; | ||||
|         @NonNull private final Set<Field> requiredFields; | ||||
| 
 | ||||
|         private Adapter(@NonNull TypeAdapter<T> delegate, @NonNull final Set<Field> requiredFields) { | ||||
|             this.delegate = delegate; | ||||
|             this.requiredFields = requiredFields; | ||||
|         } | ||||
| 
 | ||||
|         @Override public void write(JsonWriter out, T value) throws IOException { | ||||
|             delegate.write(out, value); | ||||
|         } | ||||
| 
 | ||||
|         @Override @Nullable public T read(JsonReader in) throws IOException { | ||||
|             T deserialized = delegate.read(in); | ||||
|             return allRequiredFieldsPresent(deserialized, requiredFields) ? deserialized : null; | ||||
|         } | ||||
| 
 | ||||
|         private boolean allRequiredFieldsPresent(@NonNull T deserialized, | ||||
|                                                  @NonNull Set<Field> required) { | ||||
|             for (Field field : required) { | ||||
|                 try { | ||||
|                     if (field.get(deserialized) == null) { | ||||
|                         return false; | ||||
|                     } | ||||
|                 } catch (IllegalArgumentException | IllegalAccessException e) { | ||||
|                     throw new JsonParseException(e); | ||||
|                 } | ||||
|             } | ||||
|             return true; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,75 @@ | |||
| package fr.free.nrw.commons.wikidata.json | ||||
| 
 | ||||
| import com.google.gson.Gson | ||||
| import com.google.gson.JsonParseException | ||||
| import com.google.gson.TypeAdapter | ||||
| import com.google.gson.TypeAdapterFactory | ||||
| import com.google.gson.reflect.TypeToken | ||||
| import com.google.gson.stream.JsonReader | ||||
| import com.google.gson.stream.JsonWriter | ||||
| import fr.free.nrw.commons.wikidata.json.annotations.Required | ||||
| import java.io.IOException | ||||
| import java.lang.reflect.Field | ||||
| 
 | ||||
| /** | ||||
|  * TypeAdapterFactory that provides TypeAdapters that return null values for objects that are | ||||
|  * missing fields annotated with @Required. | ||||
|  * | ||||
|  * BEWARE: This means that a List or other Collection of objects that have @Required fields can | ||||
|  * contain null elements after deserialization! | ||||
|  * | ||||
|  * TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements | ||||
|  * annotation and another corresponding TypeAdapter(Factory). | ||||
|  */ | ||||
| class RequiredFieldsCheckOnReadTypeAdapterFactory : TypeAdapterFactory { | ||||
|     override fun <T> create(gson: Gson, typeToken: TypeToken<T>): TypeAdapter<T>? { | ||||
|         val rawType: Class<*> = typeToken.rawType | ||||
|         val requiredFields = collectRequiredFields(rawType) | ||||
| 
 | ||||
|         if (requiredFields.isEmpty()) { | ||||
|             return null | ||||
|         } | ||||
| 
 | ||||
|         for (field in requiredFields) { | ||||
|             field.isAccessible = true | ||||
|         } | ||||
| 
 | ||||
|         return Adapter(gson.getDelegateAdapter(this, typeToken), requiredFields) | ||||
|     } | ||||
| 
 | ||||
|     private fun collectRequiredFields(clazz: Class<*>): Set<Field> = buildSet { | ||||
|         for (field in clazz.declaredFields) { | ||||
|             if (field.isAnnotationPresent(Required::class.java)) add(field) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private class Adapter<T>( | ||||
|         private val delegate: TypeAdapter<T>, | ||||
|         private val requiredFields: Set<Field> | ||||
|     ) : TypeAdapter<T>() { | ||||
| 
 | ||||
|         @Throws(IOException::class) | ||||
|         override fun write(out: JsonWriter, value: T?) = | ||||
|             delegate.write(out, value) | ||||
| 
 | ||||
|         @Throws(IOException::class) | ||||
|         override fun read(reader: JsonReader): T? = | ||||
|             if (allRequiredFieldsPresent(delegate.read(reader), requiredFields)) | ||||
|                 delegate.read(reader) | ||||
|             else | ||||
|                 null | ||||
| 
 | ||||
|         fun allRequiredFieldsPresent(deserialized: T, required: Set<Field>): Boolean { | ||||
|             for (field in required) { | ||||
|                 try { | ||||
|                     if (field[deserialized] == null) return false | ||||
|                 } catch (e: IllegalArgumentException) { | ||||
|                     throw JsonParseException(e) | ||||
|                 } catch (e: IllegalAccessException) { | ||||
|                     throw JsonParseException(e) | ||||
|                 } | ||||
|             } | ||||
|             return true | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -1,280 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata.json; | ||||
| 
 | ||||
| /* | ||||
|  * Copyright (C) 2011 Google Inc. | ||||
|  * | ||||
|  * Licensed under the Apache License, Version 2.0 (the "License"); | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * | ||||
|  *      http://www.apache.org/licenses/LICENSE-2.0 | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
| 
 | ||||
| import android.util.Log; | ||||
| import java.io.IOException; | ||||
| import java.util.LinkedHashMap; | ||||
| import java.util.Map; | ||||
| 
 | ||||
| import com.google.gson.Gson; | ||||
| import com.google.gson.JsonElement; | ||||
| import com.google.gson.JsonObject; | ||||
| import com.google.gson.JsonParseException; | ||||
| import com.google.gson.JsonPrimitive; | ||||
| import com.google.gson.TypeAdapter; | ||||
| import com.google.gson.TypeAdapterFactory; | ||||
| import com.google.gson.internal.Streams; | ||||
| import com.google.gson.reflect.TypeToken; | ||||
| import com.google.gson.stream.JsonReader; | ||||
| import com.google.gson.stream.JsonWriter; | ||||
| 
 | ||||
| /** | ||||
|  * Adapts values whose runtime type may differ from their declaration type. This | ||||
|  * is necessary when a field's type is not the same type that GSON should create | ||||
|  * when deserializing that field. For example, consider these types: | ||||
|  * <pre>   {@code | ||||
|  *   abstract class Shape { | ||||
|  *     int x; | ||||
|  *     int y; | ||||
|  *   } | ||||
|  *   class Circle extends Shape { | ||||
|  *     int radius; | ||||
|  *   } | ||||
|  *   class Rectangle extends Shape { | ||||
|  *     int width; | ||||
|  *     int height; | ||||
|  *   } | ||||
|  *   class Diamond extends Shape { | ||||
|  *     int width; | ||||
|  *     int height; | ||||
|  *   } | ||||
|  *   class Drawing { | ||||
|  *     Shape bottomShape; | ||||
|  *     Shape topShape; | ||||
|  *   } | ||||
|  * }</pre> | ||||
|  * <p>Without additional type information, the serialized JSON is ambiguous. Is | ||||
|  * the bottom shape in this drawing a rectangle or a diamond? <pre>   {@code | ||||
|  *   { | ||||
|  *     "bottomShape": { | ||||
|  *       "width": 10, | ||||
|  *       "height": 5, | ||||
|  *       "x": 0, | ||||
|  *       "y": 0 | ||||
|  *     }, | ||||
|  *     "topShape": { | ||||
|  *       "radius": 2, | ||||
|  *       "x": 4, | ||||
|  *       "y": 1 | ||||
|  *     } | ||||
|  *   }}</pre> | ||||
|  * This class addresses this problem by adding type information to the | ||||
|  * serialized JSON and honoring that type information when the JSON is | ||||
|  * deserialized: <pre>   {@code | ||||
|  *   { | ||||
|  *     "bottomShape": { | ||||
|  *       "type": "Diamond", | ||||
|  *       "width": 10, | ||||
|  *       "height": 5, | ||||
|  *       "x": 0, | ||||
|  *       "y": 0 | ||||
|  *     }, | ||||
|  *     "topShape": { | ||||
|  *       "type": "Circle", | ||||
|  *       "radius": 2, | ||||
|  *       "x": 4, | ||||
|  *       "y": 1 | ||||
|  *     } | ||||
|  *   }}</pre> | ||||
|  * Both the type field name ({@code "type"}) and the type labels ({@code | ||||
|  * "Rectangle"}) are configurable. | ||||
|  * | ||||
|  * <h3>Registering Types</h3> | ||||
|  * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field | ||||
|  * name to the {@link #of} factory method. If you don't supply an explicit type | ||||
|  * field name, {@code "type"} will be used. <pre>   {@code | ||||
|  *   RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory | ||||
|  *       = RuntimeTypeAdapterFactory.of(Shape.class, "type"); | ||||
|  * }</pre> | ||||
|  * Next register all of your subtypes. Every subtype must be explicitly | ||||
|  * registered. This protects your application from injection attacks. If you | ||||
|  * don't supply an explicit type label, the type's simple name will be used. | ||||
|  * <pre>   {@code | ||||
|  *   shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle"); | ||||
|  *   shapeAdapterFactory.registerSubtype(Circle.class, "Circle"); | ||||
|  *   shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond"); | ||||
|  * }</pre> | ||||
|  * Finally, register the type adapter factory in your application's GSON builder: | ||||
|  * <pre>   {@code | ||||
|  *   Gson gson = new GsonBuilder() | ||||
|  *       .registerTypeAdapterFactory(shapeAdapterFactory) | ||||
|  *       .create(); | ||||
|  * }</pre> | ||||
|  * Like {@code GsonBuilder}, this API supports chaining: <pre>   {@code | ||||
|  *   RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class) | ||||
|  *       .registerSubtype(Rectangle.class) | ||||
|  *       .registerSubtype(Circle.class) | ||||
|  *       .registerSubtype(Diamond.class); | ||||
|  * }</pre> | ||||
|  * | ||||
|  * <h3>Serialization and deserialization</h3> | ||||
|  * In order to serialize and deserialize a polymorphic object, | ||||
|  * you must specify the base type explicitly. | ||||
|  * <pre>   {@code | ||||
|  *   Diamond diamond = new Diamond(); | ||||
|  *   String json = gson.toJson(diamond, Shape.class); | ||||
|  * }</pre> | ||||
|  * And then: | ||||
|  * <pre>   {@code | ||||
|  *   Shape shape = gson.fromJson(json, Shape.class); | ||||
|  * }</pre> | ||||
|  */ | ||||
| public final class RuntimeTypeAdapterFactory<T> implements TypeAdapterFactory { | ||||
|   private final Class<?> baseType; | ||||
|   private final String typeFieldName; | ||||
|   private final Map<String, Class<?>> labelToSubtype = new LinkedHashMap<String, Class<?>>(); | ||||
|   private final Map<Class<?>, String> subtypeToLabel = new LinkedHashMap<Class<?>, String>(); | ||||
|   private final boolean maintainType; | ||||
| 
 | ||||
|   private RuntimeTypeAdapterFactory(Class<?> baseType, String typeFieldName, boolean maintainType) { | ||||
|     if (typeFieldName == null || baseType == null) { | ||||
|       throw new NullPointerException(); | ||||
|     } | ||||
|     this.baseType = baseType; | ||||
|     this.typeFieldName = typeFieldName; | ||||
|     this.maintainType = maintainType; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Creates a new runtime type adapter using for {@code baseType} using {@code | ||||
|    * typeFieldName} as the type field name. Type field names are case sensitive. | ||||
|    * {@code maintainType} flag decide if the type will be stored in pojo or not. | ||||
|    */ | ||||
|   public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName, boolean maintainType) { | ||||
|     return new RuntimeTypeAdapterFactory<T>(baseType, typeFieldName, maintainType); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Creates a new runtime type adapter using for {@code baseType} using {@code | ||||
|    * typeFieldName} as the type field name. Type field names are case sensitive. | ||||
|    */ | ||||
|   public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName) { | ||||
|     return new RuntimeTypeAdapterFactory<T>(baseType, typeFieldName, false); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as | ||||
|    * the type field name. | ||||
|    */ | ||||
|   public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType) { | ||||
|     return new RuntimeTypeAdapterFactory<T>(baseType, "type", false); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Registers {@code type} identified by {@code label}. Labels are case | ||||
|    * sensitive. | ||||
|    * | ||||
|    * @throws IllegalArgumentException if either {@code type} or {@code label} | ||||
|    *     have already been registered on this type adapter. | ||||
|    */ | ||||
|   public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type, String label) { | ||||
|     if (type == null || label == null) { | ||||
|       throw new NullPointerException(); | ||||
|     } | ||||
|     if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) { | ||||
|       throw new IllegalArgumentException("types and labels must be unique"); | ||||
|     } | ||||
|     labelToSubtype.put(label, type); | ||||
|     subtypeToLabel.put(type, label); | ||||
|     return this; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Registers {@code type} identified by its {@link Class#getSimpleName simple | ||||
|    * name}. Labels are case sensitive. | ||||
|    * | ||||
|    * @throws IllegalArgumentException if either {@code type} or its simple name | ||||
|    *     have already been registered on this type adapter. | ||||
|    */ | ||||
|   public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type) { | ||||
|     return registerSubtype(type, type.getSimpleName()); | ||||
|   } | ||||
| 
 | ||||
|   public <R> TypeAdapter<R> create(Gson gson, TypeToken<R> type) { | ||||
|     if (type.getRawType() != baseType) { | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     final Map<String, TypeAdapter<?>> labelToDelegate | ||||
|         = new LinkedHashMap<String, TypeAdapter<?>>(); | ||||
|     final Map<Class<?>, TypeAdapter<?>> subtypeToDelegate | ||||
|         = new LinkedHashMap<Class<?>, TypeAdapter<?>>(); | ||||
|     for (Map.Entry<String, Class<?>> entry : labelToSubtype.entrySet()) { | ||||
|       TypeAdapter<?> delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue())); | ||||
|       labelToDelegate.put(entry.getKey(), delegate); | ||||
|       subtypeToDelegate.put(entry.getValue(), delegate); | ||||
|     } | ||||
| 
 | ||||
|     return new TypeAdapter<R>() { | ||||
|       @Override public R read(JsonReader in) throws IOException { | ||||
|         JsonElement jsonElement = Streams.parse(in); | ||||
|         JsonElement labelJsonElement; | ||||
|         if (maintainType) { | ||||
|           labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName); | ||||
|         } else { | ||||
|           labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName); | ||||
|         } | ||||
| 
 | ||||
|         if (labelJsonElement == null) { | ||||
|           throw new JsonParseException("cannot deserialize " + baseType | ||||
|               + " because it does not define a field named " + typeFieldName); | ||||
|         } | ||||
|         String label = labelJsonElement.getAsString(); | ||||
|         @SuppressWarnings("unchecked") // registration requires that subtype extends T | ||||
|             TypeAdapter<R> delegate = (TypeAdapter<R>) labelToDelegate.get(label); | ||||
|         if (delegate == null) { | ||||
| 
 | ||||
|           Log.e("RuntimeTypeAdapter", "cannot deserialize " + baseType + " subtype named " | ||||
|               + label + "; did you forget to register a subtype? " +jsonElement); | ||||
|           return null; | ||||
|         } | ||||
|         return delegate.fromJsonTree(jsonElement); | ||||
|       } | ||||
| 
 | ||||
|       @Override public void write(JsonWriter out, R value) throws IOException { | ||||
|         Class<?> srcType = value.getClass(); | ||||
|         String label = subtypeToLabel.get(srcType); | ||||
|         @SuppressWarnings("unchecked") // registration requires that subtype extends T | ||||
|             TypeAdapter<R> delegate = (TypeAdapter<R>) subtypeToDelegate.get(srcType); | ||||
|         if (delegate == null) { | ||||
|           throw new JsonParseException("cannot serialize " + srcType.getName() | ||||
|               + "; did you forget to register a subtype?"); | ||||
|         } | ||||
|         JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject(); | ||||
| 
 | ||||
|         if (maintainType) { | ||||
|           Streams.write(jsonObject, out); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         JsonObject clone = new JsonObject(); | ||||
| 
 | ||||
|         if (jsonObject.has(typeFieldName)) { | ||||
|           throw new JsonParseException("cannot serialize " + srcType.getName() | ||||
|               + " because it already defines a field named " + typeFieldName); | ||||
|         } | ||||
|         clone.add(typeFieldName, new JsonPrimitive(label)); | ||||
| 
 | ||||
|         for (Map.Entry<String, JsonElement> e : jsonObject.entrySet()) { | ||||
|           clone.add(e.getKey(), e.getValue()); | ||||
|         } | ||||
|         Streams.write(clone, out); | ||||
|       } | ||||
|     }.nullSafe(); | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,273 @@ | |||
| package fr.free.nrw.commons.wikidata.json | ||||
| 
 | ||||
| import com.google.gson.Gson | ||||
| import com.google.gson.JsonObject | ||||
| import com.google.gson.JsonParseException | ||||
| import com.google.gson.JsonPrimitive | ||||
| import com.google.gson.TypeAdapter | ||||
| import com.google.gson.TypeAdapterFactory | ||||
| import com.google.gson.internal.Streams | ||||
| import com.google.gson.reflect.TypeToken | ||||
| import com.google.gson.stream.JsonReader | ||||
| import com.google.gson.stream.JsonWriter | ||||
| import timber.log.Timber | ||||
| import java.io.IOException | ||||
| 
 | ||||
| /* | ||||
| * Copyright (C) 2011 Google Inc. | ||||
| * | ||||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||||
| * you may not use this file except in compliance with the License. | ||||
| * You may obtain a copy of the License at | ||||
| * | ||||
| *      http://www.apache.org/licenses/LICENSE-2.0 | ||||
| * | ||||
| * Unless required by applicable law or agreed to in writing, software | ||||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
| * See the License for the specific language governing permissions and | ||||
| * limitations under the License. | ||||
| */ | ||||
| 
 | ||||
| /** | ||||
|  * Adapts values whose runtime type may differ from their declaration type. This | ||||
|  * is necessary when a field's type is not the same type that GSON should create | ||||
|  * when deserializing that field. For example, consider these types: | ||||
|  * <pre>   `abstract class Shape { | ||||
|  * int x; | ||||
|  * int y; | ||||
|  * } | ||||
|  * class Circle extends Shape { | ||||
|  * int radius; | ||||
|  * } | ||||
|  * class Rectangle extends Shape { | ||||
|  * int width; | ||||
|  * int height; | ||||
|  * } | ||||
|  * class Diamond extends Shape { | ||||
|  * int width; | ||||
|  * int height; | ||||
|  * } | ||||
|  * class Drawing { | ||||
|  * Shape bottomShape; | ||||
|  * Shape topShape; | ||||
|  * } | ||||
| `</pre> * | ||||
|  * | ||||
|  * Without additional type information, the serialized JSON is ambiguous. Is | ||||
|  * the bottom shape in this drawing a rectangle or a diamond? <pre>   `{ | ||||
|  * "bottomShape": { | ||||
|  * "width": 10, | ||||
|  * "height": 5, | ||||
|  * "x": 0, | ||||
|  * "y": 0 | ||||
|  * }, | ||||
|  * "topShape": { | ||||
|  * "radius": 2, | ||||
|  * "x": 4, | ||||
|  * "y": 1 | ||||
|  * } | ||||
|  * }`</pre> | ||||
|  * This class addresses this problem by adding type information to the | ||||
|  * serialized JSON and honoring that type information when the JSON is | ||||
|  * deserialized: <pre>   `{ | ||||
|  * "bottomShape": { | ||||
|  * "type": "Diamond", | ||||
|  * "width": 10, | ||||
|  * "height": 5, | ||||
|  * "x": 0, | ||||
|  * "y": 0 | ||||
|  * }, | ||||
|  * "topShape": { | ||||
|  * "type": "Circle", | ||||
|  * "radius": 2, | ||||
|  * "x": 4, | ||||
|  * "y": 1 | ||||
|  * } | ||||
|  * }`</pre> | ||||
|  * Both the type field name (`"type"`) and the type labels (`"Rectangle"`) are configurable. | ||||
|  * | ||||
|  * <h3>Registering Types</h3> | ||||
|  * Create a `RuntimeTypeAdapterFactory` by passing the base type and type field | ||||
|  * name to the [.of] factory method. If you don't supply an explicit type | ||||
|  * field name, `"type"` will be used. <pre>   `RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory | ||||
|  * = RuntimeTypeAdapterFactory.of(Shape.class, "type"); | ||||
| `</pre> * | ||||
|  * Next register all of your subtypes. Every subtype must be explicitly | ||||
|  * registered. This protects your application from injection attacks. If you | ||||
|  * don't supply an explicit type label, the type's simple name will be used. | ||||
|  * <pre>   `shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle"); | ||||
|  * shapeAdapterFactory.registerSubtype(Circle.class, "Circle"); | ||||
|  * shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond"); | ||||
| `</pre> * | ||||
|  * Finally, register the type adapter factory in your application's GSON builder: | ||||
|  * <pre>   `Gson gson = new GsonBuilder() | ||||
|  * .registerTypeAdapterFactory(shapeAdapterFactory) | ||||
|  * .create(); | ||||
| `</pre> * | ||||
|  * Like `GsonBuilder`, this API supports chaining: <pre>   `RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class) | ||||
|  * .registerSubtype(Rectangle.class) | ||||
|  * .registerSubtype(Circle.class) | ||||
|  * .registerSubtype(Diamond.class); | ||||
| `</pre> * | ||||
|  * | ||||
|  * <h3>Serialization and deserialization</h3> | ||||
|  * In order to serialize and deserialize a polymorphic object, | ||||
|  * you must specify the base type explicitly. | ||||
|  * <pre>   `Diamond diamond = new Diamond(); | ||||
|  * String json = gson.toJson(diamond, Shape.class); | ||||
| `</pre> * | ||||
|  * And then: | ||||
|  * <pre>   `Shape shape = gson.fromJson(json, Shape.class); | ||||
| `</pre> * | ||||
|  */ | ||||
| class RuntimeTypeAdapterFactory<T>( | ||||
|     baseType: Class<*>?, | ||||
|     typeFieldName: String?, | ||||
|     maintainType: Boolean | ||||
| ) : TypeAdapterFactory { | ||||
| 
 | ||||
|     private val baseType: Class<*> | ||||
|     private val typeFieldName: String | ||||
|     private val labelToSubtype = mutableMapOf<String, Class<*>>() | ||||
|     private val subtypeToLabel = mutableMapOf<Class<*>, String>() | ||||
|     private val maintainType: Boolean | ||||
| 
 | ||||
|     init { | ||||
|         if (typeFieldName == null || baseType == null) { | ||||
|             throw NullPointerException() | ||||
|         } | ||||
|         this.baseType = baseType | ||||
|         this.typeFieldName = typeFieldName | ||||
|         this.maintainType = maintainType | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Registers `type` identified by `label`. Labels are case | ||||
|      * sensitive. | ||||
|      * | ||||
|      * @throws IllegalArgumentException if either `type` or `label` | ||||
|      * have already been registered on this type adapter. | ||||
|      */ | ||||
|     fun registerSubtype(type: Class<out T>?, label: String?): RuntimeTypeAdapterFactory<T> { | ||||
|         if (type == null || label == null) { | ||||
|             throw NullPointerException() | ||||
|         } | ||||
|         require(!(subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label))) { | ||||
|             "types and labels must be unique" | ||||
|         } | ||||
| 
 | ||||
|         labelToSubtype[label] = type | ||||
|         subtypeToLabel[type] = label | ||||
|         return this | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Registers `type` identified by its [simple][Class.getSimpleName]. Labels are case sensitive. | ||||
|      * | ||||
|      * @throws IllegalArgumentException if either `type` or its simple name | ||||
|      * have already been registered on this type adapter. | ||||
|      */ | ||||
|     fun registerSubtype(type: Class<out T>): RuntimeTypeAdapterFactory<T> { | ||||
|         return registerSubtype(type, type.simpleName) | ||||
|     } | ||||
| 
 | ||||
|     override fun <R : Any> create(gson: Gson, type: TypeToken<R>): TypeAdapter<R>? { | ||||
|         if (type.rawType != baseType) { | ||||
|             return null | ||||
|         } | ||||
| 
 | ||||
|         val labelToDelegate = mutableMapOf<String, TypeAdapter<*>>() | ||||
|         val subtypeToDelegate = mutableMapOf<Class<*>, TypeAdapter<*>>() | ||||
|         for ((key, value) in labelToSubtype) { | ||||
|             val delegate = gson.getDelegateAdapter( | ||||
|                 this, TypeToken.get( | ||||
|                     value | ||||
|                 ) | ||||
|             ) | ||||
|             labelToDelegate[key] = delegate | ||||
|             subtypeToDelegate[value] = delegate | ||||
|         } | ||||
| 
 | ||||
|         return object : TypeAdapter<R>() { | ||||
|             @Throws(IOException::class) | ||||
|             override fun read(reader: JsonReader): R? { | ||||
|                 val jsonElement = Streams.parse(reader) | ||||
|                 val labelJsonElement = if (maintainType) { | ||||
|                     jsonElement.asJsonObject[typeFieldName] | ||||
|                 } else { | ||||
|                     jsonElement.asJsonObject.remove(typeFieldName) | ||||
|                 } | ||||
| 
 | ||||
|                 if (labelJsonElement == null) { | ||||
|                     throw JsonParseException( | ||||
|                         "cannot deserialize $baseType because it does not define a field named $typeFieldName" | ||||
|                     ) | ||||
|                 } | ||||
|                 val label = labelJsonElement.asString | ||||
|                 val delegate = labelToDelegate[label] as TypeAdapter<R>? | ||||
|                 if (delegate == null) { | ||||
|                     Timber.tag("RuntimeTypeAdapter").e( | ||||
|                         "cannot deserialize $baseType subtype named $label; did you forget to register a subtype? $jsonElement" | ||||
|                     ) | ||||
|                     return null | ||||
|                 } | ||||
|                 return delegate.fromJsonTree(jsonElement) | ||||
|             } | ||||
| 
 | ||||
|             @Throws(IOException::class) | ||||
|             override fun write(out: JsonWriter, value: R) { | ||||
|                 val srcType: Class<*> = value::class.java.javaClass | ||||
|                 val delegate = | ||||
|                     subtypeToDelegate[srcType] as TypeAdapter<R?>? ?: throw JsonParseException( | ||||
|                         "cannot serialize ${srcType.name}; did you forget to register a subtype?" | ||||
|                     ) | ||||
| 
 | ||||
|                 val jsonObject = delegate.toJsonTree(value).asJsonObject | ||||
|                 if (maintainType) { | ||||
|                     Streams.write(jsonObject, out) | ||||
|                     return | ||||
|                 } | ||||
| 
 | ||||
|                 if (jsonObject.has(typeFieldName)) { | ||||
|                     throw JsonParseException( | ||||
|                         "cannot serialize ${srcType.name} because it already defines a field named $typeFieldName" | ||||
|                     ) | ||||
|                 } | ||||
|                 val clone = JsonObject() | ||||
|                 val label = subtypeToLabel[srcType] | ||||
|                 clone.add(typeFieldName, JsonPrimitive(label)) | ||||
|                 for ((key, value1) in jsonObject.entrySet()) { | ||||
|                     clone.add(key, value1) | ||||
|                 } | ||||
|                 Streams.write(clone, out) | ||||
|             } | ||||
|         }.nullSafe() | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
|         /** | ||||
|          * Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive. | ||||
|          * `maintainType` flag decide if the type will be stored in pojo or not. | ||||
|          */ | ||||
|         fun <T> of( | ||||
|             baseType: Class<T>, | ||||
|             typeFieldName: String, | ||||
|             maintainType: Boolean | ||||
|         ): RuntimeTypeAdapterFactory<T> = | ||||
|             RuntimeTypeAdapterFactory(baseType, typeFieldName, maintainType) | ||||
| 
 | ||||
|         /** | ||||
|          * Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive. | ||||
|          */ | ||||
|         fun <T> of(baseType: Class<T>, typeFieldName: String): RuntimeTypeAdapterFactory<T> = | ||||
|             RuntimeTypeAdapterFactory(baseType, typeFieldName, false) | ||||
| 
 | ||||
|         /** | ||||
|          * Creates a new runtime type adapter for `baseType` using `"type"` as | ||||
|          * the type field name. | ||||
|          */ | ||||
|         fun <T> of(baseType: Class<T>): RuntimeTypeAdapterFactory<T> = | ||||
|             RuntimeTypeAdapterFactory(baseType, "type", false) | ||||
|     } | ||||
| } | ||||
|  | @ -1,22 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata.json; | ||||
| 
 | ||||
| import android.net.Uri; | ||||
| 
 | ||||
| import com.google.gson.TypeAdapter; | ||||
| import com.google.gson.stream.JsonReader; | ||||
| import com.google.gson.stream.JsonWriter; | ||||
| 
 | ||||
| import java.io.IOException; | ||||
| 
 | ||||
| public class UriTypeAdapter extends TypeAdapter<Uri> { | ||||
|     @Override | ||||
|     public void write(JsonWriter out, Uri value) throws IOException { | ||||
|         out.value(value.toString()); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public Uri read(JsonReader in) throws IOException { | ||||
|         String url = in.nextString(); | ||||
|         return Uri.parse(url); | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,19 @@ | |||
| package fr.free.nrw.commons.wikidata.json | ||||
| 
 | ||||
| import android.net.Uri | ||||
| import com.google.gson.TypeAdapter | ||||
| import com.google.gson.stream.JsonReader | ||||
| import com.google.gson.stream.JsonWriter | ||||
| import java.io.IOException | ||||
| 
 | ||||
| class UriTypeAdapter : TypeAdapter<Uri>() { | ||||
|     @Throws(IOException::class) | ||||
|     override fun write(out: JsonWriter, value: Uri) { | ||||
|         out.value(value.toString()) | ||||
|     } | ||||
| 
 | ||||
|     @Throws(IOException::class) | ||||
|     override fun read(reader: JsonReader): Uri { | ||||
|         return Uri.parse(reader.nextString()) | ||||
|     } | ||||
| } | ||||
|  | @ -1,63 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata.json; | ||||
| 
 | ||||
| import android.net.Uri; | ||||
| 
 | ||||
| import com.google.gson.JsonParseException; | ||||
| import com.google.gson.TypeAdapter; | ||||
| import com.google.gson.stream.JsonReader; | ||||
| import com.google.gson.stream.JsonToken; | ||||
| import com.google.gson.stream.JsonWriter; | ||||
| 
 | ||||
| import fr.free.nrw.commons.wikidata.model.WikiSite; | ||||
| 
 | ||||
| import java.io.IOException; | ||||
| 
 | ||||
| public class WikiSiteTypeAdapter extends TypeAdapter<WikiSite> { | ||||
|     private static final String DOMAIN = "domain"; | ||||
|     private static final String LANGUAGE_CODE = "languageCode"; | ||||
| 
 | ||||
|     @Override public void write(JsonWriter out, WikiSite value) throws IOException { | ||||
|         out.beginObject(); | ||||
|         out.name(DOMAIN); | ||||
|         out.value(value.url()); | ||||
| 
 | ||||
|         out.name(LANGUAGE_CODE); | ||||
|         out.value(value.languageCode()); | ||||
|         out.endObject(); | ||||
|     } | ||||
| 
 | ||||
|     @Override public WikiSite read(JsonReader in) throws IOException { | ||||
|         // todo: legacy; remove in June 2018 | ||||
|         if (in.peek() == JsonToken.STRING) { | ||||
|             return new WikiSite(Uri.parse(in.nextString())); | ||||
|         } | ||||
| 
 | ||||
|         String domain = null; | ||||
|         String languageCode = null; | ||||
|         in.beginObject(); | ||||
|         while (in.hasNext()) { | ||||
|             String field = in.nextName(); | ||||
|             String val = in.nextString(); | ||||
|             switch (field) { | ||||
|                 case DOMAIN: | ||||
|                     domain = val; | ||||
|                     break; | ||||
|                 case LANGUAGE_CODE: | ||||
|                     languageCode = val; | ||||
|                     break; | ||||
|                 default: break; | ||||
|             } | ||||
|         } | ||||
|         in.endObject(); | ||||
| 
 | ||||
|         if (domain == null) { | ||||
|             throw new JsonParseException("Missing domain"); | ||||
|         } | ||||
| 
 | ||||
|         // todo: legacy; remove in June 2018 | ||||
|         if (languageCode == null) { | ||||
|             return new WikiSite(domain); | ||||
|         } | ||||
|         return new WikiSite(domain, languageCode); | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,61 @@ | |||
| package fr.free.nrw.commons.wikidata.json | ||||
| 
 | ||||
| import android.net.Uri | ||||
| import com.google.gson.JsonParseException | ||||
| import com.google.gson.TypeAdapter | ||||
| import com.google.gson.stream.JsonReader | ||||
| import com.google.gson.stream.JsonToken | ||||
| import com.google.gson.stream.JsonWriter | ||||
| import fr.free.nrw.commons.wikidata.model.WikiSite | ||||
| import java.io.IOException | ||||
| 
 | ||||
| class WikiSiteTypeAdapter : TypeAdapter<WikiSite>() { | ||||
|     @Throws(IOException::class) | ||||
|     override fun write(out: JsonWriter, value: WikiSite) { | ||||
|         out.beginObject() | ||||
|         out.name(DOMAIN) | ||||
|         out.value(value.url()) | ||||
| 
 | ||||
|         out.name(LANGUAGE_CODE) | ||||
|         out.value(value.languageCode()) | ||||
|         out.endObject() | ||||
|     } | ||||
| 
 | ||||
|     @Throws(IOException::class) | ||||
|     override fun read(reader: JsonReader): WikiSite { | ||||
|         // todo: legacy; remove reader June 2018 | ||||
|         if (reader.peek() == JsonToken.STRING) { | ||||
|             return WikiSite(Uri.parse(reader.nextString())) | ||||
|         } | ||||
| 
 | ||||
|         var domain: String? = null | ||||
|         var languageCode: String? = null | ||||
|         reader.beginObject() | ||||
|         while (reader.hasNext()) { | ||||
|             val field = reader.nextName() | ||||
|             val value = reader.nextString() | ||||
|             when (field) { | ||||
|                 DOMAIN -> domain = value | ||||
|                 LANGUAGE_CODE -> languageCode = value | ||||
|                 else -> {} | ||||
|             } | ||||
|         } | ||||
|         reader.endObject() | ||||
| 
 | ||||
|         if (domain == null) { | ||||
|             throw JsonParseException("Missing domain") | ||||
|         } | ||||
| 
 | ||||
|         // todo: legacy; remove reader June 2018 | ||||
|         return if (languageCode == null) { | ||||
|             WikiSite(domain) | ||||
|         } else { | ||||
|             WikiSite(domain, languageCode) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
|         private const val DOMAIN = "domain" | ||||
|         private const val LANGUAGE_CODE = "languageCode" | ||||
|     } | ||||
| } | ||||
|  | @ -1,21 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata.json.annotations; | ||||
| 
 | ||||
| 
 | ||||
| import java.lang.annotation.Documented; | ||||
| import java.lang.annotation.Retention; | ||||
| import java.lang.annotation.RetentionPolicy; | ||||
| import java.lang.annotation.Target; | ||||
| 
 | ||||
| import static java.lang.annotation.ElementType.FIELD; | ||||
| 
 | ||||
| /** | ||||
|  * Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return | ||||
|  * an instantiated object. | ||||
|  * | ||||
|  * E.g.: @NonNull @Required private String title; | ||||
|  */ | ||||
| @Documented | ||||
| @Retention(RetentionPolicy.RUNTIME) | ||||
| @Target(FIELD) | ||||
| public @interface Required { | ||||
| } | ||||
|  | @ -0,0 +1,12 @@ | |||
| package fr.free.nrw.commons.wikidata.json.annotations | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return | ||||
|  * an instantiated object. | ||||
|  * | ||||
|  * E.g.: @NonNull @Required private String title; | ||||
|  */ | ||||
| @Retention(AnnotationRetention.RUNTIME) | ||||
| @Target(AnnotationTarget.FIELD) | ||||
| annotation class Required  | ||||
|  | @ -1,24 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata.model; | ||||
| 
 | ||||
| import java.util.List; | ||||
| 
 | ||||
| /** | ||||
|  * Model class for API response obtained from search for depictions | ||||
|  */ | ||||
| public class DepictSearchResponse { | ||||
|     private final List<DepictSearchItem> search; | ||||
| 
 | ||||
|     /** | ||||
|      * Constructor to initialise value of the search object | ||||
|      */ | ||||
|     public DepictSearchResponse(List<DepictSearchItem> search) { | ||||
|         this.search = search; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @return List<DepictSearchItem> for the DepictSearchResponse | ||||
|      */ | ||||
|     public List<DepictSearchItem> getSearch() { | ||||
|         return search; | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,12 @@ | |||
| package fr.free.nrw.commons.wikidata.model | ||||
| 
 | ||||
| /** | ||||
|  * Model class for API response obtained from search for depictions | ||||
|  */ | ||||
| class DepictSearchResponse( | ||||
|     /** | ||||
|      * @return List<DepictSearchItem> for the DepictSearchResponse | ||||
|     </DepictSearchItem> | ||||
|      */ | ||||
|     val search: List<DepictSearchItem> | ||||
| ) | ||||
|  | @ -1,106 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata.model; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import com.google.gson.annotations.SerializedName; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
| import java.util.Map; | ||||
| import org.apache.commons.lang3.StringUtils; | ||||
| import org.jetbrains.annotations.NotNull; | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwResponse; | ||||
| 
 | ||||
| 
 | ||||
| public class Entities extends MwResponse { | ||||
|     @Nullable private Map<String, Entity> entities; | ||||
|     private int success; | ||||
| 
 | ||||
|     @NotNull | ||||
|     public Map<String, Entity> entities() { | ||||
|         return entities != null ? entities : Collections.emptyMap(); | ||||
|     } | ||||
| 
 | ||||
|     public int getSuccess() { | ||||
|         return success; | ||||
|     } | ||||
| 
 | ||||
|     @Nullable public Entity getFirst() { | ||||
|         if (entities == null) { | ||||
|             return null; | ||||
|         } | ||||
|         return entities.values().iterator().next(); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void postProcess() { | ||||
|         if (getFirst() != null && getFirst().isMissing()) { | ||||
|             throw new RuntimeException("The requested entity was not found."); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public static class Entity { | ||||
|         @Nullable private String type; | ||||
|         @Nullable private String id; | ||||
|         @Nullable private Map<String, Label> labels; | ||||
|         @Nullable private Map<String, Label> descriptions; | ||||
|         @Nullable private Map<String, SiteLink> sitelinks; | ||||
|         @Nullable @SerializedName(value = "statements", alternate = "claims") private Map<String, List<StatementPartial>> statements; | ||||
|         @Nullable private String missing; | ||||
| 
 | ||||
|         @NonNull public String id() { | ||||
|             return StringUtils.defaultString(id); | ||||
|         } | ||||
| 
 | ||||
|         @NonNull public Map<String, Label> labels() { | ||||
|             return labels != null ? labels : Collections.emptyMap(); | ||||
|         } | ||||
| 
 | ||||
|         @NonNull public Map<String, Label> descriptions() { | ||||
|             return descriptions != null ? descriptions : Collections.emptyMap(); | ||||
|         } | ||||
| 
 | ||||
|         @NonNull public Map<String, SiteLink> sitelinks() { | ||||
|             return sitelinks != null ? sitelinks : Collections.emptyMap(); | ||||
|         } | ||||
| 
 | ||||
|         @Nullable | ||||
|         public Map<String, List<StatementPartial>> getStatements() { | ||||
|             return statements; | ||||
|         } | ||||
| 
 | ||||
|         boolean isMissing() { | ||||
|             return "-1".equals(id) && missing != null; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public static class Label { | ||||
|         @Nullable private String language; | ||||
|         @Nullable private String value; | ||||
| 
 | ||||
|         public Label(@Nullable final String language, @Nullable final String value) { | ||||
|             this.language = language; | ||||
|             this.value = value; | ||||
|         } | ||||
| 
 | ||||
|         @NonNull public String language() { | ||||
|             return StringUtils.defaultString(language); | ||||
|         } | ||||
| 
 | ||||
|         @NonNull public String value() { | ||||
|             return StringUtils.defaultString(value); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public static class SiteLink { | ||||
|         @Nullable private String site; | ||||
|         @Nullable private String title; | ||||
| 
 | ||||
|         @NonNull public String getSite() { | ||||
|             return StringUtils.defaultString(site); | ||||
|         } | ||||
| 
 | ||||
|         @NonNull public String getTitle() { | ||||
|             return StringUtils.defaultString(title); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,64 @@ | |||
| package fr.free.nrw.commons.wikidata.model | ||||
| 
 | ||||
| import com.google.gson.annotations.SerializedName | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwResponse | ||||
| import org.apache.commons.lang3.StringUtils | ||||
| 
 | ||||
| class Entities : MwResponse() { | ||||
|     private val entities: Map<String, Entity>? = null | ||||
|     val success: Int = 0 | ||||
| 
 | ||||
|     fun entities(): Map<String, Entity> = entities ?: emptyMap() | ||||
| 
 | ||||
|     private val first : Entity? | ||||
|         get() = entities?.values?.iterator()?.next() | ||||
| 
 | ||||
|     override fun postProcess() { | ||||
|         first?.let { | ||||
|             if (it.isMissing()) throw RuntimeException("The requested entity was not found.") | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     class Entity { | ||||
|         private val type: String? = null | ||||
|         private val id: String? = null | ||||
|         private val labels: Map<String, Label>? = null | ||||
|         private val descriptions: Map<String, Label>? = null | ||||
|         private val sitelinks: Map<String, SiteLink>? = null | ||||
| 
 | ||||
|         @SerializedName(value = "statements", alternate = ["claims"]) | ||||
|         val statements: Map<String, List<StatementPartial>>? = null | ||||
|         private val missing: String? = null | ||||
| 
 | ||||
|         fun id(): String = | ||||
|             StringUtils.defaultString(id) | ||||
| 
 | ||||
|         fun labels(): Map<String, Label> = | ||||
|             labels ?: emptyMap() | ||||
| 
 | ||||
|         fun descriptions(): Map<String, Label> = | ||||
|             descriptions ?: emptyMap() | ||||
| 
 | ||||
|         fun sitelinks(): Map<String, SiteLink> = | ||||
|             sitelinks ?: emptyMap() | ||||
| 
 | ||||
|         fun isMissing(): Boolean = | ||||
|             "-1" == id && missing != null | ||||
|     } | ||||
| 
 | ||||
|     class Label(private val language: String?, private val value: String?) { | ||||
|         fun language(): String = | ||||
|             StringUtils.defaultString(language) | ||||
| 
 | ||||
|         fun value(): String = | ||||
|             StringUtils.defaultString(value) | ||||
|     } | ||||
| 
 | ||||
|     class SiteLink { | ||||
|         val site: String? = null | ||||
|             get() = StringUtils.defaultString(field) | ||||
| 
 | ||||
|         private val title: String? = null | ||||
|             get() = StringUtils.defaultString(field) | ||||
|     } | ||||
| } | ||||
|  | @ -1,292 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata.model; | ||||
| 
 | ||||
| import android.net.Uri; | ||||
| import android.os.Parcel; | ||||
| import android.os.Parcelable; | ||||
| import android.text.TextUtils; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import com.google.gson.annotations.SerializedName; | ||||
| 
 | ||||
| import org.apache.commons.lang3.StringUtils; | ||||
| import fr.free.nrw.commons.language.AppLanguageLookUpTable; | ||||
| 
 | ||||
| /** | ||||
|  * The base URL and Wikipedia language code for a MediaWiki site. Examples: | ||||
|  * | ||||
|  * <ul> | ||||
|  *     <lh>Name: scheme / authority / language code</lh> | ||||
|  *     <li>English Wikipedia: HTTPS / en.wikipedia.org / en</li> | ||||
|  *     <li>Chinese Wikipedia: HTTPS / zh.wikipedia.org / zh-hans or zh-hant</li> | ||||
|  *     <li>Meta-Wiki: HTTPS / meta.wikimedia.org / (none)</li> | ||||
|  *     <li>Test Wikipedia: HTTPS / test.wikipedia.org / test</li> | ||||
|  *     <li>Võro Wikipedia: HTTPS / fiu-vro.wikipedia.org / fiu-vro</li> | ||||
|  *     <li>Simple English Wikipedia: HTTPS / simple.wikipedia.org / simple</li> | ||||
|  *     <li>Simple English Wikipedia (beta cluster mirror): HTTP / simple.wikipedia.beta.wmflabs.org / simple</li> | ||||
|  *     <li>Development: HTTP / 192.168.1.11:8080 / (none)</li> | ||||
|  * </ul> | ||||
|  * | ||||
|  * <strong>As shown above, the language code or mapping is part of the authority:</strong> | ||||
|  * <ul> | ||||
|  *     <lh>Validity: authority / language code</lh> | ||||
|  *     <li>Correct: "test.wikipedia.org" / "test"</li> | ||||
|  *     <li>Correct: "wikipedia.org", ""</li> | ||||
|  *     <li>Correct: "no.wikipedia.org", "nb"</li> | ||||
|  *     <li>Incorrect: "wikipedia.org", "test"</li> | ||||
|  * </ul> | ||||
|  */ | ||||
| public class WikiSite implements Parcelable { | ||||
|     private static String WIKIPEDIA_URL = "https://wikipedia.org/"; | ||||
| 
 | ||||
|     public static final String DEFAULT_SCHEME = "https"; | ||||
|     private static String DEFAULT_BASE_URL = WIKIPEDIA_URL; | ||||
| 
 | ||||
|     public static final Parcelable.Creator<WikiSite> CREATOR = new Parcelable.Creator<WikiSite>() { | ||||
|         @Override | ||||
|         public WikiSite createFromParcel(Parcel in) { | ||||
|             return new WikiSite(in); | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public WikiSite[] newArray(int size) { | ||||
|             return new WikiSite[size]; | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     // todo: remove @SerializedName. this is now in the TypeAdapter and a "uri" case may be added | ||||
|     @SerializedName("domain") @NonNull private final Uri uri; | ||||
|     @NonNull private String languageCode; | ||||
| 
 | ||||
|     public static WikiSite forLanguageCode(@NonNull String languageCode) { | ||||
|         Uri uri = ensureScheme(Uri.parse(DEFAULT_BASE_URL)); | ||||
|         return new WikiSite((languageCode.isEmpty() | ||||
|                 ? "" : (languageCodeToSubdomain(languageCode) + ".")) + uri.getAuthority(), | ||||
|                 languageCode); | ||||
|     } | ||||
| 
 | ||||
|     public WikiSite(@NonNull Uri uri) { | ||||
|         Uri tempUri = ensureScheme(uri); | ||||
|         String authority = tempUri.getAuthority(); | ||||
|         if (("wikipedia.org".equals(authority) || "www.wikipedia.org".equals(authority)) | ||||
|                 && tempUri.getPath() != null && tempUri.getPath().startsWith("/wiki")) { | ||||
|             // Special case for Wikipedia only: assume English subdomain when none given. | ||||
|             authority = "en.wikipedia.org"; | ||||
|         } | ||||
|         String langVariant = getLanguageVariantFromUri(tempUri); | ||||
|         if (!TextUtils.isEmpty(langVariant)) { | ||||
|             languageCode = langVariant; | ||||
|         } else { | ||||
|             languageCode = authorityToLanguageCode(authority); | ||||
|         } | ||||
|         this.uri = new Uri.Builder() | ||||
|                 .scheme(tempUri.getScheme()) | ||||
|                 .encodedAuthority(authority) | ||||
|                 .build(); | ||||
|     } | ||||
| 
 | ||||
|     /** Get language variant code from a Uri, e.g. "zh-*", otherwise returns empty string. */ | ||||
|     @NonNull | ||||
|     private String getLanguageVariantFromUri(@NonNull Uri uri) { | ||||
|         if (TextUtils.isEmpty(uri.getPath())) { | ||||
|             return ""; | ||||
|         } | ||||
|         String[] parts = StringUtils.split(StringUtils.defaultString(uri.getPath()), '/'); | ||||
|         return parts.length > 1 && !parts[0].equals("wiki") ? parts[0] : ""; | ||||
|     } | ||||
| 
 | ||||
|     public WikiSite(@NonNull String url) { | ||||
|         this(url.startsWith("http") ? Uri.parse(url) : url.startsWith("//") | ||||
|                 ? Uri.parse(DEFAULT_SCHEME + ":" + url) : Uri.parse(DEFAULT_SCHEME + "://" + url)); | ||||
|     } | ||||
| 
 | ||||
|     public WikiSite(@NonNull String authority, @NonNull String languageCode) { | ||||
|         this(authority); | ||||
|         this.languageCode = languageCode; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public String scheme() { | ||||
|         return TextUtils.isEmpty(uri.getScheme()) ? DEFAULT_SCHEME : uri.getScheme(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @return The complete wiki authority including language subdomain but not including scheme, | ||||
|      *         authentication, port, nor trailing slash. | ||||
|      * | ||||
|      * @see <a href='https://en.wikipedia.org/wiki/Uniform_Resource_Locator#Syntax'>URL syntax</a> | ||||
|      */ | ||||
|     @NonNull | ||||
|     public String authority() { | ||||
|         return uri.getAuthority(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Like {@link #authority()} but with a "m." between the language subdomain and the rest of the host. | ||||
|      * Examples: | ||||
|      * | ||||
|      * <ul> | ||||
|      *     <li>English Wikipedia: en.m.wikipedia.org</li> | ||||
|      *     <li>Chinese Wikipedia: zh.m.wikipedia.org</li> | ||||
|      *     <li>Meta-Wiki: meta.m.wikimedia.org</li> | ||||
|      *     <li>Test Wikipedia: test.m.wikipedia.org</li> | ||||
|      *     <li>Võro Wikipedia: fiu-vro.m.wikipedia.org</li> | ||||
|      *     <li>Simple English Wikipedia: simple.m.wikipedia.org</li> | ||||
|      *     <li>Simple English Wikipedia (beta cluster mirror): simple.m.wikipedia.beta.wmflabs.org</li> | ||||
|      *     <li>Development: m.192.168.1.11</li> | ||||
|      * </ul> | ||||
|      */ | ||||
|     @NonNull | ||||
|     public String mobileAuthority() { | ||||
|         return authorityToMobile(authority()); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get wiki's mobile URL | ||||
|      * Eg. https://en.m.wikipedia.org | ||||
|      * @return | ||||
|      */ | ||||
|     public String mobileUrl() { | ||||
|         return String.format("%1$s://%2$s", scheme(), mobileAuthority()); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public String subdomain() { | ||||
|         return languageCodeToSubdomain(languageCode); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @return A path without an authority for the segment including a leading "/". | ||||
|      */ | ||||
|     @NonNull | ||||
|     public String path(@NonNull String segment) { | ||||
|         return "/w/" + segment; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     @NonNull public Uri uri() { | ||||
|         return uri; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @return The canonical URL. e.g., https://en.wikipedia.org. | ||||
|      */ | ||||
|     @NonNull public String url() { | ||||
|         return uri.toString(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @return The canonical URL for segment. e.g., https://en.wikipedia.org/w/foo. | ||||
|      */ | ||||
|     @NonNull public String url(@NonNull String segment) { | ||||
|         return url() + path(segment); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @return The wiki language code which may differ from the language subdomain. Empty if | ||||
|      *         language code is unknown. Ex: "en", "zh-hans", "" | ||||
|      * | ||||
|      * @see AppLanguageLookUpTable | ||||
|      */ | ||||
|     @NonNull | ||||
|     public String languageCode() { | ||||
|         return languageCode; | ||||
|     } | ||||
| 
 | ||||
|     // Auto-generated | ||||
|     @Override | ||||
|     public boolean equals(Object o) { | ||||
|         if (this == o) { | ||||
|             return true; | ||||
|         } | ||||
|         if (o == null || getClass() != o.getClass()) { | ||||
|             return false; | ||||
|         } | ||||
| 
 | ||||
|         WikiSite wiki = (WikiSite) o; | ||||
| 
 | ||||
|         if (!uri.equals(wiki.uri)) { | ||||
|             return false; | ||||
|         } | ||||
|         return languageCode.equals(wiki.languageCode); | ||||
|     } | ||||
| 
 | ||||
|     // Auto-generated | ||||
|     @Override | ||||
|     public int hashCode() { | ||||
|         int result = uri.hashCode(); | ||||
|         result = 31 * result + languageCode.hashCode(); | ||||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     // Auto-generated | ||||
|     @Override | ||||
|     public String toString() { | ||||
|         return "WikiSite{" | ||||
|                 + "uri=" + uri | ||||
|                 + ", languageCode='" + languageCode + '\'' | ||||
|                 + '}'; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public int describeContents() { | ||||
|         return 0; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void writeToParcel(@NonNull Parcel dest, int flags) { | ||||
|         dest.writeParcelable(uri, 0); | ||||
|         dest.writeString(languageCode); | ||||
|     } | ||||
| 
 | ||||
|     protected WikiSite(@NonNull Parcel in) { | ||||
|         this.uri = in.readParcelable(Uri.class.getClassLoader()); | ||||
|         this.languageCode = in.readString(); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     private static String languageCodeToSubdomain(@NonNull String languageCode) { | ||||
|         switch (languageCode) { | ||||
|             case AppLanguageLookUpTable.SIMPLIFIED_CHINESE_LANGUAGE_CODE: | ||||
|             case AppLanguageLookUpTable.TRADITIONAL_CHINESE_LANGUAGE_CODE: | ||||
|             case AppLanguageLookUpTable.CHINESE_CN_LANGUAGE_CODE: | ||||
|             case AppLanguageLookUpTable.CHINESE_HK_LANGUAGE_CODE: | ||||
|             case AppLanguageLookUpTable.CHINESE_MO_LANGUAGE_CODE: | ||||
|             case AppLanguageLookUpTable.CHINESE_SG_LANGUAGE_CODE: | ||||
|             case AppLanguageLookUpTable.CHINESE_TW_LANGUAGE_CODE: | ||||
|                 return AppLanguageLookUpTable.CHINESE_LANGUAGE_CODE; | ||||
|             case AppLanguageLookUpTable.NORWEGIAN_BOKMAL_LANGUAGE_CODE: | ||||
|                 return AppLanguageLookUpTable.NORWEGIAN_LEGACY_LANGUAGE_CODE; // T114042 | ||||
|             default: | ||||
|                 return languageCode; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @NonNull private static String authorityToLanguageCode(@NonNull String authority) { | ||||
|         String[] parts = authority.split("\\."); | ||||
|         final int minLengthForSubdomain = 3; | ||||
|         if (parts.length < minLengthForSubdomain | ||||
|                 || parts.length == minLengthForSubdomain && parts[0].equals("m")) { | ||||
|             // "" | ||||
|             // wikipedia.org | ||||
|             // m.wikipedia.org | ||||
|             return ""; | ||||
|         } | ||||
|         return parts[0]; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull private static Uri ensureScheme(@NonNull Uri uri) { | ||||
|         if (TextUtils.isEmpty(uri.getScheme())) { | ||||
|             return uri.buildUpon().scheme(DEFAULT_SCHEME).build(); | ||||
|         } | ||||
|         return uri; | ||||
|     } | ||||
| 
 | ||||
|     /** @param authority Host and optional port. */ | ||||
|     @NonNull private String authorityToMobile(@NonNull String authority) { | ||||
|         if (authority.startsWith("m.") || authority.contains(".m.")) { | ||||
|             return authority; | ||||
|         } | ||||
|         return authority.replaceFirst("^" + subdomain() + "\\.?", "$0m."); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										269
									
								
								app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,269 @@ | |||
| package fr.free.nrw.commons.wikidata.model | ||||
| 
 | ||||
| import android.net.Uri | ||||
| import android.os.Parcel | ||||
| import android.os.Parcelable | ||||
| import android.text.TextUtils | ||||
| import com.google.gson.annotations.SerializedName | ||||
| import fr.free.nrw.commons.language.AppLanguageLookUpTable | ||||
| import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_CN_LANGUAGE_CODE | ||||
| import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_HK_LANGUAGE_CODE | ||||
| import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_LANGUAGE_CODE | ||||
| import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_MO_LANGUAGE_CODE | ||||
| import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_SG_LANGUAGE_CODE | ||||
| import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_TW_LANGUAGE_CODE | ||||
| import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.NORWEGIAN_BOKMAL_LANGUAGE_CODE | ||||
| import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.NORWEGIAN_LEGACY_LANGUAGE_CODE | ||||
| import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.SIMPLIFIED_CHINESE_LANGUAGE_CODE | ||||
| import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.TRADITIONAL_CHINESE_LANGUAGE_CODE | ||||
| import org.apache.commons.lang3.StringUtils | ||||
| import java.util.Locale | ||||
| 
 | ||||
| /** | ||||
|  * The base URL and Wikipedia language code for a MediaWiki site. Examples: | ||||
|  * | ||||
|  * | ||||
|  * <lh>Name: scheme / authority / language code</lh> | ||||
|  *  * English Wikipedia: HTTPS / en.wikipedia.org / en | ||||
|  *  * Chinese Wikipedia: HTTPS / zh.wikipedia.org / zh-hans or zh-hant | ||||
|  *  * Meta-Wiki: HTTPS / meta.wikimedia.org / (none) | ||||
|  *  * Test Wikipedia: HTTPS / test.wikipedia.org / test | ||||
|  *  * Võro Wikipedia: HTTPS / fiu-vro.wikipedia.org / fiu-vro | ||||
|  *  * Simple English Wikipedia: HTTPS / simple.wikipedia.org / simple | ||||
|  *  * Simple English Wikipedia (beta cluster mirror): HTTP / simple.wikipedia.beta.wmflabs.org / simple | ||||
|  *  * Development: HTTP / 192.168.1.11:8080 / (none) | ||||
|  * | ||||
|  * | ||||
|  * **As shown above, the language code or mapping is part of the authority:** | ||||
|  * | ||||
|  * <lh>Validity: authority / language code</lh> | ||||
|  *  * Correct: "test.wikipedia.org" / "test" | ||||
|  *  * Correct: "wikipedia.org", "" | ||||
|  *  * Correct: "no.wikipedia.org", "nb" | ||||
|  *  * Incorrect: "wikipedia.org", "test" | ||||
|  * | ||||
|  */ | ||||
| class WikiSite : Parcelable { | ||||
|     //TODO: remove @SerializedName. this is now in the TypeAdapter and a "uri" case may be added | ||||
|     @SerializedName("domain") | ||||
|     private val uri: Uri | ||||
| 
 | ||||
|     private var languageCode: String? = null | ||||
| 
 | ||||
|     constructor(uri: Uri) { | ||||
|         val tempUri = ensureScheme(uri) | ||||
|         var authority = tempUri.authority | ||||
| 
 | ||||
|         if (authority.isWikipedia && tempUri.path?.startsWith("/wiki") == true) { | ||||
|             // Special case for Wikipedia only: assume English subdomain when none given. | ||||
|             authority = "en.wikipedia.org" | ||||
|         } | ||||
| 
 | ||||
|         val langVariant = getLanguageVariantFromUri(tempUri) | ||||
|         languageCode = if (!TextUtils.isEmpty(langVariant)) { | ||||
|             langVariant | ||||
|         } else { | ||||
|             authorityToLanguageCode(authority!!) | ||||
|         } | ||||
| 
 | ||||
|         this.uri = Uri.Builder() | ||||
|             .scheme(tempUri.scheme) | ||||
|             .encodedAuthority(authority) | ||||
|             .build() | ||||
|     } | ||||
| 
 | ||||
|     private val String?.isWikipedia: Boolean get() = | ||||
|         (this == "wikipedia.org" || this == "www.wikipedia.org") | ||||
| 
 | ||||
|     /** Get language variant code from a Uri, e.g. "zh-*", otherwise returns empty string.  */ | ||||
|     private fun getLanguageVariantFromUri(uri: Uri): String { | ||||
|         if (TextUtils.isEmpty(uri.path)) { | ||||
|             return "" | ||||
|         } | ||||
|         val parts = StringUtils.split(StringUtils.defaultString(uri.path), '/') | ||||
|         return if (parts.size > 1 && parts[0] != "wiki") parts[0] else "" | ||||
|     } | ||||
| 
 | ||||
|     constructor(url: String) : this( | ||||
|         if (url.startsWith("http")) Uri.parse(url) else if (url.startsWith("//")) | ||||
|             Uri.parse("$DEFAULT_SCHEME:$url") | ||||
|         else | ||||
|             Uri.parse("$DEFAULT_SCHEME://$url") | ||||
|     ) | ||||
| 
 | ||||
|     constructor(authority: String, languageCode: String) : this(authority) { | ||||
|         this.languageCode = languageCode | ||||
|     } | ||||
| 
 | ||||
|     fun scheme(): String = | ||||
|         if (TextUtils.isEmpty(uri.scheme)) DEFAULT_SCHEME else uri.scheme!! | ||||
| 
 | ||||
|     /** | ||||
|      * @return The complete wiki authority including language subdomain but not including scheme, | ||||
|      * authentication, port, nor trailing slash. | ||||
|      * | ||||
|      * @see [URL syntax](https://en.wikipedia.org/wiki/Uniform_Resource_Locator.Syntax) | ||||
|      */ | ||||
|     fun authority(): String = uri.authority!! | ||||
| 
 | ||||
|     /** | ||||
|      * Like [.authority] but with a "m." between the language subdomain and the rest of the host. | ||||
|      * Examples: | ||||
|      * | ||||
|      * | ||||
|      *  * English Wikipedia: en.m.wikipedia.org | ||||
|      *  * Chinese Wikipedia: zh.m.wikipedia.org | ||||
|      *  * Meta-Wiki: meta.m.wikimedia.org | ||||
|      *  * Test Wikipedia: test.m.wikipedia.org | ||||
|      *  * Võro Wikipedia: fiu-vro.m.wikipedia.org | ||||
|      *  * Simple English Wikipedia: simple.m.wikipedia.org | ||||
|      *  * Simple English Wikipedia (beta cluster mirror): simple.m.wikipedia.beta.wmflabs.org | ||||
|      *  * Development: m.192.168.1.11 | ||||
|      * | ||||
|      */ | ||||
|     fun mobileAuthority(): String = authorityToMobile(authority()) | ||||
| 
 | ||||
|     /** | ||||
|      * Get wiki's mobile URL | ||||
|      * Eg. https://en.m.wikipedia.org | ||||
|      * @return | ||||
|      */ | ||||
|     fun mobileUrl(): String = String.format("%1\$s://%2\$s", scheme(), mobileAuthority()) | ||||
| 
 | ||||
|     fun subdomain(): String = languageCodeToSubdomain(languageCode!!) | ||||
| 
 | ||||
|     /** | ||||
|      * @return A path without an authority for the segment including a leading "/". | ||||
|      */ | ||||
|     fun path(segment: String): String = "/w/$segment" | ||||
| 
 | ||||
| 
 | ||||
|     fun uri(): Uri = uri | ||||
| 
 | ||||
|     /** | ||||
|      * @return The canonical URL. e.g., https://en.wikipedia.org. | ||||
|      */ | ||||
|     fun url(): String = uri.toString() | ||||
| 
 | ||||
|     /** | ||||
|      * @return The canonical URL for segment. e.g., https://en.wikipedia.org/w/foo. | ||||
|      */ | ||||
|     fun url(segment: String): String = url() + path(segment) | ||||
| 
 | ||||
|     /** | ||||
|      * @return The wiki language code which may differ from the language subdomain. Empty if | ||||
|      * language code is unknown. Ex: "en", "zh-hans", "" | ||||
|      * | ||||
|      * @see AppLanguageLookUpTable | ||||
|      */ | ||||
|     fun languageCode(): String = languageCode!! | ||||
| 
 | ||||
|     // Auto-generated | ||||
|     override fun equals(o: Any?): Boolean { | ||||
|         if (this === o) { | ||||
|             return true | ||||
|         } | ||||
|         if (o == null || javaClass != o.javaClass) { | ||||
|             return false | ||||
|         } | ||||
| 
 | ||||
|         val wiki = o as WikiSite | ||||
| 
 | ||||
|         if (uri != wiki.uri) { | ||||
|             return false | ||||
|         } | ||||
|         return languageCode == wiki.languageCode | ||||
|     } | ||||
| 
 | ||||
|     // Auto-generated | ||||
|     override fun hashCode(): Int { | ||||
|         var result = uri.hashCode() | ||||
|         result = 31 * result + languageCode.hashCode() | ||||
|         return result | ||||
|     } | ||||
| 
 | ||||
|     // Auto-generated | ||||
|     override fun toString(): String { | ||||
|         return ("WikiSite{" | ||||
|                 + "uri=" + uri | ||||
|                 + ", languageCode='" + languageCode + '\'' | ||||
|                 + '}') | ||||
|     } | ||||
| 
 | ||||
|     override fun describeContents(): Int = 0 | ||||
| 
 | ||||
|     override fun writeToParcel(dest: Parcel, flags: Int) { | ||||
|         dest.writeParcelable(uri, 0) | ||||
|         dest.writeString(languageCode) | ||||
|     } | ||||
| 
 | ||||
|     protected constructor(`in`: Parcel) { | ||||
|         uri = `in`.readParcelable(Uri::class.java.classLoader)!! | ||||
|         languageCode = `in`.readString() | ||||
|     } | ||||
| 
 | ||||
|     /** @param authority Host and optional port. | ||||
|      */ | ||||
|     private fun authorityToMobile(authority: String): String { | ||||
|         if (authority.startsWith("m.") || authority.contains(".m.")) { | ||||
|             return authority | ||||
|         } | ||||
|         return authority.replaceFirst(("^" + subdomain() + "\\.?").toRegex(), "$0m.") | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
|         const val WIKIPEDIA_URL = "https://wikipedia.org/" | ||||
|         const val DEFAULT_SCHEME: String = "https" | ||||
| 
 | ||||
|         @JvmField | ||||
|         val CREATOR: Parcelable.Creator<WikiSite> = object : Parcelable.Creator<WikiSite> { | ||||
|             override fun createFromParcel(parcel: Parcel): WikiSite { | ||||
|                 return WikiSite(parcel) | ||||
|             } | ||||
| 
 | ||||
|             override fun newArray(size: Int): Array<WikiSite?> { | ||||
|                 return arrayOfNulls(size) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         fun forDefaultLocaleLanguageCode(): WikiSite { | ||||
|             val languageCode: String = Locale.getDefault().language | ||||
|             val subdomain = if (languageCode.isEmpty()) "" else languageCodeToSubdomain(languageCode) + "." | ||||
|             val uri = ensureScheme(Uri.parse(WIKIPEDIA_URL)) | ||||
|             return WikiSite(subdomain + uri.authority, languageCode) | ||||
|         } | ||||
| 
 | ||||
|         private fun languageCodeToSubdomain(languageCode: String): String = when (languageCode) { | ||||
|             SIMPLIFIED_CHINESE_LANGUAGE_CODE, | ||||
|             TRADITIONAL_CHINESE_LANGUAGE_CODE, | ||||
|             CHINESE_CN_LANGUAGE_CODE, | ||||
|             CHINESE_HK_LANGUAGE_CODE, | ||||
|             CHINESE_MO_LANGUAGE_CODE, | ||||
|             CHINESE_SG_LANGUAGE_CODE, | ||||
|             CHINESE_TW_LANGUAGE_CODE -> CHINESE_LANGUAGE_CODE | ||||
| 
 | ||||
|             NORWEGIAN_BOKMAL_LANGUAGE_CODE -> NORWEGIAN_LEGACY_LANGUAGE_CODE // T114042 | ||||
| 
 | ||||
|             else -> languageCode | ||||
|         } | ||||
| 
 | ||||
|         private fun authorityToLanguageCode(authority: String): String { | ||||
|             val parts = authority.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() | ||||
|             val minLengthForSubdomain = 3 | ||||
|             if (parts.size < minLengthForSubdomain || parts.size == minLengthForSubdomain && parts[0] == "m") { | ||||
|                 // "" | ||||
|                 // wikipedia.org | ||||
|                 // m.wikipedia.org | ||||
|                 return "" | ||||
|             } | ||||
|             return parts[0] | ||||
|         } | ||||
| 
 | ||||
|         private fun ensureScheme(uri: Uri): Uri { | ||||
|             if (TextUtils.isEmpty(uri.scheme)) { | ||||
|                 return uri.buildUpon().scheme(DEFAULT_SCHEME).build() | ||||
|             } | ||||
|             return uri | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -148,7 +148,7 @@ public class Notification { | |||
|                 return null; | ||||
|             } | ||||
|             if (primaryLink == null && primary instanceof JsonObject) { | ||||
|                 primaryLink = GsonUtil.getDefaultGson().fromJson(primary, Link.class); | ||||
|                 primaryLink = GsonUtil.INSTANCE.getDefaultGson().fromJson(primary, Link.class); | ||||
|             } | ||||
|             return primaryLink; | ||||
|         } | ||||
|  |  | |||
|  | @ -1,34 +0,0 @@ | |||
| package fr.free.nrw.commons.wikidata.mwapi; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import java.util.Map; | ||||
| 
 | ||||
| 
 | ||||
| public class UserInfo { | ||||
|     @NonNull private String name; | ||||
|     @NonNull private int id; | ||||
| 
 | ||||
|     //Block information | ||||
|     private int blockid; | ||||
|     private String blockedby; | ||||
|     private int blockedbyid; | ||||
|     private String blockreason; | ||||
|     private String blocktimestamp; | ||||
|     private String blockexpiry; | ||||
| 
 | ||||
|     // Object type is any JSON type. | ||||
|     @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") | ||||
|     @Nullable private Map<String, ?> options; | ||||
| 
 | ||||
|     public int id() { | ||||
|         return id; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public String blockexpiry() { | ||||
|         if (blockexpiry != null) | ||||
|             return blockexpiry; | ||||
|         else return ""; | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,21 @@ | |||
| package fr.free.nrw.commons.wikidata.mwapi | ||||
| 
 | ||||
| data class UserInfo( | ||||
|     val name: String = "", | ||||
|     val id: Int = 0, | ||||
| 
 | ||||
|     //Block information | ||||
|     val blockid: Int = 0, | ||||
|     val blockedby: String? = null, | ||||
|     val blockedbyid: Int = 0, | ||||
|     val blockreason: String? = null, | ||||
|     val blocktimestamp: String? = null, | ||||
|     val blockexpiry: String? = null, | ||||
| 
 | ||||
|     // Object type is any JSON type. | ||||
|     val options: Map<String, *>? = null | ||||
| ) { | ||||
|     fun id(): Int = id | ||||
| 
 | ||||
|     fun blockexpiry(): String = blockexpiry ?: "" | ||||
| } | ||||
|  | @ -1,20 +1,20 @@ | |||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
| <ScrollView xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|   xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|   xmlns:tools="http://schemas.android.com/tools" | ||||
|   android:id="@+id/drawer_layout" | ||||
|   android:layout_width="match_parent" | ||||
|   android:layout_height="match_parent" | ||||
|   android:background="?attr/achievementBackground"> | ||||
|   android:background="?attr/achievementBackground" | ||||
|   android:fillViewport="true" | ||||
|   tools:ignore="ContentDescription" > | ||||
| 
 | ||||
|     <ScrollView | ||||
|       android:layout_width="wrap_content" | ||||
| <!-- TODO Add ContentDescription For ALL Images Added ignore to suppress Lints --> | ||||
| 
 | ||||
|   <androidx.constraintlayout.widget.ConstraintLayout | ||||
|     android:id="@+id/layout" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="wrap_content"> | ||||
| 
 | ||||
|       <LinearLayout | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="match_parent" | ||||
|         android:orientation="vertical"> | ||||
| 
 | ||||
|     <androidx.appcompat.widget.AppCompatTextView | ||||
|       android:id="@+id/tv_achievements_of_user" | ||||
|  | @ -22,51 +22,34 @@ | |||
|       android:layout_width="match_parent" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:padding="10dp" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintHorizontal_bias="0.0" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toTopOf="parent" | ||||
|       tools:text="Achievements of user : Ashish" /> | ||||
| 
 | ||||
| 
 | ||||
|         <RelativeLayout | ||||
|           android:layout_width="match_parent" | ||||
|           android:layout_height="wrap_content" | ||||
|           android:background="?attr/achievementBackground" | ||||
|           android:orientation="vertical"> | ||||
| 
 | ||||
|           <TextView | ||||
|             style="?android:textAppearanceLarge" | ||||
|             android:layout_width="wrap_content" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|             android:text="@string/level" | ||||
|             android:id="@+id/achievement_level" | ||||
|             android:textAllCaps="true"/> | ||||
| 
 | ||||
|     <ImageView | ||||
|       android:id="@+id/achievement_info" | ||||
|       android:layout_width="wrap_content" | ||||
|       android:layout_height="wrap_content" | ||||
|             android:layout_marginTop="@dimen/activity_margin_vertical" | ||||
|       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|             android:layout_alignParentEnd="true" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/tv_achievements_of_user" | ||||
|       app:srcCompat="@drawable/ic_info_outline_24dp" | ||||
|             android:layout_marginVertical="@dimen/activity_margin_vertical" | ||||
|             app:tint="@color/black" /> | ||||
| 
 | ||||
|           <androidx.constraintlayout.widget.ConstraintLayout | ||||
|             android:id="@+id/badge_layout" | ||||
|             android:layout_width="wrap_content" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_below="@id/achievement_info" | ||||
|             android:layout_centerHorizontal="true"> | ||||
|       app:tint="@color/black" | ||||
|       tools:ignore="ContentDescription" /> | ||||
| 
 | ||||
|     <ImageView | ||||
|       android:id="@+id/achievement_badge_image" | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|       android:layout_width="150dp" | ||||
|       android:layout_height="150dp" | ||||
|       android:layout_marginTop="16dp" | ||||
|       android:background="@drawable/badge" | ||||
|       app:layout_constraintLeft_toLeftOf="parent" | ||||
|       app:layout_constraintRight_toRightOf="parent" | ||||
|       app:layout_constraintTop_toTopOf="parent" | ||||
|               app:srcCompat="@drawable/badge" /> | ||||
|       tools:layout_height="100dp" | ||||
|       tools:layout_width="100dp" /> | ||||
| 
 | ||||
|     <TextView | ||||
|       android:id="@+id/achievement_badge_text" | ||||
|  | @ -75,566 +58,311 @@ | |||
|       android:textAlignment="center" | ||||
|       android:textColor="@color/achievement_badge_text" | ||||
|       android:textSize="75sp" | ||||
|       tools:text="1" | ||||
|       app:layout_constraintBottom_toBottomOf="@+id/achievement_badge_image" | ||||
|       app:layout_constraintEnd_toEndOf="@+id/achievement_badge_image" | ||||
|       app:layout_constraintStart_toStartOf="@+id/achievement_badge_image" | ||||
|       app:layout_constraintTop_toTopOf="@+id/achievement_badge_image" | ||||
|       app:layout_constraintVertical_bias="0.58" /> | ||||
|           </androidx.constraintlayout.widget.ConstraintLayout> | ||||
| 
 | ||||
|           <RelativeLayout | ||||
|             android:id="@+id/layout_image_uploaded" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_below="@+id/badge_layout" | ||||
|             android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal"> | ||||
| 
 | ||||
| 
 | ||||
|             <LinearLayout | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|               android:id="@+id/images_upload_info" | ||||
|               android:orientation="horizontal" | ||||
|               > | ||||
| 
 | ||||
|     <TextView | ||||
|                 style="?android:textAppearanceMedium" | ||||
|       android:id="@+id/achievement_level" | ||||
|       android:layout_width="wrap_content" | ||||
|       android:layout_height="wrap_content" | ||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginTop="8dp" | ||||
|       android:text="@string/level" | ||||
|       android:textAllCaps="true" | ||||
|       android:textSize="16sp" | ||||
|       android:textStyle="bold" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/achievement_badge_image" /> | ||||
| 
 | ||||
|     <!-- Images Uploaded --> | ||||
|     <TextView | ||||
|       android:id="@+id/images_upload_text_param" | ||||
|                 android:layout_marginTop="@dimen/achievements_activity_margin_vertical" | ||||
|                 android:text="@string/images_uploaded" /> | ||||
|       style="?android:textAppearanceMedium" | ||||
|       android:layout_width="0dp" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|       android:text="@string/images_uploaded" | ||||
|       android:textStyle="bold" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/achievement_level" /> | ||||
| 
 | ||||
|     <ImageView | ||||
|                 android:layout_width="@dimen/quarter_standard_height" | ||||
|                 android:layout_height="@dimen/quarter_standard_height" | ||||
|                 android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|       android:id="@+id/images_upload_info_icon" | ||||
|       android:layout_width="wrap_content" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|       app:layout_constraintBottom_toBottomOf="@+id/images_upload_text_param" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintTop_toTopOf="@+id/images_upload_text_param" | ||||
|       app:srcCompat="@drawable/ic_info_outline_24dp" | ||||
|                 android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|       app:tint="@color/black" /> | ||||
| 
 | ||||
|     <!--  Image's Uploaded Progress Bar  --> | ||||
|     <RelativeLayout | ||||
|       android:id="@+id/rl_images_Uploaded" | ||||
|       android:layout_width="match_parent" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|                 app:tint="@color/primaryLightColor" /> | ||||
|       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/images_upload_text_param"> | ||||
| 
 | ||||
|             </LinearLayout> | ||||
| 
 | ||||
|             <FrameLayout | ||||
|               android:layout_width="@dimen/dimen_40" | ||||
|               android:layout_height="@dimen/dimen_40" | ||||
|               android:layout_alignParentEnd="true" | ||||
|               android:layout_marginEnd="32dp"> | ||||
| 
 | ||||
|               <com.google.android.material.progressindicator.CircularProgressIndicator | ||||
|       <ProgressBar | ||||
|         android:id="@+id/images_uploaded_progressbar" | ||||
|                 android:layout_width="@dimen/dimen_40" | ||||
|                 android:layout_height="@dimen/dimen_40" | ||||
|                 android:indeterminate="false" | ||||
|                 android:layout_marginEnd="@dimen/large_gap" | ||||
|                 app:showAnimationBehavior="outward" | ||||
|                 app:indicatorColor="@color/primaryColor" | ||||
|                 app:indicatorSize="32dp" | ||||
|                 app:trackThickness="@dimen/progressbar_stroke" | ||||
|                 app:trackColor="#B7B6B6" | ||||
|                 android:visibility="gone"/> | ||||
| 
 | ||||
|               <androidx.appcompat.widget.AppCompatTextView | ||||
|                 android:id="@+id/tv_uploaded_images" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 android:padding="@dimen/progressbar_padding" | ||||
|                 android:gravity="center" | ||||
|                 android:maxLines="1" | ||||
|                 android:textColor="@color/secondaryColor" | ||||
|                 app:autoSizeMaxTextSize="@dimen/progressbar_text" | ||||
|                 app:autoSizeMinTextSize="2sp" | ||||
|                 app:autoSizeStepGranularity="1sp" | ||||
|                 app:autoSizeTextType="uniform" /> | ||||
| 
 | ||||
|             </FrameLayout> | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|           </RelativeLayout> | ||||
| 
 | ||||
|           <RelativeLayout | ||||
|             android:id="@+id/layout_image_reverts" | ||||
|         style="?android:attr/progressBarStyleHorizontal" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|             android:layout_marginTop="@dimen/tiny_margin" | ||||
|             android:layout_below="@+id/layout_image_uploaded" | ||||
|             android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal"> | ||||
| 
 | ||||
|             <LinearLayout | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|               android:id="@+id/images_reverted_info" | ||||
|               android:orientation="horizontal" | ||||
|               > | ||||
|         android:layout_centerInParent="true" | ||||
|         android:progressDrawable="@android:drawable/progress_horizontal" | ||||
|         android:progressBackgroundTintMode="multiply" | ||||
|         android:progressTint="#5ce65c" | ||||
|         tools:progress="50" /> | ||||
| 
 | ||||
|       <TextView | ||||
|         android:id="@+id/imageUploadedTVCount" | ||||
|         style="?android:textAppearanceMedium" | ||||
|         android:layout_width="wrap_content" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_centerInParent="true" | ||||
|         android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|         tools:text="10/15" /> | ||||
| 
 | ||||
|     </RelativeLayout> | ||||
| 
 | ||||
|     <com.google.android.material.divider.MaterialDivider | ||||
|       android:id="@+id/materialDivider" | ||||
|       android:layout_width="match_parent" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/rl_images_Uploaded" /> | ||||
| 
 | ||||
| 
 | ||||
|     <!-- Image's Not Reverted --> | ||||
|     <TextView | ||||
|       android:id="@+id/images_reverted_text" | ||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|                 android:text="@string/image_reverts" /> | ||||
| 
 | ||||
|               <ImageView | ||||
|                 android:layout_width="@dimen/medium_width" | ||||
|                 android:layout_height="@dimen/medium_height" | ||||
|                 android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|                 app:srcCompat="@drawable/ic_info_outline_24dp" | ||||
|                 android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" app:tint="@color/primaryLightColor" /> | ||||
| 
 | ||||
|             </LinearLayout> | ||||
| 
 | ||||
| 
 | ||||
|             <TextView | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|               android:text="@string/achievements_revert_limit_message" | ||||
|               android:textSize="@dimen/small_text" | ||||
|               android:id="@+id/images_revert_limit_text" | ||||
|               android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|               android:layout_below="@id/images_reverted_info"/> | ||||
| 
 | ||||
|             <FrameLayout | ||||
|               android:layout_width="@dimen/dimen_40" | ||||
|               android:layout_height="@dimen/dimen_40" | ||||
|               android:layout_alignParentEnd="true" | ||||
|               android:layout_marginEnd="32dp"> | ||||
| 
 | ||||
|               <com.google.android.material.progressindicator.CircularProgressIndicator | ||||
|                 android:id="@+id/image_reverts_progressbar" | ||||
|                 android:layout_width="@dimen/dimen_40" | ||||
|                 android:layout_height="@dimen/dimen_40" | ||||
|                 android:indeterminate="false" | ||||
|                 android:layout_marginEnd="@dimen/large_gap" | ||||
|                 app:showAnimationBehavior="outward" | ||||
|                 app:indicatorColor="@color/primaryColor" | ||||
|                 app:indicatorSize="32dp" | ||||
|                 app:trackThickness="@dimen/progressbar_stroke" | ||||
|                 app:trackColor="#B7B6B6" | ||||
|                 android:visibility="gone"/> | ||||
| 
 | ||||
|               <androidx.appcompat.widget.AppCompatTextView | ||||
|                 android:id="@+id/tv_reverted_images" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 android:padding="@dimen/progressbar_padding" | ||||
|                 android:gravity="center" | ||||
|                 android:maxLines="1" | ||||
|                 android:textColor="@color/secondaryColor" | ||||
|                 app:autoSizeMaxTextSize="@dimen/progressbar_text" | ||||
|                 app:autoSizeMinTextSize="2sp" | ||||
|                 app:autoSizeStepGranularity="1sp" | ||||
|                 app:autoSizeTextType="uniform" /> | ||||
|             </FrameLayout> | ||||
|           </RelativeLayout> | ||||
| 
 | ||||
|           <RelativeLayout | ||||
|             android:id="@+id/layout_image_used_by_wiki" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginTop="@dimen/tiny_margin" | ||||
|             android:layout_below="@+id/layout_image_reverts" | ||||
|             android:layout_marginBottom="@dimen/activity_margin_vertical" | ||||
|             android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal"> | ||||
| 
 | ||||
|             <LinearLayout | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|               android:id="@+id/images_used_by_wiki_info" | ||||
|               android:orientation="horizontal"> | ||||
| 
 | ||||
|               <TextView | ||||
|       style="?android:textAppearanceMedium" | ||||
|       android:layout_width="wrap_content" | ||||
|       android:layout_height="wrap_content" | ||||
|                 android:id="@+id/images_used_by_wiki_text" | ||||
|       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginTop="@dimen/achievements_activity_margin_vertical" | ||||
|                 android:text="@string/images_used_by_wiki" /> | ||||
|       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|       android:text="@string/image_reverts" | ||||
|       android:textStyle="bold" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/materialDivider" /> | ||||
| 
 | ||||
|     <TextView | ||||
|       android:id="@+id/images_revert_limit_text" | ||||
|       android:layout_width="0dp" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginStart="4dp" | ||||
|       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|       android:text="@string/achievements_revert_limit_message" | ||||
|       app:layout_constraintBottom_toBottomOf="@+id/images_reverted_text" | ||||
|       app:layout_constraintEnd_toStartOf="@+id/images_reverted_info_icon" | ||||
|       app:layout_constraintStart_toEndOf="@+id/images_reverted_text" | ||||
|       app:layout_constraintTop_toTopOf="@+id/images_reverted_text" /> | ||||
| 
 | ||||
|     <ImageView | ||||
|                 android:layout_width="@dimen/medium_width" | ||||
|                 android:layout_height="@dimen/medium_height" | ||||
|                 android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|       android:id="@+id/images_reverted_info_icon" | ||||
|       android:layout_width="wrap_content" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|       app:layout_constraintBottom_toBottomOf="@+id/images_revert_limit_text" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintTop_toTopOf="@+id/images_revert_limit_text" | ||||
|       app:srcCompat="@drawable/ic_info_outline_24dp" | ||||
|                 android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|                 app:tint="@color/primaryLightColor" /> | ||||
|       app:tint="@color/black" /> | ||||
| 
 | ||||
|             </LinearLayout> | ||||
| 
 | ||||
|             <FrameLayout | ||||
|               android:layout_width="@dimen/dimen_40" | ||||
|               android:layout_height="@dimen/dimen_40" | ||||
|               android:layout_alignParentEnd="true" | ||||
|               android:layout_marginEnd="32dp"> | ||||
| 
 | ||||
|               <com.google.android.material.progressindicator.CircularProgressIndicator | ||||
|                 android:id="@+id/images_used_by_wiki_progress_bar" | ||||
|                 android:layout_width="@dimen/dimen_40" | ||||
|                 android:layout_height="@dimen/dimen_40" | ||||
|                 android:indeterminate="false" | ||||
|                 android:layout_marginEnd="@dimen/large_gap" | ||||
|                 app:showAnimationBehavior="outward" | ||||
|                 app:indicatorColor="@color/primaryColor" | ||||
|                 app:indicatorSize="32dp" | ||||
|                 app:trackThickness="@dimen/progressbar_stroke" | ||||
|                 app:trackColor="#B7B6B6" | ||||
|                 android:visibility="gone"/> | ||||
| 
 | ||||
|               <androidx.appcompat.widget.AppCompatTextView | ||||
|                 android:id="@+id/tv_wiki_pb" | ||||
|     <!--  Image's Not Reverted Progress Bar  --> | ||||
|     <RelativeLayout | ||||
|       android:id="@+id/rl_images_reverted" | ||||
|       android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 android:padding="@dimen/progressbar_padding" | ||||
|                 android:gravity="center" | ||||
|                 android:maxLines="1" | ||||
|                 android:textColor="@color/secondaryColor" | ||||
|                 app:autoSizeMaxTextSize="@dimen/progressbar_text" | ||||
|                 app:autoSizeMinTextSize="2sp" | ||||
|                 app:autoSizeStepGranularity="1sp" | ||||
|                 app:autoSizeTextType="uniform" /> | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/images_reverted_text"> | ||||
| 
 | ||||
|             </FrameLayout> | ||||
|       <ProgressBar | ||||
|         android:id="@+id/image_reverts_progressbar" | ||||
|         style="?android:attr/progressBarStyleHorizontal" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_centerInParent="true" | ||||
|         android:progressDrawable="@android:drawable/progress_horizontal" | ||||
|         android:progressBackgroundTintMode="multiply" | ||||
|         android:progressTint="#5ce65c" | ||||
|         tools:progress="50" /> | ||||
| 
 | ||||
|       <TextView | ||||
|         android:id="@+id/imageRevertTVCount" | ||||
|         style="?android:textAppearanceMedium" | ||||
|         android:layout_width="wrap_content" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_centerInParent="true" | ||||
|         tools:text="10/15" /> | ||||
|     </RelativeLayout> | ||||
| 
 | ||||
| 
 | ||||
|     <com.google.android.material.divider.MaterialDivider | ||||
|       android:id="@+id/materialDivider1" | ||||
|       android:layout_width="match_parent" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/rl_images_reverted" /> | ||||
| 
 | ||||
|     <!-- Image Used --> | ||||
|     <TextView | ||||
|       android:id="@+id/images_used_tv" | ||||
|       style="?android:textAppearanceMedium" | ||||
|       android:layout_width="wrap_content" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|       android:text="@string/images_used_by_wiki" | ||||
|       android:textStyle="bold" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/materialDivider1" /> | ||||
| 
 | ||||
|     <ImageView | ||||
|       android:id="@+id/images_used_by_wiki_info_icon" | ||||
|       android:layout_width="wrap_content" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|       app:layout_constraintBottom_toBottomOf="@+id/images_used_tv" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintTop_toTopOf="@+id/images_used_tv" | ||||
|       app:srcCompat="@drawable/ic_info_outline_24dp" | ||||
|       app:tint="@color/black" /> | ||||
| 
 | ||||
|     <!--  Image's Used Progress Bar  --> | ||||
|     <RelativeLayout | ||||
|       android:id="@+id/rl_images_used" | ||||
|       android:layout_width="match_parent" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/images_used_tv"> | ||||
| 
 | ||||
|       <ProgressBar | ||||
|         android:id="@+id/images_used_by_wiki_progress_bar" | ||||
|         style="?android:attr/progressBarStyleHorizontal" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_centerInParent="true" | ||||
|         android:progressDrawable="@android:drawable/progress_horizontal" | ||||
|         android:progressBackgroundTintMode="multiply" | ||||
|         android:progressTint="#5ce65c" | ||||
|         tools:progress="50" /> | ||||
| 
 | ||||
|       <TextView | ||||
|         android:id="@+id/imagesUsedCount" | ||||
|         style="?android:textAppearanceMedium" | ||||
|         android:layout_width="wrap_content" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_centerInParent="true" | ||||
|         tools:text="10/15" /> | ||||
|     </RelativeLayout> | ||||
| 
 | ||||
|     <com.google.android.material.divider.MaterialDivider | ||||
|       android:id="@+id/materialDivider2" | ||||
|       android:layout_width="match_parent" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/rl_images_used" /> | ||||
| 
 | ||||
|     <!-- Statistics --> | ||||
|     <TextView | ||||
|       android:id="@+id/tv_statistics" | ||||
|       android:layout_width="wrap_content" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|       android:text="@string/badges" | ||||
|       android:textAllCaps="true" | ||||
|       android:textSize="16sp" | ||||
|       android:textStyle="bold" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/materialDivider2" /> | ||||
| 
 | ||||
|     <LinearLayout | ||||
|       android:id="@+id/badgesItems" | ||||
|       android:layout_width="match_parent" | ||||
|       android:layout_height="wrap_content" | ||||
|       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|       android:orientation="horizontal" | ||||
|       android:padding="@dimen/activity_margin_horizontal" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toBottomOf="@+id/tv_statistics"> | ||||
| 
 | ||||
|       <!--Nearby Places Statistics--> | ||||
|       <ImageView | ||||
|         android:id="@+id/wikidata_edits_icon" | ||||
|         android:layout_width="@dimen/dimen_40" | ||||
|         android:layout_height="@dimen/dimen_40" | ||||
|         android:layout_marginEnd="@dimen/large_gap" | ||||
|         app:srcCompat="@drawable/ic_custom_map_marker" /> | ||||
| 
 | ||||
|       <!--Featured Image Statistics--> | ||||
|       <ImageView | ||||
|         android:id="@+id/featured_image_icon" | ||||
|         android:layout_width="@dimen/dimen_40" | ||||
|         android:layout_height="@dimen/dimen_40" | ||||
|         android:layout_marginEnd="@dimen/large_gap" | ||||
|         app:srcCompat="@drawable/featured" /> | ||||
| 
 | ||||
|       <!--Quality Image Statistics--> | ||||
|       <ImageView | ||||
|         android:id="@+id/quality_image_icon" | ||||
|         android:layout_width="@dimen/dimen_40" | ||||
|         android:layout_height="@dimen/dimen_40" | ||||
|         android:layout_marginEnd="@dimen/large_gap" | ||||
|         app:srcCompat="@drawable/ic_quality_images_logo" /> | ||||
| 
 | ||||
|       <!--Thank Image Statistics--> | ||||
|       <ImageView | ||||
|         android:id="@+id/thanks_image_icon" | ||||
|         android:layout_width="@dimen/dimen_40" | ||||
|         android:layout_height="@dimen/dimen_40" | ||||
|         android:layout_marginEnd="@dimen/large_gap" | ||||
|         app:srcCompat="@drawable/ic_thanks" /> | ||||
|     </LinearLayout> | ||||
| 
 | ||||
|     <ProgressBar | ||||
|       android:id="@+id/progressBar" | ||||
|             android:layout_centerVertical="true" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" /> | ||||
| 
 | ||||
|         </RelativeLayout> | ||||
| 
 | ||||
|         <LinearLayout | ||||
|           android:layout_width="match_parent" | ||||
|           android:layout_height="wrap_content" | ||||
|           android:id="@+id/layout_statistics" | ||||
|           android:orientation="vertical"> | ||||
| 
 | ||||
|           <TextView | ||||
|       android:layout_width="wrap_content" | ||||
|       android:layout_height="wrap_content" | ||||
|             android:text="@string/statistics" | ||||
|             style="?android:textAppearanceLarge" | ||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginTop="@dimen/activity_margin_vertical" | ||||
|             android:textAllCaps="true"/> | ||||
| 
 | ||||
|           <RelativeLayout | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginTop="@dimen/activity_margin_horizontal"> | ||||
| 
 | ||||
|             <androidx.constraintlayout.widget.ConstraintLayout | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|               android:id="@+id/images_nearby_info" | ||||
|               android:layout_centerVertical="true" | ||||
|               android:layout_alignParentStart="true" | ||||
|               android:layout_toStartOf="@+id/wikidata_edits" | ||||
|               android:orientation="horizontal" | ||||
|               android:gravity="center_vertical"> | ||||
| 
 | ||||
|               <ImageView | ||||
|                 android:id="@+id/wikidata_edits_icon" | ||||
|                 android:layout_width="@dimen/overflow_icon_dimen" | ||||
|                 android:layout_height="@dimen/overflow_icon_dimen" | ||||
|                 app:layout_constraintLeft_toLeftOf="parent" | ||||
|                 app:layout_constraintTop_toTopOf="parent" | ||||
|                 app:srcCompat="@drawable/ic_custom_map_marker_dark" /> | ||||
| 
 | ||||
|               <TextView | ||||
|                 android:layout_width="0dp" | ||||
|                 android:layout_height="0dp" | ||||
|                 android:id="@+id/images_nearby_data" | ||||
|                 style="?android:textAppearanceMedium" | ||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|                 app:layout_constraintLeft_toRightOf="@id/wikidata_edits_icon" | ||||
|                 app:layout_constraintTop_toTopOf="parent" | ||||
|       app:layout_constraintBottom_toBottomOf="parent" | ||||
|                 app:layout_constraintRight_toLeftOf="@id/images_nearby_info_icon" | ||||
|                 android:text="@string/statistics_wikidata_edits"  /> | ||||
| 
 | ||||
|               <ImageView | ||||
|                 android:layout_width="@dimen/medium_width" | ||||
|                 android:layout_height="@dimen/medium_height" | ||||
|                 android:id="@+id/images_nearby_info_icon" | ||||
|                 android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_gravity="top" | ||||
|                 app:layout_constraintLeft_toRightOf="@id/images_nearby_data" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintStart_toStartOf="parent" | ||||
|       app:layout_constraintTop_toTopOf="parent" | ||||
|                 app:layout_constraintRight_toRightOf="parent" | ||||
|                 app:srcCompat="@drawable/ic_info_outline_24dp" | ||||
|                 app:tint="@color/primaryLightColor" /> | ||||
|       app:layout_constraintVertical_bias="0.5" | ||||
|       app:layout_constraintHorizontal_bias="0.5" | ||||
|       android:visibility="gone"/> | ||||
| 
 | ||||
|   </androidx.constraintlayout.widget.ConstraintLayout> | ||||
| 
 | ||||
| 
 | ||||
|             <TextView | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|               style="?android:textAppearanceMedium" | ||||
|               android:layout_alignParentEnd="true" | ||||
|               android:layout_marginEnd="@dimen/half_standard_height" | ||||
|               android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|               android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|               android:layout_centerVertical="true" | ||||
|               tools:text="2" | ||||
|               android:id="@+id/wikidata_edits" | ||||
|               /> | ||||
| 
 | ||||
|           </RelativeLayout> | ||||
| 
 | ||||
|           <RelativeLayout | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginTop="@dimen/activity_margin_horizontal"> | ||||
| 
 | ||||
|             <androidx.constraintlayout.widget.ConstraintLayout | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|               android:id="@+id/images_featured_info" | ||||
|               android:layout_centerVertical="true" | ||||
|               android:layout_alignParentStart="true" | ||||
|               android:layout_toStartOf="@+id/image_featured" | ||||
|               android:orientation="horizontal" | ||||
|               android:gravity="center_vertical"> | ||||
| 
 | ||||
|               <ImageView | ||||
|                 android:layout_width="@dimen/overflow_icon_dimen" | ||||
|                 android:layout_height="@dimen/overflow_icon_dimen" | ||||
|                 android:id="@+id/featured_image_icon" | ||||
|                 app:layout_constraintLeft_toLeftOf="parent" | ||||
|                 app:layout_constraintTop_toTopOf="parent" | ||||
|                 app:srcCompat="@drawable/featured" | ||||
|                 android:scaleType="centerCrop" /> | ||||
| 
 | ||||
|               <TextView | ||||
|                 android:layout_width="0dp" | ||||
|                 android:layout_height="0dp" | ||||
|                 style="?android:textAppearanceMedium" | ||||
|                 android:id="@+id/images_featured_data" | ||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|                 app:layout_constraintLeft_toRightOf="@id/featured_image_icon" | ||||
|                 app:layout_constraintTop_toTopOf="parent" | ||||
|                 app:layout_constraintBottom_toBottomOf="parent" | ||||
|                 app:layout_constraintRight_toLeftOf="@id/images_featured_info_icon" | ||||
|                 android:text="@string/statistics_featured"  /> | ||||
| 
 | ||||
|               <ImageView | ||||
|                 android:layout_width="@dimen/medium_width" | ||||
|                 android:layout_height="@dimen/medium_height" | ||||
|                 android:id="@+id/images_featured_info_icon" | ||||
|                 android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|                 app:layout_constraintLeft_toRightOf="@id/images_featured_data" | ||||
|                 app:layout_constraintTop_toTopOf="parent" | ||||
|                 app:layout_constraintRight_toRightOf="parent" | ||||
|                 android:layout_gravity="top" | ||||
|                 app:srcCompat="@drawable/ic_info_outline_24dp" | ||||
|                 app:tint="@color/primaryLightColor" /> | ||||
| 
 | ||||
|             </androidx.constraintlayout.widget.ConstraintLayout> | ||||
| 
 | ||||
|             <TextView | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|               style="?android:textAppearanceMedium" | ||||
|               android:layout_alignParentEnd="true" | ||||
|               android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|               android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|               android:layout_centerVertical="true" | ||||
|               tools:text="2" | ||||
|               android:id="@+id/image_featured" | ||||
|               android:layout_marginEnd="@dimen/half_standard_height" | ||||
|               /> | ||||
| 
 | ||||
|           </RelativeLayout> | ||||
| 
 | ||||
|           <RelativeLayout | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginTop="@dimen/activity_margin_horizontal"> | ||||
| 
 | ||||
|             <androidx.constraintlayout.widget.ConstraintLayout | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|               android:id="@+id/quality_images_info" | ||||
|               android:layout_centerVertical="true" | ||||
|               android:layout_alignParentStart="true" | ||||
|               android:layout_toStartOf="@+id/quality_images" | ||||
|               android:orientation="horizontal" | ||||
|               android:gravity="center_vertical"> | ||||
| 
 | ||||
|               <ImageView | ||||
|                 android:layout_width="@dimen/overflow_icon_dimen" | ||||
|                 android:layout_height="@dimen/overflow_icon_dimen" | ||||
|                 android:id="@+id/quality_image_icon" | ||||
|                 app:layout_constraintLeft_toLeftOf="parent" | ||||
|                 app:layout_constraintTop_toTopOf="parent" | ||||
|                 app:srcCompat="@drawable/ic_quality_images_logo" | ||||
|                 android:scaleType="centerInside" /> | ||||
| 
 | ||||
|               <TextView | ||||
|                 android:layout_width="0dp" | ||||
|                 android:layout_height="0dp" | ||||
|                 style="?android:textAppearanceMedium" | ||||
|                 android:id="@+id/quality_images_data" | ||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|                 app:layout_constraintLeft_toRightOf="@id/quality_image_icon" | ||||
|                 app:layout_constraintTop_toTopOf="parent" | ||||
|                 app:layout_constraintBottom_toBottomOf="parent" | ||||
|                 app:layout_constraintRight_toLeftOf="@id/quality_images_info_icon" | ||||
|                 android:text="@string/statistics_quality"  /> | ||||
| 
 | ||||
|               <ImageView | ||||
|                 android:layout_width="@dimen/medium_width" | ||||
|                 android:layout_height="@dimen/medium_height" | ||||
|                 android:id="@+id/quality_images_info_icon" | ||||
|                 android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|                 app:layout_constraintLeft_toRightOf="@id/quality_images_data" | ||||
|                 app:layout_constraintTop_toTopOf="parent" | ||||
|                 app:layout_constraintRight_toRightOf="parent" | ||||
|                 android:layout_gravity="top" | ||||
|                 app:srcCompat="@drawable/ic_info_outline_24dp" | ||||
|                 app:tint="@color/primaryLightColor" /> | ||||
| 
 | ||||
|             </androidx.constraintlayout.widget.ConstraintLayout> | ||||
| 
 | ||||
|             <TextView | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|               style="?android:textAppearanceMedium" | ||||
|               android:layout_alignParentEnd="true" | ||||
|               android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|               android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|               android:layout_centerVertical="true" | ||||
|               tools:text="2" | ||||
|               android:text="0" | ||||
|               android:id="@+id/quality_images" | ||||
|               android:layout_marginEnd="@dimen/half_standard_height" | ||||
|               /> | ||||
| 
 | ||||
|           </RelativeLayout> | ||||
| 
 | ||||
|           <RelativeLayout | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|             android:layout_marginEnd="@dimen/activity_margin_horizontal"> | ||||
| 
 | ||||
|             <androidx.constraintlayout.widget.ConstraintLayout | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|               android:id="@+id/thanks_received_info" | ||||
|               android:layout_centerVertical="true" | ||||
|               android:layout_alignParentStart="true" | ||||
|               android:layout_toStartOf="@+id/thanks_received" | ||||
|               android:orientation="horizontal" | ||||
|               android:gravity="center_vertical"> | ||||
| 
 | ||||
|               <ImageView | ||||
|                 android:layout_width="@dimen/overflow_icon_dimen" | ||||
|                 android:layout_height="@dimen/overflow_icon_dimen" | ||||
|                 android:id="@+id/thanks_image_icon" | ||||
|                 app:layout_constraintLeft_toLeftOf="parent" | ||||
|                 app:layout_constraintTop_toTopOf="parent" | ||||
|                 app:srcCompat="@drawable/ic_thanks" | ||||
|                 android:scaleType="centerCrop" /> | ||||
| 
 | ||||
|               <TextView | ||||
|                 android:layout_width="0dp" | ||||
|                 android:layout_height="0dp" | ||||
|                 style="?android:textAppearanceMedium" | ||||
|                 android:id="@+id/thanks_received_data" | ||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginLeft="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginRight="@dimen/activity_margin_horizontal" | ||||
|                 app:layout_constraintLeft_toRightOf="@id/thanks_image_icon" | ||||
|                 app:layout_constraintTop_toTopOf="parent" | ||||
|                 app:layout_constraintBottom_toBottomOf="parent" | ||||
|                 app:layout_constraintRight_toLeftOf="@id/thanks_received_info_icon" | ||||
|                 android:text="@string/statistics_thanks"  /> | ||||
| 
 | ||||
|               <ImageView | ||||
|                 android:layout_width="@dimen/medium_width" | ||||
|                 android:layout_height="@dimen/medium_height" | ||||
|                 android:id="@+id/thanks_received_info_icon" | ||||
|                 android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|                 android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||
|                 app:layout_constraintLeft_toRightOf="@id/thanks_received_data" | ||||
|                 app:layout_constraintTop_toTopOf="parent" | ||||
|                 app:layout_constraintRight_toRightOf="parent" | ||||
|                 android:layout_gravity="top" | ||||
|                 app:srcCompat="@drawable/ic_info_outline_24dp" | ||||
|                 app:tint="@color/primaryLightColor" /> | ||||
| 
 | ||||
|             </androidx.constraintlayout.widget.ConstraintLayout> | ||||
| 
 | ||||
|             <TextView | ||||
|               android:layout_width="wrap_content" | ||||
|               android:layout_height="wrap_content" | ||||
|               style="?android:textAppearanceMedium" | ||||
|               android:layout_alignParentEnd="true" | ||||
|               android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||
|               android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||
|               android:layout_centerVertical="true" | ||||
|               tools:text="2" | ||||
|               android:id="@+id/thanks_received" | ||||
|               android:layout_marginEnd="@dimen/half_standard_height" | ||||
|               /> | ||||
| 
 | ||||
|           </RelativeLayout> | ||||
| 
 | ||||
|         </LinearLayout> | ||||
|       </LinearLayout> | ||||
| </ScrollView> | ||||
| </androidx.drawerlayout.widget.DrawerLayout> | ||||
|  |  | |||
|  | @ -21,6 +21,7 @@ | |||
| * Okkerem | ||||
| * Oyuncu | ||||
| * Rapsar | ||||
| * RuzDD | ||||
| * SaldırganSincap | ||||
| * Sayginer | ||||
| * Sezgin İbiş | ||||
|  | @ -146,6 +147,7 @@ | |||
|   <string name="categories_search_text_hint">Kategori ara</string> | ||||
|   <string name="depicts_search_text_hint">Medyanızın tasvir ettiği ögeleri arayın (dağ, Tac Mahal, vb.)</string> | ||||
|   <string name="menu_save_categories">Kaydet</string> | ||||
|   <string name="menu_overflow_desc">Taşma menüsü</string> | ||||
|   <string name="refresh_button">Yenile</string> | ||||
|   <string name="display_list_button">Liste</string> | ||||
|   <string name="contributions_subtitle_zero">!Henüz yükleme yok)</string> | ||||
|  | @ -800,6 +802,7 @@ | |||
|   <string name="please_enter_some_comments">Lütfen bir yorum girin</string> | ||||
|   <string name="talk">Tartışma</string> | ||||
|   <string name="write_something_about_the_item">\' %1$s \' öğesi hakkında bir şeyler yazın. Herkes tarafından görülebilir olacaktır.</string> | ||||
|   <string name="does_not_exist_anymore_no_picture_can_ever_be_taken_of_it">\'%1$s\' artık yok, dolayısı ile resmi çekilemez.</string> | ||||
|   <string name="other_problem_or_information_please_explain_below">Diğer sorun veya bilgi (lütfen aşağıda açıklayınız).</string> | ||||
|   <string name="feedback_destination_note">Geri bildiriminiz aşağıdaki wiki sayfasına gönderilir:  <a href=\"https://commons.wikimedia.org/wiki/Commons:Mobile_app/Feedback\">Commons:Mobile app/Feedback</a></string> | ||||
|   <string name="are_you_sure_that_you_want_cancel_all_the_uploads">Tüm yüklemeleri iptal etmek istediğinizden emin misiniz?</string> | ||||
|  | @ -807,5 +810,10 @@ | |||
|   <string name="uploads">Yüklemeler</string> | ||||
|   <string name="pending">Beklemede</string> | ||||
|   <string name="failed">Başarısız</string> | ||||
|   <string name="custom_selector_delete">Sil</string> | ||||
|   <string name="custom_selector_cancel">İptal</string> | ||||
|   <string name="custom_selector_folder_deleted_success">%1$s klasörü başarıyla silindi</string> | ||||
|   <string name="custom_selector_folder_deleted_failure">%1$s klasörü silinemedi</string> | ||||
|   <string name="green_pin">Bu yerin zaten bir resmi var.</string> | ||||
|   <string name="grey_pin">Şimdi bu yerin bir resime sahip olup olmadığı denetleniyor.</string> | ||||
| </resources> | ||||
|  |  | |||
|  | @ -371,11 +371,13 @@ | |||
|   <string name="delete">Delete</string> | ||||
|   <string name="Achievements">Achievements</string> | ||||
|   <string name="Profile">Profile</string> | ||||
|   <string name="badges">Badges</string> | ||||
|   <string name="statistics">Statistics</string> | ||||
|   <string name="statistics_thanks">Thanks Received</string> | ||||
|   <string name="statistics_featured">Featured Images</string> | ||||
|   <string name="statistics_wikidata_edits">Images via \"Nearby Places\"</string> | ||||
|   <string name="level">Level</string> | ||||
|   <string name="level">Level %d</string> | ||||
|   <string name="profileLevel">%s (Level %s)</string> | ||||
|   <string name="images_uploaded">Images Uploaded</string> | ||||
|   <string name="image_reverts">Images Not Reverted</string> | ||||
|   <string name="images_used_by_wiki">Images Used</string> | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| <resources> | ||||
| 
 | ||||
|     <style name="DarkAppTheme" parent="Theme.AppCompat.NoActionBar"> | ||||
|     <style name="DarkAppTheme" parent="Theme.MaterialComponents.NoActionBar.Bridge"> | ||||
|         <item name="contributionsListBackground">@color/contributionListDarkBackground</item> | ||||
|         <item name="tabBackground">@color/contributionListDarkBackground</item> | ||||
|         <item name="tabIndicatorColor">@color/white</item> | ||||
|  | @ -62,7 +62,7 @@ | |||
|         <item name="android:splitMotionEvents">false</item> | ||||
|     </style> | ||||
| 
 | ||||
|     <style name="LightAppTheme" parent="Theme.AppCompat.Light.NoActionBar"> | ||||
|     <style name="LightAppTheme" parent="Theme.MaterialComponents.Light.NoActionBar.Bridge"> | ||||
|         <item name="contributionsListBackground">@color/white</item> | ||||
|         <item name="tabBackground">@color/card_light_grey</item> | ||||
|         <item name="tabIndicatorColor">@color/primaryDarkColor</item> | ||||
|  | @ -73,6 +73,7 @@ | |||
|         <item name="drawerHeaderBackground">@color/drawerHeader_background_light</item> | ||||
|         <item name="tutorialBackground">@color/tutorial_background_light</item> | ||||
|         <item name="icon">@color/secondaryTextColor</item> | ||||
|         <item name="colorPrimary">@color/primaryDarkColor</item> | ||||
|         <item name="colorPrimaryDark">@color/primaryDarkColor</item> | ||||
|         <item name="colorAccent">@color/primaryColor</item> | ||||
|         <item name="colorButtonNormal">@color/primaryColor</item> | ||||
|  |  | |||
|  | @ -69,7 +69,7 @@ public abstract class MockWebServerTest { | |||
|                 .baseUrl(url) | ||||
|                 .callbackExecutor(new ImmediateExecutor()) | ||||
|                 .client(okHttpClient) | ||||
|                 .addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson())) | ||||
|                 .addConverterFactory(GsonConverterFactory.create(GsonUtil.INSTANCE.getDefaultGson())) | ||||
|                 .build() | ||||
|                 .create(clazz); | ||||
|     } | ||||
|  |  | |||
|  | @ -49,13 +49,13 @@ class CampaignsPresenterTest { | |||
|         campaignsSingle = Single.just(campaignResponseDTO) | ||||
|         campaignsPresenter = CampaignsPresenter(okHttpJsonApiClient, testScheduler, testScheduler) | ||||
|         campaignsPresenter.onAttachView(view) | ||||
|         Mockito.`when`(okHttpJsonApiClient.campaigns).thenReturn(campaignsSingle) | ||||
|         Mockito.`when`(okHttpJsonApiClient.getCampaigns()).thenReturn(campaignsSingle) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun getCampaignsTestNoCampaigns() { | ||||
|         campaignsPresenter.getCampaigns() | ||||
|         verify(okHttpJsonApiClient).campaigns | ||||
|         verify(okHttpJsonApiClient).getCampaigns() | ||||
|         testScheduler.triggerActions() | ||||
|         verify(view).showCampaigns(null) | ||||
|     } | ||||
|  | @ -77,7 +77,7 @@ class CampaignsPresenterTest { | |||
|         Mockito.`when`(campaign.endDate).thenReturn(endDateString) | ||||
|         Mockito.`when`(campaign.startDate).thenReturn(startDateString) | ||||
|         Mockito.`when`(campaignResponseDTO.campaigns).thenReturn(campaigns) | ||||
|         verify(okHttpJsonApiClient).campaigns | ||||
|         verify(okHttpJsonApiClient).getCampaigns() | ||||
|         testScheduler.triggerActions() | ||||
|         verify(view).showCampaigns(campaign) | ||||
|     } | ||||
|  |  | |||
|  | @ -1,32 +0,0 @@ | |||
| package fr.free.nrw.commons.filepicker; | ||||
| 
 | ||||
| import android.database.Cursor; | ||||
| import android.database.MatrixCursor; | ||||
| import android.net.Uri; | ||||
| import android.provider.OpenableColumns; | ||||
| import androidx.core.content.FileProvider; | ||||
| import org.robolectric.annotation.Implementation; | ||||
| import org.robolectric.annotation.Implements; | ||||
| 
 | ||||
| @Implements(FileProvider.class) | ||||
| public class ShadowFileProvider { | ||||
| 
 | ||||
|     @Implementation | ||||
|     public Cursor query(final Uri uri, final String[] projection, final String selection, | ||||
|         final String[] selectionArgs, | ||||
|         final String sortOrder) { | ||||
| 
 | ||||
|         if (uri == null) { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         final String[] columns = {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}; | ||||
|         final Object[] values = {"dummy", 500}; | ||||
|         final MatrixCursor cursor = new MatrixCursor(columns, 1); | ||||
| 
 | ||||
|         if (!uri.equals(Uri.EMPTY)) { | ||||
|             cursor.addRow(values); | ||||
|         } | ||||
|         return cursor; | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,36 @@ | |||
| package fr.free.nrw.commons.filepicker | ||||
| 
 | ||||
| import android.database.Cursor | ||||
| import android.database.MatrixCursor | ||||
| import android.net.Uri | ||||
| import android.provider.OpenableColumns | ||||
| import androidx.core.content.FileProvider | ||||
| import org.robolectric.annotation.Implementation | ||||
| import org.robolectric.annotation.Implements | ||||
| 
 | ||||
| @Implements(FileProvider::class) | ||||
| class ShadowFileProvider { | ||||
| 
 | ||||
|     @Implementation | ||||
|     fun query( | ||||
|         uri: Uri?, | ||||
|         projection: Array<String>?, | ||||
|         selection: String?, | ||||
|         selectionArgs: Array<String>?, | ||||
|         sortOrder: String? | ||||
|     ): Cursor? { | ||||
| 
 | ||||
|         if (uri == null) { | ||||
|             return null | ||||
|         } | ||||
| 
 | ||||
|         val columns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) | ||||
|         val values = arrayOf<Any>("dummy", 500) | ||||
|         val cursor = MatrixCursor(columns, 1) | ||||
| 
 | ||||
|         if (uri != Uri.EMPTY) { | ||||
|             cursor.addRow(values) | ||||
|         } | ||||
|         return cursor | ||||
|     } | ||||
| } | ||||
|  | @ -30,8 +30,7 @@ class UserClientTest { | |||
| 
 | ||||
|     @Test | ||||
|     fun isUserBlockedFromCommonsForInfinitelyBlockedUser() { | ||||
|         val userInfo = Mockito.mock(UserInfo::class.java) | ||||
|         Mockito.`when`(userInfo.blockexpiry()).thenReturn("infinite") | ||||
|         val userInfo = UserInfo(blockexpiry = "infinite") | ||||
|         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) | ||||
|         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) | ||||
|         val mockResponse = Mockito.mock(MwQueryResponse::class.java) | ||||
|  | @ -49,8 +48,7 @@ class UserClientTest { | |||
|         val currentDate = Date() | ||||
|         val expiredDate = Date(currentDate.time + 10000) | ||||
| 
 | ||||
|         val userInfo = Mockito.mock(UserInfo::class.java) | ||||
|         Mockito.`when`(userInfo.blockexpiry()).thenReturn(DateUtil.iso8601DateFormat(expiredDate)) | ||||
|         val userInfo = UserInfo(blockexpiry = DateUtil.iso8601DateFormat(expiredDate)) | ||||
|         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) | ||||
|         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) | ||||
|         val mockResponse = Mockito.mock(MwQueryResponse::class.java) | ||||
|  | @ -65,8 +63,7 @@ class UserClientTest { | |||
| 
 | ||||
|     @Test | ||||
|     fun isUserBlockedFromCommonsForNeverBlockedUser() { | ||||
|         val userInfo = Mockito.mock(UserInfo::class.java) | ||||
|         Mockito.`when`(userInfo.blockexpiry()).thenReturn("") | ||||
|         val userInfo = UserInfo(blockexpiry = "") | ||||
|         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) | ||||
|         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) | ||||
|         val mockResponse = Mockito.mock(MwQueryResponse::class.java) | ||||
|  |  | |||
|  | @ -325,7 +325,7 @@ class NearbyParentFragmentUnitTest { | |||
|     @Throws(Exception::class) | ||||
|     fun testOnDestroy() { | ||||
|         fragment.onDestroy() | ||||
|         verify(wikidataEditListener).setAuthenticationStateListener(null) | ||||
|         verify(wikidataEditListener).authenticationStateListener = null | ||||
|     } | ||||
| 
 | ||||
|     @Test @Ignore | ||||
|  |  | |||
|  | @ -120,25 +120,15 @@ class NotificationClientTest { | |||
|     ) = Notification().apply { | ||||
|         setId(notificationId) | ||||
| 
 | ||||
|         setTimestamp( | ||||
|             Notification.Timestamp().apply { | ||||
|                 setUtciso8601(timestamp) | ||||
|             }, | ||||
|         ) | ||||
|         setTimestamp(Notification.Timestamp().apply { setUtciso8601(timestamp) }) | ||||
| 
 | ||||
|         contents = | ||||
|             Notification.Contents().apply { | ||||
|         contents = Notification.Contents().apply { | ||||
|             setCompactHeader(compactHeader) | ||||
| 
 | ||||
|                 links = | ||||
|                     Notification.Links().apply { | ||||
|                         setPrimary( | ||||
|                             GsonUtil.getDefaultGson().toJsonTree( | ||||
|                                 Notification.Link().apply { | ||||
|                                     setUrl(primaryUrl) | ||||
|                                 }, | ||||
|                             ), | ||||
|                         ) | ||||
|             links = Notification.Links().apply { | ||||
|                 setPrimary(GsonUtil.defaultGson.toJsonTree( | ||||
|                     Notification.Link().apply { setUrl(primaryUrl) } | ||||
|                 )) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  |  | |||
|  | @ -79,7 +79,7 @@ class AchievementsFragmentUnitTests { | |||
|         fragmentTransaction.commitNowAllowingStateLoss() | ||||
| 
 | ||||
|         layoutInflater = LayoutInflater.from(activity) | ||||
|         view = fragment.onCreateView(layoutInflater, activity.findViewById(R.id.container), null)!! | ||||
|         view = fragment.onCreateView(layoutInflater, activity.findViewById(R.id.container), null) | ||||
| 
 | ||||
|         achievements = Achievements(0, 0, 0, 0, 0, 0, 0) | ||||
|     } | ||||
|  |  | |||
|  | @ -62,7 +62,7 @@ class UploadPresenterTest { | |||
|         `when`(repository.buildContributions()).thenReturn(Observable.just(contribution)) | ||||
|         uploadableFiles.add(uploadableFile) | ||||
|         `when`(view.uploadableFiles).thenReturn(uploadableFiles) | ||||
|         `when`(uploadableFile.filePath).thenReturn("data://test") | ||||
|         `when`(uploadableFile.getFilePath()).thenReturn("data://test") | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Neel Doshi
						Neel Doshi