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.jakewharton.timber:timber:4.7.1' | ||||||
|     implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0' |     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 'com.karumi:dexter:5.0.0' | ||||||
|     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' |     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -105,7 +105,7 @@ class AboutActivityTest { | ||||||
|     fun testLaunchTranslate() { |     fun testLaunchTranslate() { | ||||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click()) |         Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click()) | ||||||
|         Espresso.onView(ViewMatchers.withId(android.R.id.button1)).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( |         Intents.intended( | ||||||
|             CoreMatchers.allOf( |             CoreMatchers.allOf( | ||||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), |                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||||
|  |  | ||||||
|  | @ -52,12 +52,12 @@ class CampaignsPresenter @Inject constructor( | ||||||
|                 return |                 return | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             okHttpJsonApiClient.campaigns |             okHttpJsonApiClient.getCampaigns() | ||||||
|                 .observeOn(mainThreadScheduler) |                 .observeOn(mainThreadScheduler) | ||||||
|                 .subscribeOn(ioScheduler) |                 .subscribeOn(ioScheduler) | ||||||
|                 .doOnSubscribe { disposable = it } |                 .doOnSubscribe { disposable = it } | ||||||
|                 .subscribe({ campaignResponseDTO -> |                 .subscribe({ campaignResponseDTO -> | ||||||
|                     val campaigns = campaignResponseDTO.campaigns?.toMutableList() |                     val campaigns = campaignResponseDTO?.campaigns?.toMutableList() | ||||||
|                     if (campaigns.isNullOrEmpty()) { |                     if (campaigns.isNullOrEmpty()) { | ||||||
|                         Timber.e("The campaigns list is empty") |                         Timber.e("The campaigns list is empty") | ||||||
|                         view!!.showCampaigns(null) |                         view!!.showCampaigns(null) | ||||||
|  |  | ||||||
|  | @ -44,7 +44,6 @@ import okhttp3.logging.HttpLoggingInterceptor | ||||||
| import okhttp3.logging.HttpLoggingInterceptor.Level | import okhttp3.logging.HttpLoggingInterceptor.Level | ||||||
| import timber.log.Timber | import timber.log.Timber | ||||||
| import java.io.File | import java.io.File | ||||||
| import java.util.Locale |  | ||||||
| import java.util.concurrent.TimeUnit | import java.util.concurrent.TimeUnit | ||||||
| import javax.inject.Named | import javax.inject.Named | ||||||
| import javax.inject.Singleton | import javax.inject.Singleton | ||||||
|  | @ -170,14 +169,13 @@ class NetworkingModule { | ||||||
|     @Named(NAMED_WIKI_DATA_WIKI_SITE) |     @Named(NAMED_WIKI_DATA_WIKI_SITE) | ||||||
|     fun provideWikidataWikiSite(): WikiSite = WikiSite(BuildConfig.WIKIDATA_URL) |     fun provideWikidataWikiSite(): WikiSite = WikiSite(BuildConfig.WIKIDATA_URL) | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|     /** |     /** | ||||||
|      * Gson objects are very heavy. The app should ideally be using just one instance of it instead of creating new instances everywhere. |      * Gson objects are very heavy. The app should ideally be using just one instance of it instead of creating new instances everywhere. | ||||||
|      * @return returns a singleton Gson instance |      * @return returns a singleton Gson instance | ||||||
|      */ |      */ | ||||||
|     @Provides |     @Provides | ||||||
|     @Singleton |     @Singleton | ||||||
|     fun provideGson(): Gson = GsonUtil.getDefaultGson() |     fun provideGson(): Gson = GsonUtil.defaultGson | ||||||
| 
 | 
 | ||||||
|     @Provides |     @Provides | ||||||
|     @Singleton |     @Singleton | ||||||
|  | @ -294,9 +292,8 @@ class NetworkingModule { | ||||||
|     @Provides |     @Provides | ||||||
|     @Singleton |     @Singleton | ||||||
|     @Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) |     @Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) | ||||||
|     fun provideLanguageWikipediaSite(): WikiSite { |     fun provideLanguageWikipediaSite(): WikiSite = | ||||||
|         return WikiSite.forLanguageCode(Locale.getDefault().language) |         WikiSite.forDefaultLocaleLanguageCode() | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     companion object { |     companion object { | ||||||
|         private const val WIKIDATA_SPARQL_QUERY_URL = "https://query.wikidata.org/sparql" |         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.content.Intent | ||||||
| import android.graphics.BitmapFactory | import android.graphics.BitmapFactory | ||||||
| import android.graphics.Matrix | import android.graphics.Matrix | ||||||
|  | //noinspection ExifInterface TODO Issue : #5994 | ||||||
| import android.media.ExifInterface | import android.media.ExifInterface | ||||||
| import android.os.Bundle | import android.os.Bundle | ||||||
| import android.util.Log |  | ||||||
| import android.view.animation.AccelerateDecelerateInterpolator | import android.view.animation.AccelerateDecelerateInterpolator | ||||||
| import android.widget.ImageView | import android.widget.ImageView | ||||||
| import android.widget.Toast | import android.widget.Toast | ||||||
|  | @ -20,6 +20,7 @@ import androidx.lifecycle.ViewModelProvider | ||||||
| import fr.free.nrw.commons.databinding.ActivityEditBinding | import fr.free.nrw.commons.databinding.ActivityEditBinding | ||||||
| import timber.log.Timber | import timber.log.Timber | ||||||
| import java.io.File | import java.io.File | ||||||
|  | import kotlin.math.ceil | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * An activity class for editing and rotating images using LLJTran with EXIF attribute preservation. |  * An activity class for editing and rotating images using LLJTran with EXIF attribute preservation. | ||||||
|  | @ -42,8 +43,11 @@ class EditActivity : AppCompatActivity() { | ||||||
|         supportActionBar?.title = "" |         supportActionBar?.title = "" | ||||||
|         val intent = intent |         val intent = intent | ||||||
|         imageUri = intent.getStringExtra("image") ?: "" |         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) } |         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 = |         val exifTags = | ||||||
|             arrayOf( |             arrayOf( | ||||||
|                 ExifInterface.TAG_APERTURE, |                 ExifInterface.TAG_APERTURE, | ||||||
|  | @ -88,38 +92,36 @@ class EditActivity : AppCompatActivity() { | ||||||
|     private fun init() { |     private fun init() { | ||||||
|         binding.iv.adjustViewBounds = true |         binding.iv.adjustViewBounds = true | ||||||
|         binding.iv.scaleType = ImageView.ScaleType.MATRIX |         binding.iv.scaleType = ImageView.ScaleType.MATRIX | ||||||
|         binding.iv.post( |         binding.iv.post { | ||||||
|             Runnable { |             val options = BitmapFactory.Options() | ||||||
|                 val options = BitmapFactory.Options() |             options.inJustDecodeBounds = true | ||||||
|                 options.inJustDecodeBounds = true |             BitmapFactory.decodeFile(imageUri, options) | ||||||
|                 BitmapFactory.decodeFile(imageUri, options) |  | ||||||
| 
 | 
 | ||||||
|                 val bitmapWidth = options.outWidth |             val bitmapWidth = options.outWidth | ||||||
|                 val bitmapHeight = options.outHeight |             val bitmapHeight = options.outHeight | ||||||
| 
 | 
 | ||||||
|                 // Check if the bitmap dimensions exceed a certain threshold |             // Check if the bitmap dimensions exceed a certain threshold | ||||||
|                 val maxBitmapSize = 2000 // Set your maximum size here |             val maxBitmapSize = 2000 // Set your maximum size here | ||||||
|                 if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) { |             if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) { | ||||||
|                     val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize) |                 val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize) | ||||||
|                     options.inSampleSize = scaleFactor |                 options.inSampleSize = scaleFactor | ||||||
|                     options.inJustDecodeBounds = false |                 options.inJustDecodeBounds = false | ||||||
|                     val scaledBitmap = BitmapFactory.decodeFile(imageUri, options) |                 val scaledBitmap = BitmapFactory.decodeFile(imageUri, options) | ||||||
|                     binding.iv.setImageBitmap(scaledBitmap) |                 binding.iv.setImageBitmap(scaledBitmap) | ||||||
|                     // Update the ImageView with the scaled bitmap |                 // Update the ImageView with the scaled bitmap | ||||||
|                     val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat() |                 val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat() | ||||||
|                     binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt() |                 binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt() | ||||||
|                     binding.iv.imageMatrix = scaleMatrix(scale, scale) |                 binding.iv.imageMatrix = scaleMatrix(scale, scale) | ||||||
|                 } else { |             } else { | ||||||
|                     options.inJustDecodeBounds = false |                 options.inJustDecodeBounds = false | ||||||
|                     val bitmap = BitmapFactory.decodeFile(imageUri, options) |                 val bitmap = BitmapFactory.decodeFile(imageUri, options) | ||||||
|                     binding.iv.setImageBitmap(bitmap) |                 binding.iv.setImageBitmap(bitmap) | ||||||
| 
 | 
 | ||||||
|                     val scale = binding.iv.measuredWidth.toFloat() / bitmapWidth.toFloat() |                 val scale = binding.iv.measuredWidth.toFloat() / bitmapWidth.toFloat() | ||||||
|                     binding.iv.layoutParams.height = (scale * bitmapHeight).toInt() |                 binding.iv.layoutParams.height = (scale * bitmapHeight).toInt() | ||||||
|                     binding.iv.imageMatrix = scaleMatrix(scale, scale) |                 binding.iv.imageMatrix = scaleMatrix(scale, scale) | ||||||
|                 } |             } | ||||||
|             }, |         } | ||||||
|         ) |  | ||||||
|         binding.rotateBtn.setOnClickListener { |         binding.rotateBtn.setOnClickListener { | ||||||
|             animateImageHeight() |             animateImageHeight() | ||||||
|         } |         } | ||||||
|  | @ -143,15 +145,15 @@ class EditActivity : AppCompatActivity() { | ||||||
|         val drawableWidth: Float = |         val drawableWidth: Float = | ||||||
|             binding.iv |             binding.iv | ||||||
|                 .getDrawable() |                 .getDrawable() | ||||||
|                 .getIntrinsicWidth() |                 .intrinsicWidth | ||||||
|                 .toFloat() |                 .toFloat() | ||||||
|         val drawableHeight: Float = |         val drawableHeight: Float = | ||||||
|             binding.iv |             binding.iv | ||||||
|                 .getDrawable() |                 .getDrawable() | ||||||
|                 .getIntrinsicHeight() |                 .intrinsicHeight | ||||||
|                 .toFloat() |                 .toFloat() | ||||||
|         val viewWidth: Float = binding.iv.getMeasuredWidth().toFloat() |         val viewWidth: Float = binding.iv.measuredWidth.toFloat() | ||||||
|         val viewHeight: Float = binding.iv.getMeasuredHeight().toFloat() |         val viewHeight: Float = binding.iv.measuredHeight.toFloat() | ||||||
|         val rotation = imageRotation % 360 |         val rotation = imageRotation % 360 | ||||||
|         val newRotation = rotation + 90 |         val newRotation = rotation + 90 | ||||||
| 
 | 
 | ||||||
|  | @ -162,16 +164,23 @@ class EditActivity : AppCompatActivity() { | ||||||
|         Timber.d("Rotation $rotation") |         Timber.d("Rotation $rotation") | ||||||
|         Timber.d("new Rotation $newRotation") |         Timber.d("new Rotation $newRotation") | ||||||
| 
 | 
 | ||||||
|         if (rotation == 0 || rotation == 180) { |         when (rotation) { | ||||||
|             imageScale = viewWidth / drawableWidth |             0, 180 -> { | ||||||
|             newImageScale = viewWidth / drawableHeight |                 imageScale = viewWidth / drawableWidth | ||||||
|             newViewHeight = (drawableWidth * newImageScale).toInt() |                 newImageScale = viewWidth / drawableHeight | ||||||
|         } else if (rotation == 90 || rotation == 270) { |                 newViewHeight = (drawableWidth * newImageScale).toInt() | ||||||
|             imageScale = viewWidth / drawableHeight |             } | ||||||
|             newImageScale = viewWidth / drawableWidth |             90, 270 -> { | ||||||
|             newViewHeight = (drawableHeight * newImageScale).toInt() |                 imageScale = viewWidth / drawableHeight | ||||||
|         } else { |                 newImageScale = viewWidth / drawableWidth | ||||||
|             throw UnsupportedOperationException("rotation can 0, 90, 180 or 270. \${rotation} is unsupported") |                 newViewHeight = (drawableHeight * newImageScale).toInt() | ||||||
|  |             } | ||||||
|  |             else -> { | ||||||
|  |                 throw | ||||||
|  |                 UnsupportedOperationException( | ||||||
|  |                     "rotation can 0, 90, 180 or 270. \${rotation} is unsupported" | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L) |         val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L) | ||||||
|  | @ -204,7 +213,7 @@ class EditActivity : AppCompatActivity() { | ||||||
|                 (complementaryAnimVal * viewHeight + animVal * newViewHeight).toInt() |                 (complementaryAnimVal * viewHeight + animVal * newViewHeight).toInt() | ||||||
|             val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale |             val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale | ||||||
|             val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation |             val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation | ||||||
|             binding.iv.getLayoutParams().height = animatedHeight |             binding.iv.layoutParams.height = animatedHeight | ||||||
|             val matrix: Matrix = |             val matrix: Matrix = | ||||||
|                 rotationMatrix( |                 rotationMatrix( | ||||||
|                     animatedRotation, |                     animatedRotation, | ||||||
|  | @ -218,8 +227,8 @@ class EditActivity : AppCompatActivity() { | ||||||
|                 drawableHeight / 2, |                 drawableHeight / 2, | ||||||
|             ) |             ) | ||||||
|             matrix.postTranslate( |             matrix.postTranslate( | ||||||
|                 -(drawableWidth - binding.iv.getMeasuredWidth()) / 2, |                 -(drawableWidth - binding.iv.measuredWidth) / 2, | ||||||
|                 -(drawableHeight - binding.iv.getMeasuredHeight()) / 2, |                 -(drawableHeight - binding.iv.measuredHeight) / 2, | ||||||
|             ) |             ) | ||||||
|             binding.iv.setImageMatrix(matrix) |             binding.iv.setImageMatrix(matrix) | ||||||
|             binding.iv.requestLayout() |             binding.iv.requestLayout() | ||||||
|  | @ -267,9 +276,9 @@ class EditActivity : AppCompatActivity() { | ||||||
|      */ |      */ | ||||||
|     private fun copyExifData(editedImageExif: ExifInterface?) { |     private fun copyExifData(editedImageExif: ExifInterface?) { | ||||||
|         for (attr in sourceExifAttributeList) { |         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) |             editedImageExif!!.setAttribute(attr.first, attr.second) | ||||||
|             Log.d("Tag is ${attr.first}", "Value is ${attr.second}") |             Timber.d("Value is ${attr.second}") | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         editedImageExif?.saveAttributes() |         editedImageExif?.saveAttributes() | ||||||
|  | @ -298,9 +307,10 @@ class EditActivity : AppCompatActivity() { | ||||||
|         var scaleFactor = 1 |         var scaleFactor = 1 | ||||||
| 
 | 
 | ||||||
|         if (originalWidth > maxSize || originalHeight > maxSize) { |         if (originalWidth > maxSize || originalHeight > maxSize) { | ||||||
|             // Calculate the largest power of 2 that is less than or equal to the desired width and height |             // Calculate the largest power of 2 that is less than or equal to the desired | ||||||
|             val widthRatio = Math.ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt() |             // width and height | ||||||
|             val heightRatio = Math.ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt() |             val widthRatio = ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt() | ||||||
|  |             val heightRatio = ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt() | ||||||
| 
 | 
 | ||||||
|             scaleFactor = if (widthRatio > heightRatio) widthRatio else heightRatio |             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() { |     private fun setUserName() { | ||||||
|         val store = BasicKvStore(requireContext(), getUserName()) |         val store = BasicKvStore(requireContext(), getUserName()) | ||||||
|         val level = store.getString("userAchievementsLevel", "0") |         val level = store.getString("userAchievementsLevel", "0") | ||||||
|         binding?.moreProfile?.text = if (level == "0") { |         if (level == "0"){ | ||||||
|             "${getUserName()} (${getString(R.string.see_your_achievements)})" |             binding?.moreProfile?.text = getString( | ||||||
|  |                 R.string.profileLevel, | ||||||
|  |                 getUserName(), | ||||||
|  |                 getString(R.string.see_your_achievements) // Second argument | ||||||
|  |             ) | ||||||
|         } else { |         } 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.core.content.FileProvider; | ||||||
| import androidx.fragment.app.Fragment; | import androidx.fragment.app.Fragment; | ||||||
| import androidx.fragment.app.FragmentManager; | import androidx.fragment.app.FragmentManager; | ||||||
| import com.google.android.material.tabs.TabLayout; |  | ||||||
| import fr.free.nrw.commons.R; | import fr.free.nrw.commons.R; | ||||||
| import fr.free.nrw.commons.Utils; | import fr.free.nrw.commons.Utils; | ||||||
| import fr.free.nrw.commons.ViewPagerAdapter; | 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.ContributionController | ||||||
| import fr.free.nrw.commons.contributions.MainActivity | import fr.free.nrw.commons.contributions.MainActivity | ||||||
| import fr.free.nrw.commons.di.ApplicationlessInjection | import fr.free.nrw.commons.di.ApplicationlessInjection | ||||||
|  | import fr.free.nrw.commons.filepicker.FilePicker | ||||||
| import fr.free.nrw.commons.kvstore.JsonKvStore | import fr.free.nrw.commons.kvstore.JsonKvStore | ||||||
| import fr.free.nrw.commons.location.LocationServiceManager | import fr.free.nrw.commons.location.LocationServiceManager | ||||||
| import fr.free.nrw.commons.logging.CommonsLogSender | import fr.free.nrw.commons.logging.CommonsLogSender | ||||||
|  | @ -83,9 +84,17 @@ class SettingsFragment : PreferenceFragmentCompat() { | ||||||
| 
 | 
 | ||||||
|     private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> = |     private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> = | ||||||
|         registerForActivityResult(StartActivityForResult()) { result -> |         registerForActivityResult(StartActivityForResult()) { result -> | ||||||
|         contributionController.handleActivityResultWithCallback(requireActivity()) { callbacks -> |         contributionController.handleActivityResultWithCallback( | ||||||
|             contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks) |             requireActivity(), | ||||||
|         } |             object: FilePicker.HandleActivityResult { | ||||||
|  |                 override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) { | ||||||
|  |                     contributionController.onPictureReturnedFromCamera( | ||||||
|  |                         result, | ||||||
|  |                         requireActivity(), | ||||||
|  |                         callbacks | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |         }) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  |  | ||||||
|  | @ -194,7 +194,7 @@ class FileProcessor | ||||||
|             requireNotNull(imageCoordinates.decimalCoords) |             requireNotNull(imageCoordinates.decimalCoords) | ||||||
|             compositeDisposable.add( |             compositeDisposable.add( | ||||||
|                 apiCall |                 apiCall | ||||||
|                     .request(imageCoordinates.decimalCoords) |                     .request(imageCoordinates.decimalCoords!!) | ||||||
|                     .subscribeOn(Schedulers.io()) |                     .subscribeOn(Schedulers.io()) | ||||||
|                     .observeOn(Schedulers.io()) |                     .observeOn(Schedulers.io()) | ||||||
|                     .subscribe( |                     .subscribe( | ||||||
|  | @ -220,7 +220,7 @@ class FileProcessor | ||||||
|                 .concatMap { |                 .concatMap { | ||||||
|                     Observable.fromCallable { |                     Observable.fromCallable { | ||||||
|                         okHttpJsonApiClient.getNearbyPlaces( |                         okHttpJsonApiClient.getNearbyPlaces( | ||||||
|                             imageCoordinates.latLng, |                             imageCoordinates.latLng!!, | ||||||
|                             Locale.getDefault().language, |                             Locale.getDefault().language, | ||||||
|                             it, |                             it, | ||||||
|                         ) |                         ) | ||||||
|  |  | ||||||
|  | @ -496,14 +496,14 @@ class UploadWorker( | ||||||
| 
 | 
 | ||||||
|                 withContext(Dispatchers.Main) { |                 withContext(Dispatchers.Main) { | ||||||
|                     wikidataEditService.handleImageClaimResult( |                     wikidataEditService.handleImageClaimResult( | ||||||
|                         contribution.wikidataPlace, |                         contribution.wikidataPlace!!, | ||||||
|                         revisionID, |                         revisionID, | ||||||
|                     ) |                     ) | ||||||
|                 } |                 } | ||||||
|             } else { |             } else { | ||||||
|                 withContext(Dispatchers.Main) { |                 withContext(Dispatchers.Main) { | ||||||
|                     wikidataEditService.handleImageClaimResult( |                     wikidataEditService.handleImageClaimResult( | ||||||
|                         contribution.wikidataPlace, |                         contribution.wikidataPlace!!, | ||||||
|                         null, |                         null, | ||||||
|                     ) |                     ) | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  | @ -63,7 +63,7 @@ class CustomSelectorUtils { | ||||||
|                 fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) |                 fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) | ||||||
|                 val sha1 = |                 val sha1 = | ||||||
|                     fileUtilsWrapper.getSHA1( |                     fileUtilsWrapper.getSHA1( | ||||||
|                         fileUtilsWrapper.getFileInputStream(uploadableFile.filePath), |                         fileUtilsWrapper.getFileInputStream(uploadableFile.getFilePath()), | ||||||
|                     ) |                     ) | ||||||
|                 uploadableFile.file.delete() |                 uploadableFile.file.delete() | ||||||
|                 sha1 |                 sha1 | ||||||
|  |  | ||||||
|  | @ -10,11 +10,10 @@ class CommonsServiceFactory( | ||||||
| ) { | ) { | ||||||
|     val builder: Retrofit.Builder by lazy { |     val builder: Retrofit.Builder by lazy { | ||||||
|         // All instances of retrofit share this configuration, but create it lazily |         // All instances of retrofit share this configuration, but create it lazily | ||||||
|         Retrofit |         Retrofit.Builder() | ||||||
|             .Builder() |  | ||||||
|             .client(okHttpClient) |             .client(okHttpClient) | ||||||
|             .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) |             .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) | ||||||
|             .addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson())) |             .addConverterFactory(GsonConverterFactory.create(GsonUtil.defaultGson)) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     val retrofitCache: MutableMap<String, Retrofit> = mutableMapOf() |     val retrofitCache: MutableMap<String, Retrofit> = mutableMapOf() | ||||||
|  |  | ||||||
|  | @ -1,34 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata; |  | ||||||
| 
 |  | ||||||
| import android.net.Uri; |  | ||||||
| import com.google.gson.Gson; |  | ||||||
| import com.google.gson.GsonBuilder; |  | ||||||
| import fr.free.nrw.commons.wikidata.json.RequiredFieldsCheckOnReadTypeAdapterFactory; |  | ||||||
| import fr.free.nrw.commons.wikidata.model.DataValue; |  | ||||||
| import fr.free.nrw.commons.wikidata.model.WikiSite; |  | ||||||
| import fr.free.nrw.commons.wikidata.json.NamespaceTypeAdapter; |  | ||||||
| import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter; |  | ||||||
| import fr.free.nrw.commons.wikidata.json.UriTypeAdapter; |  | ||||||
| import fr.free.nrw.commons.wikidata.json.WikiSiteTypeAdapter; |  | ||||||
| import fr.free.nrw.commons.wikidata.model.page.Namespace; |  | ||||||
| 
 |  | ||||||
| public final class GsonUtil { |  | ||||||
|     private static final String DATE_FORMAT = "MMM dd, yyyy HH:mm:ss"; |  | ||||||
| 
 |  | ||||||
|     private static final GsonBuilder DEFAULT_GSON_BUILDER = new GsonBuilder() |  | ||||||
|             .setDateFormat(DATE_FORMAT) |  | ||||||
|             .registerTypeAdapterFactory(DataValue.getPolymorphicTypeAdapter()) |  | ||||||
|             .registerTypeHierarchyAdapter(Uri.class, new UriTypeAdapter().nullSafe()) |  | ||||||
|             .registerTypeHierarchyAdapter(Namespace.class, new NamespaceTypeAdapter().nullSafe()) |  | ||||||
|             .registerTypeAdapter(WikiSite.class, new WikiSiteTypeAdapter().nullSafe()) |  | ||||||
|             .registerTypeAdapterFactory(new RequiredFieldsCheckOnReadTypeAdapterFactory()) |  | ||||||
|             .registerTypeAdapterFactory(new PostProcessingTypeAdapter()); |  | ||||||
| 
 |  | ||||||
|     private static final Gson DEFAULT_GSON = DEFAULT_GSON_BUILDER.create(); |  | ||||||
| 
 |  | ||||||
|     public static Gson getDefaultGson() { |  | ||||||
|         return DEFAULT_GSON; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private GsonUtil() { } |  | ||||||
| } |  | ||||||
							
								
								
									
										29
									
								
								app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata | ||||||
|  | 
 | ||||||
|  | import android.net.Uri | ||||||
|  | import com.google.gson.Gson | ||||||
|  | import com.google.gson.GsonBuilder | ||||||
|  | import fr.free.nrw.commons.wikidata.json.NamespaceTypeAdapter | ||||||
|  | import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter | ||||||
|  | import fr.free.nrw.commons.wikidata.json.RequiredFieldsCheckOnReadTypeAdapterFactory | ||||||
|  | import fr.free.nrw.commons.wikidata.json.UriTypeAdapter | ||||||
|  | import fr.free.nrw.commons.wikidata.json.WikiSiteTypeAdapter | ||||||
|  | import fr.free.nrw.commons.wikidata.model.DataValue.Companion.polymorphicTypeAdapter | ||||||
|  | import fr.free.nrw.commons.wikidata.model.WikiSite | ||||||
|  | import fr.free.nrw.commons.wikidata.model.page.Namespace | ||||||
|  | 
 | ||||||
|  | object GsonUtil { | ||||||
|  |     private const val DATE_FORMAT = "MMM dd, yyyy HH:mm:ss" | ||||||
|  | 
 | ||||||
|  |     private val DEFAULT_GSON_BUILDER: GsonBuilder by lazy { | ||||||
|  |         GsonBuilder().setDateFormat(DATE_FORMAT) | ||||||
|  |             .registerTypeAdapterFactory(polymorphicTypeAdapter) | ||||||
|  |             .registerTypeHierarchyAdapter(Uri::class.java, UriTypeAdapter().nullSafe()) | ||||||
|  |             .registerTypeHierarchyAdapter(Namespace::class.java, NamespaceTypeAdapter().nullSafe()) | ||||||
|  |             .registerTypeAdapter(WikiSite::class.java, WikiSiteTypeAdapter().nullSafe()) | ||||||
|  |             .registerTypeAdapterFactory(RequiredFieldsCheckOnReadTypeAdapterFactory()) | ||||||
|  |             .registerTypeAdapterFactory(PostProcessingTypeAdapter()) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     val defaultGson: Gson by lazy { DEFAULT_GSON_BUILDER.create() } | ||||||
|  | } | ||||||
|  | @ -1,11 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata; |  | ||||||
| 
 |  | ||||||
| public class WikidataConstants { |  | ||||||
|     public static final String PLACE_OBJECT = "place"; |  | ||||||
|     public static final String BOOKMARKS_ITEMS = "bookmarks.items"; |  | ||||||
|     public static final String SELECTED_NEARBY_PLACE = "selected.nearby.place"; |  | ||||||
|     public static final String SELECTED_NEARBY_PLACE_CATEGORY = "selected.nearby.place.category"; |  | ||||||
| 
 |  | ||||||
|     public static final String MW_API_PREFIX = "w/api.php?format=json&formatversion=2&errorformat=plaintext&"; |  | ||||||
|     public static final String WIKIPEDIA_URL = "https://wikipedia.org/"; |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,11 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata | ||||||
|  | 
 | ||||||
|  | object WikidataConstants { | ||||||
|  |     const val PLACE_OBJECT: String = "place" | ||||||
|  |     const val BOOKMARKS_ITEMS: String = "bookmarks.items" | ||||||
|  |     const val SELECTED_NEARBY_PLACE: String = "selected.nearby.place" | ||||||
|  |     const val SELECTED_NEARBY_PLACE_CATEGORY: String = "selected.nearby.place.category" | ||||||
|  | 
 | ||||||
|  |     const val MW_API_PREFIX: String = "w/api.php?format=json&formatversion=2&errorformat=plaintext&" | ||||||
|  |     const val WIKIPEDIA_URL: String = "https://wikipedia.org/" | ||||||
|  | } | ||||||
|  | @ -1,16 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata; |  | ||||||
| 
 |  | ||||||
| public abstract class WikidataEditListener { |  | ||||||
| 
 |  | ||||||
|     protected WikidataP18EditListener wikidataP18EditListener; |  | ||||||
| 
 |  | ||||||
|     public abstract void onSuccessfulWikidataEdit(); |  | ||||||
| 
 |  | ||||||
|     public void setAuthenticationStateListener(WikidataP18EditListener wikidataP18EditListener) { |  | ||||||
|         this.wikidataP18EditListener = wikidataP18EditListener; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public interface WikidataP18EditListener { |  | ||||||
|         void onWikidataEditSuccessful(); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,11 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata | ||||||
|  | 
 | ||||||
|  | abstract class WikidataEditListener { | ||||||
|  |     var authenticationStateListener: WikidataP18EditListener? = null | ||||||
|  | 
 | ||||||
|  |     abstract fun onSuccessfulWikidataEdit() | ||||||
|  | 
 | ||||||
|  |     interface WikidataP18EditListener { | ||||||
|  |         fun onWikidataEditSuccessful() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,20 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Listener for wikidata edits |  | ||||||
|  */ |  | ||||||
| public class WikidataEditListenerImpl extends WikidataEditListener { |  | ||||||
| 
 |  | ||||||
|     public WikidataEditListenerImpl() { |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Fired when wikidata P18 edit is successful. If there's an active listener, then it is fired |  | ||||||
|      */ |  | ||||||
|     @Override |  | ||||||
|     public void onSuccessfulWikidataEdit() { |  | ||||||
|         if (wikidataP18EditListener != null) { |  | ||||||
|             wikidataP18EditListener.onWikidataEditSuccessful(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,13 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Listener for wikidata edits | ||||||
|  |  */ | ||||||
|  | class WikidataEditListenerImpl : WikidataEditListener() { | ||||||
|  |     /** | ||||||
|  |      * Fired when wikidata P18 edit is successful. If there's an active listener, then it is fired | ||||||
|  |      */ | ||||||
|  |     override fun onSuccessfulWikidataEdit() { | ||||||
|  |         authenticationStateListener?.onWikidataEditSuccessful() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,271 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata; |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| import static fr.free.nrw.commons.media.MediaClientKt.PAGE_ID_PREFIX; |  | ||||||
| 
 |  | ||||||
| import android.annotation.SuppressLint; |  | ||||||
| import android.content.Context; |  | ||||||
| import androidx.annotation.Nullable; |  | ||||||
| import com.google.gson.Gson; |  | ||||||
| import fr.free.nrw.commons.R; |  | ||||||
| import fr.free.nrw.commons.contributions.Contribution; |  | ||||||
| import fr.free.nrw.commons.kvstore.JsonKvStore; |  | ||||||
| import fr.free.nrw.commons.upload.UploadResult; |  | ||||||
| import fr.free.nrw.commons.upload.WikidataItem; |  | ||||||
| import fr.free.nrw.commons.upload.WikidataPlace; |  | ||||||
| import fr.free.nrw.commons.utils.ConfigUtils; |  | ||||||
| import fr.free.nrw.commons.utils.ViewUtil; |  | ||||||
| import fr.free.nrw.commons.wikidata.model.DataValue; |  | ||||||
| import fr.free.nrw.commons.wikidata.model.DataValue.ValueString; |  | ||||||
| import fr.free.nrw.commons.wikidata.model.EditClaim; |  | ||||||
| import fr.free.nrw.commons.wikidata.model.RemoveClaim; |  | ||||||
| import fr.free.nrw.commons.wikidata.model.SnakPartial; |  | ||||||
| import fr.free.nrw.commons.wikidata.model.StatementPartial; |  | ||||||
| import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue; |  | ||||||
| import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse; |  | ||||||
| import io.reactivex.Observable; |  | ||||||
| import io.reactivex.schedulers.Schedulers; |  | ||||||
| import java.util.ArrayList; |  | ||||||
| import java.util.Arrays; |  | ||||||
| import java.util.Collections; |  | ||||||
| import java.util.List; |  | ||||||
| import java.util.Locale; |  | ||||||
| import java.util.Map; |  | ||||||
| import java.util.UUID; |  | ||||||
| import javax.inject.Inject; |  | ||||||
| import javax.inject.Named; |  | ||||||
| import javax.inject.Singleton; |  | ||||||
| import timber.log.Timber; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki |  | ||||||
|  * Apis to make the necessary calls, log the edits and fire listeners on successful edits |  | ||||||
|  */ |  | ||||||
| @Singleton |  | ||||||
| public class WikidataEditService { |  | ||||||
| 
 |  | ||||||
|     public static final String COMMONS_APP_TAG = "wikimedia-commons-app"; |  | ||||||
| 
 |  | ||||||
|     private final Context context; |  | ||||||
|     private final WikidataEditListener wikidataEditListener; |  | ||||||
|     private final JsonKvStore directKvStore; |  | ||||||
|     private final WikiBaseClient wikiBaseClient; |  | ||||||
|     private final WikidataClient wikidataClient; |  | ||||||
|     private final Gson gson; |  | ||||||
| 
 |  | ||||||
|     @Inject |  | ||||||
|     public WikidataEditService(final Context context, |  | ||||||
|         final WikidataEditListener wikidataEditListener, |  | ||||||
|         @Named("default_preferences") final JsonKvStore directKvStore, |  | ||||||
|         final WikiBaseClient wikiBaseClient, |  | ||||||
|         final WikidataClient wikidataClient, final Gson gson) { |  | ||||||
|         this.context = context; |  | ||||||
|         this.wikidataEditListener = wikidataEditListener; |  | ||||||
|         this.directKvStore = directKvStore; |  | ||||||
|         this.wikiBaseClient = wikiBaseClient; |  | ||||||
|         this.wikidataClient = wikidataClient; |  | ||||||
|         this.gson = gson; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Edits the wikibase entity by adding DEPICTS property. Adding DEPICTS property requires call |  | ||||||
|      * to the wikibase API to set tag against the entity. |  | ||||||
|      */ |  | ||||||
|     @SuppressLint("CheckResult") |  | ||||||
|     private Observable<Boolean> addDepictsProperty( |  | ||||||
|         final String fileEntityId, |  | ||||||
|         final List<String> depictedItems |  | ||||||
|     ) { |  | ||||||
|         final EditClaim data = editClaim( |  | ||||||
|             ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10") |  | ||||||
|                 // Wikipedia:Sandbox (Q10) |  | ||||||
|                 : depictedItems |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data)) |  | ||||||
|             .doOnNext(success -> { |  | ||||||
|                 if (success) { |  | ||||||
|                     Timber.d("DEPICTS property was set successfully for %s", fileEntityId); |  | ||||||
|                 } else { |  | ||||||
|                     Timber.d("Unable to set DEPICTS property for %s", fileEntityId); |  | ||||||
|                 } |  | ||||||
|             }) |  | ||||||
|             .doOnError(throwable -> { |  | ||||||
|                 Timber.e(throwable, "Error occurred while setting DEPICTS property"); |  | ||||||
|                 ViewUtil.showLongToast(context, throwable.toString()); |  | ||||||
|             }) |  | ||||||
|             .subscribeOn(Schedulers.io()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Takes depicts ID as a parameter and create a uploadable data with the Id |  | ||||||
|      * and send the data for POST operation |  | ||||||
|      * |  | ||||||
|      * @param fileEntityId ID of the file |  | ||||||
|      * @param depictedItems IDs of the selected depict item |  | ||||||
|      * @return Observable<Boolean> |  | ||||||
|      */ |  | ||||||
|     @SuppressLint("CheckResult") |  | ||||||
|     public Observable<Boolean> updateDepictsProperty( |  | ||||||
|         final String fileEntityId, |  | ||||||
|         final List<String> depictedItems |  | ||||||
|     ) { |  | ||||||
|         final String entityId = PAGE_ID_PREFIX + fileEntityId; |  | ||||||
|         final List<String> claimIds = getDepictionsClaimIds(entityId); |  | ||||||
| 
 |  | ||||||
|         final RemoveClaim data = removeClaim( /* Please consider removeClaim scenario for BetaDebug */ |  | ||||||
|             ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10") |  | ||||||
|                 // Wikipedia:Sandbox (Q10) |  | ||||||
|                 : claimIds |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data)) |  | ||||||
|             .doOnError(throwable -> { |  | ||||||
|                 Timber.e(throwable, "Error occurred while removing existing claims for DEPICTS property"); |  | ||||||
|                 ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); |  | ||||||
|             }).switchMap(success-> { |  | ||||||
|                 if(success) { |  | ||||||
|                     Timber.d("DEPICTS property was deleted successfully"); |  | ||||||
|                     return addDepictsProperty(fileEntityId, depictedItems); |  | ||||||
|                 } else { |  | ||||||
|                     Timber.d("Unable to delete DEPICTS property"); |  | ||||||
|                     return Observable.empty(); |  | ||||||
|                 } |  | ||||||
|             }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @SuppressLint("CheckResult") |  | ||||||
|     private List<String> getDepictionsClaimIds(final String entityId) { |  | ||||||
|         return wikiBaseClient.getClaimIdsByProperty(entityId, WikidataProperties.DEPICTS.getPropertyName()) |  | ||||||
|             .subscribeOn(Schedulers.io()) |  | ||||||
|             .blockingFirst(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private EditClaim editClaim(final List<String> entityIds) { |  | ||||||
|         return EditClaim.from(entityIds, WikidataProperties.DEPICTS.getPropertyName()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private RemoveClaim removeClaim(final List<String> claimIds) { |  | ||||||
|         return RemoveClaim.from(claimIds); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Show a success toast when the edit is made successfully |  | ||||||
|      */ |  | ||||||
|     private void showSuccessToast(final String wikiItemName) { |  | ||||||
|         final String successStringTemplate = context.getString(R.string.successful_wikidata_edit); |  | ||||||
|         final String successMessage = String |  | ||||||
|             .format(Locale.getDefault(), successStringTemplate, wikiItemName); |  | ||||||
|         ViewUtil.showLongToast(context, successMessage); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Adds label to Wikidata using the fileEntityId and the edit token, obtained from |  | ||||||
|      * csrfTokenClient |  | ||||||
|      * |  | ||||||
|      * @param fileEntityId |  | ||||||
|      * @return |  | ||||||
|      */ |  | ||||||
|     @SuppressLint("CheckResult") |  | ||||||
|     private Observable<Boolean> addCaption(final long fileEntityId, final String languageCode, |  | ||||||
|         final String captionValue) { |  | ||||||
|         return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue) |  | ||||||
|             .doOnNext(mwPostResponse -> onAddCaptionResponse(fileEntityId, mwPostResponse)) |  | ||||||
|             .doOnError(throwable -> { |  | ||||||
|                 Timber.e(throwable, "Error occurred while setting Captions"); |  | ||||||
|                 ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); |  | ||||||
|             }) |  | ||||||
|             .map(mwPostResponse -> mwPostResponse != null); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private void onAddCaptionResponse(Long fileEntityId, MwPostResponse response) { |  | ||||||
|         if (response != null) { |  | ||||||
|             Timber.d("Caption successfully set, revision id = %s", response); |  | ||||||
|         } else { |  | ||||||
|             Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public Long createClaim(@Nullable final WikidataPlace wikidataPlace, final String fileName, |  | ||||||
|         final Map<String, String> captions) { |  | ||||||
|         if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) { |  | ||||||
|             Timber |  | ||||||
|                 .d("Image location and nearby place location mismatched, so Wikidata item won't be edited"); |  | ||||||
|             return null; |  | ||||||
|         } |  | ||||||
|         return addImageAndMediaLegends(wikidataPlace, fileName, captions); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public Long addImageAndMediaLegends(final WikidataItem wikidataItem, final String fileName, |  | ||||||
|         final Map<String, String> captions) { |  | ||||||
|         final SnakPartial p18 = new SnakPartial("value", |  | ||||||
|             WikidataProperties.IMAGE.getPropertyName(), |  | ||||||
|             new ValueString(fileName.replace("File:", ""))); |  | ||||||
| 
 |  | ||||||
|         final List<SnakPartial> snaks = new ArrayList<>(); |  | ||||||
|         for (final Map.Entry<String, String> entry : captions.entrySet()) { |  | ||||||
|             snaks.add(new SnakPartial("value", |  | ||||||
|                 WikidataProperties.MEDIA_LEGENDS.getPropertyName(), new DataValue.MonoLingualText( |  | ||||||
|                 new WikiBaseMonolingualTextValue(entry.getValue(), entry.getKey())))); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         final String id = wikidataItem.getId() + "$" + UUID.randomUUID().toString(); |  | ||||||
|         final StatementPartial claim = new StatementPartial(p18, "statement", "normal", id, |  | ||||||
|             Collections.singletonMap(WikidataProperties.MEDIA_LEGENDS.getPropertyName(), snaks), |  | ||||||
|             Arrays.asList(WikidataProperties.MEDIA_LEGENDS.getPropertyName())); |  | ||||||
| 
 |  | ||||||
|         return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public void handleImageClaimResult(final WikidataItem wikidataItem, final Long revisionId) { |  | ||||||
|         if (revisionId != null) { |  | ||||||
|             if (wikidataEditListener != null) { |  | ||||||
|                 wikidataEditListener.onSuccessfulWikidataEdit(); |  | ||||||
|             } |  | ||||||
|             showSuccessToast(wikidataItem.getName()); |  | ||||||
|         } else { |  | ||||||
|             Timber.d("Unable to make wiki data edit for entity %s", wikidataItem); |  | ||||||
|             ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public Observable<Boolean> addDepictionsAndCaptions( |  | ||||||
|         final UploadResult uploadResult, |  | ||||||
|         final Contribution contribution |  | ||||||
|     ) { |  | ||||||
|         return wikiBaseClient.getFileEntityId(uploadResult) |  | ||||||
|             .doOnError(throwable -> { |  | ||||||
|                 Timber |  | ||||||
|                     .e(throwable, "Error occurred while getting EntityID to set DEPICTS property"); |  | ||||||
|                 ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); |  | ||||||
|             }) |  | ||||||
|             .switchMap(fileEntityId -> { |  | ||||||
|                     if (fileEntityId != null) { |  | ||||||
|                         Timber.d("EntityId for image was received successfully: %s", fileEntityId); |  | ||||||
|                         return Observable.concat( |  | ||||||
|                             depictionEdits(contribution, fileEntityId), |  | ||||||
|                             captionEdits(contribution, fileEntityId) |  | ||||||
|                         ); |  | ||||||
|                     } else { |  | ||||||
|                         Timber.d("Error acquiring EntityId for image: %s", uploadResult); |  | ||||||
|                         return Observable.empty(); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             ); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private Observable<Boolean> captionEdits(Contribution contribution, Long fileEntityId) { |  | ||||||
|         return Observable.fromIterable(contribution.getMedia().getCaptions().entrySet()) |  | ||||||
|             .concatMap(entry -> addCaption(fileEntityId, entry.getKey(), entry.getValue())); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private Observable<Boolean> depictionEdits(Contribution contribution, Long fileEntityId) { |  | ||||||
|         final List<String> depictIDs = new ArrayList<>(); |  | ||||||
|         for (final WikidataItem wikidataItem : |  | ||||||
|             contribution.getDepictedItems()) { |  | ||||||
|             depictIDs.add(wikidataItem.getId()); |  | ||||||
|         } |  | ||||||
|         return addDepictsProperty(fileEntityId.toString(), depictIDs); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
|  | @ -0,0 +1,252 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata | ||||||
|  | 
 | ||||||
|  | import android.annotation.SuppressLint | ||||||
|  | import android.content.Context | ||||||
|  | import com.google.gson.Gson | ||||||
|  | import fr.free.nrw.commons.R | ||||||
|  | import fr.free.nrw.commons.contributions.Contribution | ||||||
|  | import fr.free.nrw.commons.kvstore.JsonKvStore | ||||||
|  | import fr.free.nrw.commons.media.PAGE_ID_PREFIX | ||||||
|  | import fr.free.nrw.commons.upload.UploadResult | ||||||
|  | import fr.free.nrw.commons.upload.WikidataItem | ||||||
|  | import fr.free.nrw.commons.upload.WikidataPlace | ||||||
|  | import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour | ||||||
|  | import fr.free.nrw.commons.utils.ViewUtil.showLongToast | ||||||
|  | import fr.free.nrw.commons.wikidata.WikidataProperties.DEPICTS | ||||||
|  | import fr.free.nrw.commons.wikidata.WikidataProperties.IMAGE | ||||||
|  | import fr.free.nrw.commons.wikidata.WikidataProperties.MEDIA_LEGENDS | ||||||
|  | import fr.free.nrw.commons.wikidata.model.DataValue.MonoLingualText | ||||||
|  | import fr.free.nrw.commons.wikidata.model.DataValue.ValueString | ||||||
|  | import fr.free.nrw.commons.wikidata.model.EditClaim | ||||||
|  | import fr.free.nrw.commons.wikidata.model.RemoveClaim | ||||||
|  | import fr.free.nrw.commons.wikidata.model.SnakPartial | ||||||
|  | import fr.free.nrw.commons.wikidata.model.StatementPartial | ||||||
|  | import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue | ||||||
|  | import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse | ||||||
|  | import io.reactivex.Observable | ||||||
|  | import io.reactivex.schedulers.Schedulers | ||||||
|  | import timber.log.Timber | ||||||
|  | import java.util.Arrays | ||||||
|  | import java.util.Collections | ||||||
|  | import java.util.Locale | ||||||
|  | import java.util.Objects | ||||||
|  | import java.util.UUID | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Named | ||||||
|  | import javax.inject.Singleton | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki | ||||||
|  |  * Apis to make the necessary calls, log the edits and fire listeners on successful edits | ||||||
|  |  */ | ||||||
|  | @Singleton | ||||||
|  | class WikidataEditService @Inject constructor( | ||||||
|  |     private val context: Context, | ||||||
|  |     private val wikidataEditListener: WikidataEditListener?, | ||||||
|  |     @param:Named("default_preferences") private val directKvStore: JsonKvStore, | ||||||
|  |     private val wikiBaseClient: WikiBaseClient, | ||||||
|  |     private val wikidataClient: WikidataClient, private val gson: Gson | ||||||
|  | ) { | ||||||
|  |     @SuppressLint("CheckResult") | ||||||
|  |     private fun addDepictsProperty( | ||||||
|  |         fileEntityId: String, | ||||||
|  |         depictedItems: List<String> | ||||||
|  |     ): Observable<Boolean> { | ||||||
|  |         val data = EditClaim.from( | ||||||
|  |             if (isBetaFlavour) listOf("Q10") else depictedItems, DEPICTS.propertyName | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data)) | ||||||
|  |             .doOnNext { success: Boolean -> | ||||||
|  |                 if (success) { | ||||||
|  |                     Timber.d("DEPICTS property was set successfully for %s", fileEntityId) | ||||||
|  |                 } else { | ||||||
|  |                     Timber.d("Unable to set DEPICTS property for %s", fileEntityId) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             .doOnError { throwable: Throwable -> | ||||||
|  |                 Timber.e(throwable, "Error occurred while setting DEPICTS property") | ||||||
|  |                 showLongToast(context, throwable.toString()) | ||||||
|  |             } | ||||||
|  |             .subscribeOn(Schedulers.io()) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @SuppressLint("CheckResult") | ||||||
|  |     fun updateDepictsProperty( | ||||||
|  |         fileEntityId: String?, | ||||||
|  |         depictedItems: List<String> | ||||||
|  |     ): Observable<Boolean> { | ||||||
|  |         val entityId: String = PAGE_ID_PREFIX + fileEntityId | ||||||
|  |         val claimIds = getDepictionsClaimIds(entityId) | ||||||
|  | 
 | ||||||
|  |         /* Please consider removeClaim scenario for BetaDebug */ | ||||||
|  |         val data = RemoveClaim.from(if (isBetaFlavour) listOf("Q10") else claimIds) | ||||||
|  | 
 | ||||||
|  |         return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data)) | ||||||
|  |             .doOnError { throwable: Throwable? -> | ||||||
|  |                 Timber.e( | ||||||
|  |                     throwable, | ||||||
|  |                     "Error occurred while removing existing claims for DEPICTS property" | ||||||
|  |                 ) | ||||||
|  |                 showLongToast( | ||||||
|  |                     context, | ||||||
|  |                     context.getString(R.string.wikidata_edit_failure) | ||||||
|  |                 ) | ||||||
|  |             }.switchMap { success: Boolean -> | ||||||
|  |                 if (success) { | ||||||
|  |                     Timber.d("DEPICTS property was deleted successfully") | ||||||
|  |                     return@switchMap addDepictsProperty(fileEntityId!!, depictedItems) | ||||||
|  |                 } else { | ||||||
|  |                     Timber.d("Unable to delete DEPICTS property") | ||||||
|  |                     return@switchMap Observable.empty<Boolean>() | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @SuppressLint("CheckResult") | ||||||
|  |     private fun getDepictionsClaimIds(entityId: String): List<String> { | ||||||
|  |         return wikiBaseClient.getClaimIdsByProperty(entityId, DEPICTS.propertyName) | ||||||
|  |             .subscribeOn(Schedulers.io()) | ||||||
|  |             .blockingFirst() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun showSuccessToast(wikiItemName: String) { | ||||||
|  |         val successStringTemplate = context.getString(R.string.successful_wikidata_edit) | ||||||
|  |         val successMessage = String.format(Locale.getDefault(), successStringTemplate, wikiItemName) | ||||||
|  |         showLongToast(context, successMessage) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @SuppressLint("CheckResult") | ||||||
|  |     private fun addCaption( | ||||||
|  |         fileEntityId: Long, languageCode: String, | ||||||
|  |         captionValue: String | ||||||
|  |     ): Observable<Boolean> { | ||||||
|  |         return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue) | ||||||
|  |             .doOnNext { mwPostResponse: MwPostResponse? -> | ||||||
|  |                 onAddCaptionResponse( | ||||||
|  |                     fileEntityId, | ||||||
|  |                     mwPostResponse | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |             .doOnError { throwable: Throwable? -> | ||||||
|  |                 Timber.e(throwable, "Error occurred while setting Captions") | ||||||
|  |                 showLongToast( | ||||||
|  |                     context, | ||||||
|  |                     context.getString(R.string.wikidata_edit_failure) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |             .map(Objects::nonNull) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun onAddCaptionResponse(fileEntityId: Long, response: MwPostResponse?) { | ||||||
|  |         if (response != null) { | ||||||
|  |             Timber.d("Caption successfully set, revision id = %s", response) | ||||||
|  |         } else { | ||||||
|  |             Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fun createClaim( | ||||||
|  |         wikidataPlace: WikidataPlace?, fileName: String, | ||||||
|  |         captions: Map<String, String> | ||||||
|  |     ): Long? { | ||||||
|  |         if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) { | ||||||
|  |             Timber.d( | ||||||
|  |                 "Image location and nearby place location mismatched, so Wikidata item won't be edited" | ||||||
|  |             ) | ||||||
|  |             return null | ||||||
|  |         } | ||||||
|  |         return addImageAndMediaLegends(wikidataPlace!!, fileName, captions) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fun addImageAndMediaLegends( | ||||||
|  |         wikidataItem: WikidataItem, fileName: String, | ||||||
|  |         captions: Map<String, String> | ||||||
|  |     ): Long { | ||||||
|  |         val p18 = SnakPartial( | ||||||
|  |             "value", | ||||||
|  |             IMAGE.propertyName, | ||||||
|  |             ValueString(fileName.replace("File:", "")) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         val snaks: MutableList<SnakPartial> = ArrayList() | ||||||
|  |         for ((key, value) in captions) { | ||||||
|  |             snaks.add( | ||||||
|  |                 SnakPartial( | ||||||
|  |                     "value", | ||||||
|  |                     MEDIA_LEGENDS.propertyName, MonoLingualText( | ||||||
|  |                         WikiBaseMonolingualTextValue(value!!, key!!) | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         val id = wikidataItem.id + "$" + UUID.randomUUID().toString() | ||||||
|  |         val claim = StatementPartial( | ||||||
|  |             p18, "statement", "normal", id, Collections.singletonMap<String, List<SnakPartial>>( | ||||||
|  |                 MEDIA_LEGENDS.propertyName, snaks | ||||||
|  |             ), Arrays.asList(MEDIA_LEGENDS.propertyName) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fun handleImageClaimResult(wikidataItem: WikidataItem, revisionId: Long?) { | ||||||
|  |         if (revisionId != null) { | ||||||
|  |             wikidataEditListener?.onSuccessfulWikidataEdit() | ||||||
|  |             showSuccessToast(wikidataItem.name) | ||||||
|  |         } else { | ||||||
|  |             Timber.d("Unable to make wiki data edit for entity %s", wikidataItem) | ||||||
|  |             showLongToast(context, context.getString(R.string.wikidata_edit_failure)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fun addDepictionsAndCaptions( | ||||||
|  |         uploadResult: UploadResult, | ||||||
|  |         contribution: Contribution | ||||||
|  |     ): Observable<Boolean> { | ||||||
|  |         return wikiBaseClient.getFileEntityId(uploadResult) | ||||||
|  |             .doOnError { throwable: Throwable? -> | ||||||
|  |                 Timber.e( | ||||||
|  |                     throwable, | ||||||
|  |                     "Error occurred while getting EntityID to set DEPICTS property" | ||||||
|  |                 ) | ||||||
|  |                 showLongToast( | ||||||
|  |                     context, | ||||||
|  |                     context.getString(R.string.wikidata_edit_failure) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |             .switchMap { fileEntityId: Long? -> | ||||||
|  |                 if (fileEntityId != null) { | ||||||
|  |                     Timber.d("EntityId for image was received successfully: %s", fileEntityId) | ||||||
|  |                     return@switchMap Observable.concat<Boolean>( | ||||||
|  |                         depictionEdits(contribution, fileEntityId), | ||||||
|  |                         captionEdits(contribution, fileEntityId) | ||||||
|  |                     ) | ||||||
|  |                 } else { | ||||||
|  |                     Timber.d("Error acquiring EntityId for image: %s", uploadResult) | ||||||
|  |                     return@switchMap Observable.empty<Boolean>() | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun captionEdits(contribution: Contribution, fileEntityId: Long): Observable<Boolean> { | ||||||
|  |         return Observable.fromIterable(contribution.media.captions.entries) | ||||||
|  |             .concatMap { addCaption(fileEntityId, it.key, it.value) } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun depictionEdits( | ||||||
|  |         contribution: Contribution, | ||||||
|  |         fileEntityId: Long | ||||||
|  |     ): Observable<Boolean> = addDepictsProperty(fileEntityId.toString(), buildList { | ||||||
|  |         for ((_, _, _, _, _, _, id) in contribution.depictedItems) { | ||||||
|  |             add(id) | ||||||
|  |         } | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         const val COMMONS_APP_TAG: String = "wikimedia-commons-app" | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @ -1,29 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata.json; |  | ||||||
| 
 |  | ||||||
| import com.google.gson.TypeAdapter; |  | ||||||
| import com.google.gson.stream.JsonReader; |  | ||||||
| import com.google.gson.stream.JsonToken; |  | ||||||
| import com.google.gson.stream.JsonWriter; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.wikidata.model.page.Namespace; |  | ||||||
| 
 |  | ||||||
| import java.io.IOException; |  | ||||||
| 
 |  | ||||||
| public class NamespaceTypeAdapter extends TypeAdapter<Namespace> { |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public void write(JsonWriter out, Namespace namespace) throws IOException { |  | ||||||
|         out.value(namespace.code()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public Namespace read(JsonReader in) throws IOException { |  | ||||||
|         if (in.peek() == JsonToken.STRING) { |  | ||||||
|             // Prior to 3210ce44, we marshaled Namespace as the name string of the enum, instead of |  | ||||||
|             // the code number. This introduces a backwards-compatible check for the string value. |  | ||||||
|             // TODO: remove after April 2017, when all older namespaces have been deserialized. |  | ||||||
|             return Namespace.valueOf(in.nextString()); |  | ||||||
|         } |  | ||||||
|         return Namespace.of(in.nextInt()); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,26 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata.json | ||||||
|  | 
 | ||||||
|  | import com.google.gson.TypeAdapter | ||||||
|  | import com.google.gson.stream.JsonReader | ||||||
|  | import com.google.gson.stream.JsonToken | ||||||
|  | import com.google.gson.stream.JsonWriter | ||||||
|  | import fr.free.nrw.commons.wikidata.model.page.Namespace | ||||||
|  | import java.io.IOException | ||||||
|  | 
 | ||||||
|  | class NamespaceTypeAdapter : TypeAdapter<Namespace>() { | ||||||
|  |     @Throws(IOException::class) | ||||||
|  |     override fun write(out: JsonWriter, namespace: Namespace) { | ||||||
|  |         out.value(namespace.code().toLong()) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @Throws(IOException::class) | ||||||
|  |     override fun read(reader: JsonReader): Namespace { | ||||||
|  |         if (reader.peek() == JsonToken.STRING) { | ||||||
|  |             // Prior to 3210ce44, we marshaled Namespace as the name string of the enum, instead of | ||||||
|  |             // the code number. This introduces a backwards-compatible check for the string value. | ||||||
|  |             // TODO: remove after April 2017, when all older namespaces have been deserialized. | ||||||
|  |             return Namespace.valueOf(reader.nextString()) | ||||||
|  |         } | ||||||
|  |         return Namespace.of(reader.nextInt()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,34 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata.json; |  | ||||||
| 
 |  | ||||||
| import com.google.gson.Gson; |  | ||||||
| import com.google.gson.TypeAdapter; |  | ||||||
| import com.google.gson.TypeAdapterFactory; |  | ||||||
| import com.google.gson.reflect.TypeToken; |  | ||||||
| import com.google.gson.stream.JsonReader; |  | ||||||
| import com.google.gson.stream.JsonWriter; |  | ||||||
| 
 |  | ||||||
| import java.io.IOException; |  | ||||||
| 
 |  | ||||||
| public class PostProcessingTypeAdapter implements TypeAdapterFactory { |  | ||||||
|     public interface PostProcessable { |  | ||||||
|         void postProcess(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) { |  | ||||||
|         final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type); |  | ||||||
| 
 |  | ||||||
|         return new TypeAdapter<T>() { |  | ||||||
|             public void write(JsonWriter out, T value) throws IOException { |  | ||||||
|                 delegate.write(out, value); |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             public T read(JsonReader in) throws IOException { |  | ||||||
|                 T obj = delegate.read(in); |  | ||||||
|                 if (obj instanceof PostProcessable) { |  | ||||||
|                     ((PostProcessable)obj).postProcess(); |  | ||||||
|                 } |  | ||||||
|                 return obj; |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,35 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata.json | ||||||
|  | 
 | ||||||
|  | import com.google.gson.Gson | ||||||
|  | import com.google.gson.TypeAdapter | ||||||
|  | import com.google.gson.TypeAdapterFactory | ||||||
|  | import com.google.gson.reflect.TypeToken | ||||||
|  | import com.google.gson.stream.JsonReader | ||||||
|  | import com.google.gson.stream.JsonWriter | ||||||
|  | import java.io.IOException | ||||||
|  | 
 | ||||||
|  | class PostProcessingTypeAdapter : TypeAdapterFactory { | ||||||
|  |     interface PostProcessable { | ||||||
|  |         fun postProcess() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun <T> create(gson: Gson, type: TypeToken<T>): TypeAdapter<T> { | ||||||
|  |         val delegate = gson.getDelegateAdapter(this, type) | ||||||
|  | 
 | ||||||
|  |         return object : TypeAdapter<T>() { | ||||||
|  |             @Throws(IOException::class) | ||||||
|  |             override fun write(out: JsonWriter, value: T) { | ||||||
|  |                 delegate.write(out, value) | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             @Throws(IOException::class) | ||||||
|  |             override fun read(reader: JsonReader): T { | ||||||
|  |                 val obj = delegate.read(reader) | ||||||
|  |                 if (obj is PostProcessable) { | ||||||
|  |                     (obj as PostProcessable).postProcess() | ||||||
|  |                 } | ||||||
|  |                 return obj | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,94 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata.json; |  | ||||||
| 
 |  | ||||||
| import androidx.annotation.NonNull; |  | ||||||
| import androidx.annotation.Nullable; |  | ||||||
| import androidx.collection.ArraySet; |  | ||||||
| 
 |  | ||||||
| import com.google.gson.Gson; |  | ||||||
| import com.google.gson.JsonParseException; |  | ||||||
| import com.google.gson.TypeAdapter; |  | ||||||
| import com.google.gson.TypeAdapterFactory; |  | ||||||
| import com.google.gson.reflect.TypeToken; |  | ||||||
| import com.google.gson.stream.JsonReader; |  | ||||||
| import com.google.gson.stream.JsonWriter; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.wikidata.json.annotations.Required; |  | ||||||
| 
 |  | ||||||
| import java.io.IOException; |  | ||||||
| import java.lang.reflect.Field; |  | ||||||
| import java.util.Collections; |  | ||||||
| import java.util.Set; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * TypeAdapterFactory that provides TypeAdapters that return null values for objects that are |  | ||||||
|  * missing fields annotated with @Required. |  | ||||||
|  * |  | ||||||
|  * BEWARE: This means that a List or other Collection of objects that have @Required fields can |  | ||||||
|  * contain null elements after deserialization! |  | ||||||
|  * |  | ||||||
|  * TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements |  | ||||||
|  * annotation and another corresponding TypeAdapter(Factory). |  | ||||||
|  */ |  | ||||||
| public class RequiredFieldsCheckOnReadTypeAdapterFactory implements TypeAdapterFactory { |  | ||||||
|     @Nullable @Override public final <T> TypeAdapter<T> create(@NonNull Gson gson, @NonNull TypeToken<T> typeToken) { |  | ||||||
|         Class<?> rawType = typeToken.getRawType(); |  | ||||||
|         Set<Field> requiredFields = collectRequiredFields(rawType); |  | ||||||
| 
 |  | ||||||
|         if (requiredFields.isEmpty()) { |  | ||||||
|             return null; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         setFieldsAccessible(requiredFields, true); |  | ||||||
|         return new Adapter<>(gson.getDelegateAdapter(this, typeToken), requiredFields); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @NonNull private Set<Field> collectRequiredFields(@NonNull Class<?> clazz) { |  | ||||||
|         Field[] fields = clazz.getDeclaredFields(); |  | ||||||
|         Set<Field> required = new ArraySet<>(); |  | ||||||
|         for (Field field : fields) { |  | ||||||
|             if (field.isAnnotationPresent(Required.class)) { |  | ||||||
|                 required.add(field); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         return Collections.unmodifiableSet(required); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private void setFieldsAccessible(Iterable<Field> fields, boolean accessible) { |  | ||||||
|         for (Field field : fields) { |  | ||||||
|             field.setAccessible(accessible); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private static final class Adapter<T> extends TypeAdapter<T> { |  | ||||||
|         @NonNull private final TypeAdapter<T> delegate; |  | ||||||
|         @NonNull private final Set<Field> requiredFields; |  | ||||||
| 
 |  | ||||||
|         private Adapter(@NonNull TypeAdapter<T> delegate, @NonNull final Set<Field> requiredFields) { |  | ||||||
|             this.delegate = delegate; |  | ||||||
|             this.requiredFields = requiredFields; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         @Override public void write(JsonWriter out, T value) throws IOException { |  | ||||||
|             delegate.write(out, value); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         @Override @Nullable public T read(JsonReader in) throws IOException { |  | ||||||
|             T deserialized = delegate.read(in); |  | ||||||
|             return allRequiredFieldsPresent(deserialized, requiredFields) ? deserialized : null; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         private boolean allRequiredFieldsPresent(@NonNull T deserialized, |  | ||||||
|                                                  @NonNull Set<Field> required) { |  | ||||||
|             for (Field field : required) { |  | ||||||
|                 try { |  | ||||||
|                     if (field.get(deserialized) == null) { |  | ||||||
|                         return false; |  | ||||||
|                     } |  | ||||||
|                 } catch (IllegalArgumentException | IllegalAccessException e) { |  | ||||||
|                     throw new JsonParseException(e); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,75 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata.json | ||||||
|  | 
 | ||||||
|  | import com.google.gson.Gson | ||||||
|  | import com.google.gson.JsonParseException | ||||||
|  | import com.google.gson.TypeAdapter | ||||||
|  | import com.google.gson.TypeAdapterFactory | ||||||
|  | import com.google.gson.reflect.TypeToken | ||||||
|  | import com.google.gson.stream.JsonReader | ||||||
|  | import com.google.gson.stream.JsonWriter | ||||||
|  | import fr.free.nrw.commons.wikidata.json.annotations.Required | ||||||
|  | import java.io.IOException | ||||||
|  | import java.lang.reflect.Field | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * TypeAdapterFactory that provides TypeAdapters that return null values for objects that are | ||||||
|  |  * missing fields annotated with @Required. | ||||||
|  |  * | ||||||
|  |  * BEWARE: This means that a List or other Collection of objects that have @Required fields can | ||||||
|  |  * contain null elements after deserialization! | ||||||
|  |  * | ||||||
|  |  * TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements | ||||||
|  |  * annotation and another corresponding TypeAdapter(Factory). | ||||||
|  |  */ | ||||||
|  | class RequiredFieldsCheckOnReadTypeAdapterFactory : TypeAdapterFactory { | ||||||
|  |     override fun <T> create(gson: Gson, typeToken: TypeToken<T>): TypeAdapter<T>? { | ||||||
|  |         val rawType: Class<*> = typeToken.rawType | ||||||
|  |         val requiredFields = collectRequiredFields(rawType) | ||||||
|  | 
 | ||||||
|  |         if (requiredFields.isEmpty()) { | ||||||
|  |             return null | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         for (field in requiredFields) { | ||||||
|  |             field.isAccessible = true | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return Adapter(gson.getDelegateAdapter(this, typeToken), requiredFields) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun collectRequiredFields(clazz: Class<*>): Set<Field> = buildSet { | ||||||
|  |         for (field in clazz.declaredFields) { | ||||||
|  |             if (field.isAnnotationPresent(Required::class.java)) add(field) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private class Adapter<T>( | ||||||
|  |         private val delegate: TypeAdapter<T>, | ||||||
|  |         private val requiredFields: Set<Field> | ||||||
|  |     ) : TypeAdapter<T>() { | ||||||
|  | 
 | ||||||
|  |         @Throws(IOException::class) | ||||||
|  |         override fun write(out: JsonWriter, value: T?) = | ||||||
|  |             delegate.write(out, value) | ||||||
|  | 
 | ||||||
|  |         @Throws(IOException::class) | ||||||
|  |         override fun read(reader: JsonReader): T? = | ||||||
|  |             if (allRequiredFieldsPresent(delegate.read(reader), requiredFields)) | ||||||
|  |                 delegate.read(reader) | ||||||
|  |             else | ||||||
|  |                 null | ||||||
|  | 
 | ||||||
|  |         fun allRequiredFieldsPresent(deserialized: T, required: Set<Field>): Boolean { | ||||||
|  |             for (field in required) { | ||||||
|  |                 try { | ||||||
|  |                     if (field[deserialized] == null) return false | ||||||
|  |                 } catch (e: IllegalArgumentException) { | ||||||
|  |                     throw JsonParseException(e) | ||||||
|  |                 } catch (e: IllegalAccessException) { | ||||||
|  |                     throw JsonParseException(e) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             return true | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,280 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata.json; |  | ||||||
| 
 |  | ||||||
| /* |  | ||||||
|  * Copyright (C) 2011 Google Inc. |  | ||||||
|  * |  | ||||||
|  * Licensed under the Apache License, Version 2.0 (the "License"); |  | ||||||
|  * you may not use this file except in compliance with the License. |  | ||||||
|  * You may obtain a copy of the License at |  | ||||||
|  * |  | ||||||
|  *      http://www.apache.org/licenses/LICENSE-2.0 |  | ||||||
|  * |  | ||||||
|  * Unless required by applicable law or agreed to in writing, software |  | ||||||
|  * distributed under the License is distributed on an "AS IS" BASIS, |  | ||||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |  | ||||||
|  * See the License for the specific language governing permissions and |  | ||||||
|  * limitations under the License. |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| import android.util.Log; |  | ||||||
| import java.io.IOException; |  | ||||||
| import java.util.LinkedHashMap; |  | ||||||
| import java.util.Map; |  | ||||||
| 
 |  | ||||||
| import com.google.gson.Gson; |  | ||||||
| import com.google.gson.JsonElement; |  | ||||||
| import com.google.gson.JsonObject; |  | ||||||
| import com.google.gson.JsonParseException; |  | ||||||
| import com.google.gson.JsonPrimitive; |  | ||||||
| import com.google.gson.TypeAdapter; |  | ||||||
| import com.google.gson.TypeAdapterFactory; |  | ||||||
| import com.google.gson.internal.Streams; |  | ||||||
| import com.google.gson.reflect.TypeToken; |  | ||||||
| import com.google.gson.stream.JsonReader; |  | ||||||
| import com.google.gson.stream.JsonWriter; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Adapts values whose runtime type may differ from their declaration type. This |  | ||||||
|  * is necessary when a field's type is not the same type that GSON should create |  | ||||||
|  * when deserializing that field. For example, consider these types: |  | ||||||
|  * <pre>   {@code |  | ||||||
|  *   abstract class Shape { |  | ||||||
|  *     int x; |  | ||||||
|  *     int y; |  | ||||||
|  *   } |  | ||||||
|  *   class Circle extends Shape { |  | ||||||
|  *     int radius; |  | ||||||
|  *   } |  | ||||||
|  *   class Rectangle extends Shape { |  | ||||||
|  *     int width; |  | ||||||
|  *     int height; |  | ||||||
|  *   } |  | ||||||
|  *   class Diamond extends Shape { |  | ||||||
|  *     int width; |  | ||||||
|  *     int height; |  | ||||||
|  *   } |  | ||||||
|  *   class Drawing { |  | ||||||
|  *     Shape bottomShape; |  | ||||||
|  *     Shape topShape; |  | ||||||
|  *   } |  | ||||||
|  * }</pre> |  | ||||||
|  * <p>Without additional type information, the serialized JSON is ambiguous. Is |  | ||||||
|  * the bottom shape in this drawing a rectangle or a diamond? <pre>   {@code |  | ||||||
|  *   { |  | ||||||
|  *     "bottomShape": { |  | ||||||
|  *       "width": 10, |  | ||||||
|  *       "height": 5, |  | ||||||
|  *       "x": 0, |  | ||||||
|  *       "y": 0 |  | ||||||
|  *     }, |  | ||||||
|  *     "topShape": { |  | ||||||
|  *       "radius": 2, |  | ||||||
|  *       "x": 4, |  | ||||||
|  *       "y": 1 |  | ||||||
|  *     } |  | ||||||
|  *   }}</pre> |  | ||||||
|  * This class addresses this problem by adding type information to the |  | ||||||
|  * serialized JSON and honoring that type information when the JSON is |  | ||||||
|  * deserialized: <pre>   {@code |  | ||||||
|  *   { |  | ||||||
|  *     "bottomShape": { |  | ||||||
|  *       "type": "Diamond", |  | ||||||
|  *       "width": 10, |  | ||||||
|  *       "height": 5, |  | ||||||
|  *       "x": 0, |  | ||||||
|  *       "y": 0 |  | ||||||
|  *     }, |  | ||||||
|  *     "topShape": { |  | ||||||
|  *       "type": "Circle", |  | ||||||
|  *       "radius": 2, |  | ||||||
|  *       "x": 4, |  | ||||||
|  *       "y": 1 |  | ||||||
|  *     } |  | ||||||
|  *   }}</pre> |  | ||||||
|  * Both the type field name ({@code "type"}) and the type labels ({@code |  | ||||||
|  * "Rectangle"}) are configurable. |  | ||||||
|  * |  | ||||||
|  * <h3>Registering Types</h3> |  | ||||||
|  * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field |  | ||||||
|  * name to the {@link #of} factory method. If you don't supply an explicit type |  | ||||||
|  * field name, {@code "type"} will be used. <pre>   {@code |  | ||||||
|  *   RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory |  | ||||||
|  *       = RuntimeTypeAdapterFactory.of(Shape.class, "type"); |  | ||||||
|  * }</pre> |  | ||||||
|  * Next register all of your subtypes. Every subtype must be explicitly |  | ||||||
|  * registered. This protects your application from injection attacks. If you |  | ||||||
|  * don't supply an explicit type label, the type's simple name will be used. |  | ||||||
|  * <pre>   {@code |  | ||||||
|  *   shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle"); |  | ||||||
|  *   shapeAdapterFactory.registerSubtype(Circle.class, "Circle"); |  | ||||||
|  *   shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond"); |  | ||||||
|  * }</pre> |  | ||||||
|  * Finally, register the type adapter factory in your application's GSON builder: |  | ||||||
|  * <pre>   {@code |  | ||||||
|  *   Gson gson = new GsonBuilder() |  | ||||||
|  *       .registerTypeAdapterFactory(shapeAdapterFactory) |  | ||||||
|  *       .create(); |  | ||||||
|  * }</pre> |  | ||||||
|  * Like {@code GsonBuilder}, this API supports chaining: <pre>   {@code |  | ||||||
|  *   RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class) |  | ||||||
|  *       .registerSubtype(Rectangle.class) |  | ||||||
|  *       .registerSubtype(Circle.class) |  | ||||||
|  *       .registerSubtype(Diamond.class); |  | ||||||
|  * }</pre> |  | ||||||
|  * |  | ||||||
|  * <h3>Serialization and deserialization</h3> |  | ||||||
|  * In order to serialize and deserialize a polymorphic object, |  | ||||||
|  * you must specify the base type explicitly. |  | ||||||
|  * <pre>   {@code |  | ||||||
|  *   Diamond diamond = new Diamond(); |  | ||||||
|  *   String json = gson.toJson(diamond, Shape.class); |  | ||||||
|  * }</pre> |  | ||||||
|  * And then: |  | ||||||
|  * <pre>   {@code |  | ||||||
|  *   Shape shape = gson.fromJson(json, Shape.class); |  | ||||||
|  * }</pre> |  | ||||||
|  */ |  | ||||||
| public final class RuntimeTypeAdapterFactory<T> implements TypeAdapterFactory { |  | ||||||
|   private final Class<?> baseType; |  | ||||||
|   private final String typeFieldName; |  | ||||||
|   private final Map<String, Class<?>> labelToSubtype = new LinkedHashMap<String, Class<?>>(); |  | ||||||
|   private final Map<Class<?>, String> subtypeToLabel = new LinkedHashMap<Class<?>, String>(); |  | ||||||
|   private final boolean maintainType; |  | ||||||
| 
 |  | ||||||
|   private RuntimeTypeAdapterFactory(Class<?> baseType, String typeFieldName, boolean maintainType) { |  | ||||||
|     if (typeFieldName == null || baseType == null) { |  | ||||||
|       throw new NullPointerException(); |  | ||||||
|     } |  | ||||||
|     this.baseType = baseType; |  | ||||||
|     this.typeFieldName = typeFieldName; |  | ||||||
|     this.maintainType = maintainType; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |  | ||||||
|    * Creates a new runtime type adapter using for {@code baseType} using {@code |  | ||||||
|    * typeFieldName} as the type field name. Type field names are case sensitive. |  | ||||||
|    * {@code maintainType} flag decide if the type will be stored in pojo or not. |  | ||||||
|    */ |  | ||||||
|   public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName, boolean maintainType) { |  | ||||||
|     return new RuntimeTypeAdapterFactory<T>(baseType, typeFieldName, maintainType); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |  | ||||||
|    * Creates a new runtime type adapter using for {@code baseType} using {@code |  | ||||||
|    * typeFieldName} as the type field name. Type field names are case sensitive. |  | ||||||
|    */ |  | ||||||
|   public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName) { |  | ||||||
|     return new RuntimeTypeAdapterFactory<T>(baseType, typeFieldName, false); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |  | ||||||
|    * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as |  | ||||||
|    * the type field name. |  | ||||||
|    */ |  | ||||||
|   public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType) { |  | ||||||
|     return new RuntimeTypeAdapterFactory<T>(baseType, "type", false); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |  | ||||||
|    * Registers {@code type} identified by {@code label}. Labels are case |  | ||||||
|    * sensitive. |  | ||||||
|    * |  | ||||||
|    * @throws IllegalArgumentException if either {@code type} or {@code label} |  | ||||||
|    *     have already been registered on this type adapter. |  | ||||||
|    */ |  | ||||||
|   public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type, String label) { |  | ||||||
|     if (type == null || label == null) { |  | ||||||
|       throw new NullPointerException(); |  | ||||||
|     } |  | ||||||
|     if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) { |  | ||||||
|       throw new IllegalArgumentException("types and labels must be unique"); |  | ||||||
|     } |  | ||||||
|     labelToSubtype.put(label, type); |  | ||||||
|     subtypeToLabel.put(type, label); |  | ||||||
|     return this; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |  | ||||||
|    * Registers {@code type} identified by its {@link Class#getSimpleName simple |  | ||||||
|    * name}. Labels are case sensitive. |  | ||||||
|    * |  | ||||||
|    * @throws IllegalArgumentException if either {@code type} or its simple name |  | ||||||
|    *     have already been registered on this type adapter. |  | ||||||
|    */ |  | ||||||
|   public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type) { |  | ||||||
|     return registerSubtype(type, type.getSimpleName()); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   public <R> TypeAdapter<R> create(Gson gson, TypeToken<R> type) { |  | ||||||
|     if (type.getRawType() != baseType) { |  | ||||||
|       return null; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     final Map<String, TypeAdapter<?>> labelToDelegate |  | ||||||
|         = new LinkedHashMap<String, TypeAdapter<?>>(); |  | ||||||
|     final Map<Class<?>, TypeAdapter<?>> subtypeToDelegate |  | ||||||
|         = new LinkedHashMap<Class<?>, TypeAdapter<?>>(); |  | ||||||
|     for (Map.Entry<String, Class<?>> entry : labelToSubtype.entrySet()) { |  | ||||||
|       TypeAdapter<?> delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue())); |  | ||||||
|       labelToDelegate.put(entry.getKey(), delegate); |  | ||||||
|       subtypeToDelegate.put(entry.getValue(), delegate); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return new TypeAdapter<R>() { |  | ||||||
|       @Override public R read(JsonReader in) throws IOException { |  | ||||||
|         JsonElement jsonElement = Streams.parse(in); |  | ||||||
|         JsonElement labelJsonElement; |  | ||||||
|         if (maintainType) { |  | ||||||
|           labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName); |  | ||||||
|         } else { |  | ||||||
|           labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (labelJsonElement == null) { |  | ||||||
|           throw new JsonParseException("cannot deserialize " + baseType |  | ||||||
|               + " because it does not define a field named " + typeFieldName); |  | ||||||
|         } |  | ||||||
|         String label = labelJsonElement.getAsString(); |  | ||||||
|         @SuppressWarnings("unchecked") // registration requires that subtype extends T |  | ||||||
|             TypeAdapter<R> delegate = (TypeAdapter<R>) labelToDelegate.get(label); |  | ||||||
|         if (delegate == null) { |  | ||||||
| 
 |  | ||||||
|           Log.e("RuntimeTypeAdapter", "cannot deserialize " + baseType + " subtype named " |  | ||||||
|               + label + "; did you forget to register a subtype? " +jsonElement); |  | ||||||
|           return null; |  | ||||||
|         } |  | ||||||
|         return delegate.fromJsonTree(jsonElement); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       @Override public void write(JsonWriter out, R value) throws IOException { |  | ||||||
|         Class<?> srcType = value.getClass(); |  | ||||||
|         String label = subtypeToLabel.get(srcType); |  | ||||||
|         @SuppressWarnings("unchecked") // registration requires that subtype extends T |  | ||||||
|             TypeAdapter<R> delegate = (TypeAdapter<R>) subtypeToDelegate.get(srcType); |  | ||||||
|         if (delegate == null) { |  | ||||||
|           throw new JsonParseException("cannot serialize " + srcType.getName() |  | ||||||
|               + "; did you forget to register a subtype?"); |  | ||||||
|         } |  | ||||||
|         JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject(); |  | ||||||
| 
 |  | ||||||
|         if (maintainType) { |  | ||||||
|           Streams.write(jsonObject, out); |  | ||||||
|           return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         JsonObject clone = new JsonObject(); |  | ||||||
| 
 |  | ||||||
|         if (jsonObject.has(typeFieldName)) { |  | ||||||
|           throw new JsonParseException("cannot serialize " + srcType.getName() |  | ||||||
|               + " because it already defines a field named " + typeFieldName); |  | ||||||
|         } |  | ||||||
|         clone.add(typeFieldName, new JsonPrimitive(label)); |  | ||||||
| 
 |  | ||||||
|         for (Map.Entry<String, JsonElement> e : jsonObject.entrySet()) { |  | ||||||
|           clone.add(e.getKey(), e.getValue()); |  | ||||||
|         } |  | ||||||
|         Streams.write(clone, out); |  | ||||||
|       } |  | ||||||
|     }.nullSafe(); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,273 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata.json | ||||||
|  | 
 | ||||||
|  | import com.google.gson.Gson | ||||||
|  | import com.google.gson.JsonObject | ||||||
|  | import com.google.gson.JsonParseException | ||||||
|  | import com.google.gson.JsonPrimitive | ||||||
|  | import com.google.gson.TypeAdapter | ||||||
|  | import com.google.gson.TypeAdapterFactory | ||||||
|  | import com.google.gson.internal.Streams | ||||||
|  | import com.google.gson.reflect.TypeToken | ||||||
|  | import com.google.gson.stream.JsonReader | ||||||
|  | import com.google.gson.stream.JsonWriter | ||||||
|  | import timber.log.Timber | ||||||
|  | import java.io.IOException | ||||||
|  | 
 | ||||||
|  | /* | ||||||
|  | * Copyright (C) 2011 Google Inc. | ||||||
|  | * | ||||||
|  | * Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | * you may not use this file except in compliance with the License. | ||||||
|  | * You may obtain a copy of the License at | ||||||
|  | * | ||||||
|  | *      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | * | ||||||
|  | * Unless required by applicable law or agreed to in writing, software | ||||||
|  | * distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | * See the License for the specific language governing permissions and | ||||||
|  | * limitations under the License. | ||||||
|  | */ | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Adapts values whose runtime type may differ from their declaration type. This | ||||||
|  |  * is necessary when a field's type is not the same type that GSON should create | ||||||
|  |  * when deserializing that field. For example, consider these types: | ||||||
|  |  * <pre>   `abstract class Shape { | ||||||
|  |  * int x; | ||||||
|  |  * int y; | ||||||
|  |  * } | ||||||
|  |  * class Circle extends Shape { | ||||||
|  |  * int radius; | ||||||
|  |  * } | ||||||
|  |  * class Rectangle extends Shape { | ||||||
|  |  * int width; | ||||||
|  |  * int height; | ||||||
|  |  * } | ||||||
|  |  * class Diamond extends Shape { | ||||||
|  |  * int width; | ||||||
|  |  * int height; | ||||||
|  |  * } | ||||||
|  |  * class Drawing { | ||||||
|  |  * Shape bottomShape; | ||||||
|  |  * Shape topShape; | ||||||
|  |  * } | ||||||
|  | `</pre> * | ||||||
|  |  * | ||||||
|  |  * Without additional type information, the serialized JSON is ambiguous. Is | ||||||
|  |  * the bottom shape in this drawing a rectangle or a diamond? <pre>   `{ | ||||||
|  |  * "bottomShape": { | ||||||
|  |  * "width": 10, | ||||||
|  |  * "height": 5, | ||||||
|  |  * "x": 0, | ||||||
|  |  * "y": 0 | ||||||
|  |  * }, | ||||||
|  |  * "topShape": { | ||||||
|  |  * "radius": 2, | ||||||
|  |  * "x": 4, | ||||||
|  |  * "y": 1 | ||||||
|  |  * } | ||||||
|  |  * }`</pre> | ||||||
|  |  * This class addresses this problem by adding type information to the | ||||||
|  |  * serialized JSON and honoring that type information when the JSON is | ||||||
|  |  * deserialized: <pre>   `{ | ||||||
|  |  * "bottomShape": { | ||||||
|  |  * "type": "Diamond", | ||||||
|  |  * "width": 10, | ||||||
|  |  * "height": 5, | ||||||
|  |  * "x": 0, | ||||||
|  |  * "y": 0 | ||||||
|  |  * }, | ||||||
|  |  * "topShape": { | ||||||
|  |  * "type": "Circle", | ||||||
|  |  * "radius": 2, | ||||||
|  |  * "x": 4, | ||||||
|  |  * "y": 1 | ||||||
|  |  * } | ||||||
|  |  * }`</pre> | ||||||
|  |  * Both the type field name (`"type"`) and the type labels (`"Rectangle"`) are configurable. | ||||||
|  |  * | ||||||
|  |  * <h3>Registering Types</h3> | ||||||
|  |  * Create a `RuntimeTypeAdapterFactory` by passing the base type and type field | ||||||
|  |  * name to the [.of] factory method. If you don't supply an explicit type | ||||||
|  |  * field name, `"type"` will be used. <pre>   `RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory | ||||||
|  |  * = RuntimeTypeAdapterFactory.of(Shape.class, "type"); | ||||||
|  | `</pre> * | ||||||
|  |  * Next register all of your subtypes. Every subtype must be explicitly | ||||||
|  |  * registered. This protects your application from injection attacks. If you | ||||||
|  |  * don't supply an explicit type label, the type's simple name will be used. | ||||||
|  |  * <pre>   `shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle"); | ||||||
|  |  * shapeAdapterFactory.registerSubtype(Circle.class, "Circle"); | ||||||
|  |  * shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond"); | ||||||
|  | `</pre> * | ||||||
|  |  * Finally, register the type adapter factory in your application's GSON builder: | ||||||
|  |  * <pre>   `Gson gson = new GsonBuilder() | ||||||
|  |  * .registerTypeAdapterFactory(shapeAdapterFactory) | ||||||
|  |  * .create(); | ||||||
|  | `</pre> * | ||||||
|  |  * Like `GsonBuilder`, this API supports chaining: <pre>   `RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class) | ||||||
|  |  * .registerSubtype(Rectangle.class) | ||||||
|  |  * .registerSubtype(Circle.class) | ||||||
|  |  * .registerSubtype(Diamond.class); | ||||||
|  | `</pre> * | ||||||
|  |  * | ||||||
|  |  * <h3>Serialization and deserialization</h3> | ||||||
|  |  * In order to serialize and deserialize a polymorphic object, | ||||||
|  |  * you must specify the base type explicitly. | ||||||
|  |  * <pre>   `Diamond diamond = new Diamond(); | ||||||
|  |  * String json = gson.toJson(diamond, Shape.class); | ||||||
|  | `</pre> * | ||||||
|  |  * And then: | ||||||
|  |  * <pre>   `Shape shape = gson.fromJson(json, Shape.class); | ||||||
|  | `</pre> * | ||||||
|  |  */ | ||||||
|  | class RuntimeTypeAdapterFactory<T>( | ||||||
|  |     baseType: Class<*>?, | ||||||
|  |     typeFieldName: String?, | ||||||
|  |     maintainType: Boolean | ||||||
|  | ) : TypeAdapterFactory { | ||||||
|  | 
 | ||||||
|  |     private val baseType: Class<*> | ||||||
|  |     private val typeFieldName: String | ||||||
|  |     private val labelToSubtype = mutableMapOf<String, Class<*>>() | ||||||
|  |     private val subtypeToLabel = mutableMapOf<Class<*>, String>() | ||||||
|  |     private val maintainType: Boolean | ||||||
|  | 
 | ||||||
|  |     init { | ||||||
|  |         if (typeFieldName == null || baseType == null) { | ||||||
|  |             throw NullPointerException() | ||||||
|  |         } | ||||||
|  |         this.baseType = baseType | ||||||
|  |         this.typeFieldName = typeFieldName | ||||||
|  |         this.maintainType = maintainType | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Registers `type` identified by `label`. Labels are case | ||||||
|  |      * sensitive. | ||||||
|  |      * | ||||||
|  |      * @throws IllegalArgumentException if either `type` or `label` | ||||||
|  |      * have already been registered on this type adapter. | ||||||
|  |      */ | ||||||
|  |     fun registerSubtype(type: Class<out T>?, label: String?): RuntimeTypeAdapterFactory<T> { | ||||||
|  |         if (type == null || label == null) { | ||||||
|  |             throw NullPointerException() | ||||||
|  |         } | ||||||
|  |         require(!(subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label))) { | ||||||
|  |             "types and labels must be unique" | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         labelToSubtype[label] = type | ||||||
|  |         subtypeToLabel[type] = label | ||||||
|  |         return this | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Registers `type` identified by its [simple][Class.getSimpleName]. Labels are case sensitive. | ||||||
|  |      * | ||||||
|  |      * @throws IllegalArgumentException if either `type` or its simple name | ||||||
|  |      * have already been registered on this type adapter. | ||||||
|  |      */ | ||||||
|  |     fun registerSubtype(type: Class<out T>): RuntimeTypeAdapterFactory<T> { | ||||||
|  |         return registerSubtype(type, type.simpleName) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun <R : Any> create(gson: Gson, type: TypeToken<R>): TypeAdapter<R>? { | ||||||
|  |         if (type.rawType != baseType) { | ||||||
|  |             return null | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         val labelToDelegate = mutableMapOf<String, TypeAdapter<*>>() | ||||||
|  |         val subtypeToDelegate = mutableMapOf<Class<*>, TypeAdapter<*>>() | ||||||
|  |         for ((key, value) in labelToSubtype) { | ||||||
|  |             val delegate = gson.getDelegateAdapter( | ||||||
|  |                 this, TypeToken.get( | ||||||
|  |                     value | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |             labelToDelegate[key] = delegate | ||||||
|  |             subtypeToDelegate[value] = delegate | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return object : TypeAdapter<R>() { | ||||||
|  |             @Throws(IOException::class) | ||||||
|  |             override fun read(reader: JsonReader): R? { | ||||||
|  |                 val jsonElement = Streams.parse(reader) | ||||||
|  |                 val labelJsonElement = if (maintainType) { | ||||||
|  |                     jsonElement.asJsonObject[typeFieldName] | ||||||
|  |                 } else { | ||||||
|  |                     jsonElement.asJsonObject.remove(typeFieldName) | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 if (labelJsonElement == null) { | ||||||
|  |                     throw JsonParseException( | ||||||
|  |                         "cannot deserialize $baseType because it does not define a field named $typeFieldName" | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |                 val label = labelJsonElement.asString | ||||||
|  |                 val delegate = labelToDelegate[label] as TypeAdapter<R>? | ||||||
|  |                 if (delegate == null) { | ||||||
|  |                     Timber.tag("RuntimeTypeAdapter").e( | ||||||
|  |                         "cannot deserialize $baseType subtype named $label; did you forget to register a subtype? $jsonElement" | ||||||
|  |                     ) | ||||||
|  |                     return null | ||||||
|  |                 } | ||||||
|  |                 return delegate.fromJsonTree(jsonElement) | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             @Throws(IOException::class) | ||||||
|  |             override fun write(out: JsonWriter, value: R) { | ||||||
|  |                 val srcType: Class<*> = value::class.java.javaClass | ||||||
|  |                 val delegate = | ||||||
|  |                     subtypeToDelegate[srcType] as TypeAdapter<R?>? ?: throw JsonParseException( | ||||||
|  |                         "cannot serialize ${srcType.name}; did you forget to register a subtype?" | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                 val jsonObject = delegate.toJsonTree(value).asJsonObject | ||||||
|  |                 if (maintainType) { | ||||||
|  |                     Streams.write(jsonObject, out) | ||||||
|  |                     return | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 if (jsonObject.has(typeFieldName)) { | ||||||
|  |                     throw JsonParseException( | ||||||
|  |                         "cannot serialize ${srcType.name} because it already defines a field named $typeFieldName" | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |                 val clone = JsonObject() | ||||||
|  |                 val label = subtypeToLabel[srcType] | ||||||
|  |                 clone.add(typeFieldName, JsonPrimitive(label)) | ||||||
|  |                 for ((key, value1) in jsonObject.entrySet()) { | ||||||
|  |                     clone.add(key, value1) | ||||||
|  |                 } | ||||||
|  |                 Streams.write(clone, out) | ||||||
|  |             } | ||||||
|  |         }.nullSafe() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         /** | ||||||
|  |          * Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive. | ||||||
|  |          * `maintainType` flag decide if the type will be stored in pojo or not. | ||||||
|  |          */ | ||||||
|  |         fun <T> of( | ||||||
|  |             baseType: Class<T>, | ||||||
|  |             typeFieldName: String, | ||||||
|  |             maintainType: Boolean | ||||||
|  |         ): RuntimeTypeAdapterFactory<T> = | ||||||
|  |             RuntimeTypeAdapterFactory(baseType, typeFieldName, maintainType) | ||||||
|  | 
 | ||||||
|  |         /** | ||||||
|  |          * Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive. | ||||||
|  |          */ | ||||||
|  |         fun <T> of(baseType: Class<T>, typeFieldName: String): RuntimeTypeAdapterFactory<T> = | ||||||
|  |             RuntimeTypeAdapterFactory(baseType, typeFieldName, false) | ||||||
|  | 
 | ||||||
|  |         /** | ||||||
|  |          * Creates a new runtime type adapter for `baseType` using `"type"` as | ||||||
|  |          * the type field name. | ||||||
|  |          */ | ||||||
|  |         fun <T> of(baseType: Class<T>): RuntimeTypeAdapterFactory<T> = | ||||||
|  |             RuntimeTypeAdapterFactory(baseType, "type", false) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,22 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata.json; |  | ||||||
| 
 |  | ||||||
| import android.net.Uri; |  | ||||||
| 
 |  | ||||||
| import com.google.gson.TypeAdapter; |  | ||||||
| import com.google.gson.stream.JsonReader; |  | ||||||
| import com.google.gson.stream.JsonWriter; |  | ||||||
| 
 |  | ||||||
| import java.io.IOException; |  | ||||||
| 
 |  | ||||||
| public class UriTypeAdapter extends TypeAdapter<Uri> { |  | ||||||
|     @Override |  | ||||||
|     public void write(JsonWriter out, Uri value) throws IOException { |  | ||||||
|         out.value(value.toString()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override |  | ||||||
|     public Uri read(JsonReader in) throws IOException { |  | ||||||
|         String url = in.nextString(); |  | ||||||
|         return Uri.parse(url); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,19 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata.json | ||||||
|  | 
 | ||||||
|  | import android.net.Uri | ||||||
|  | import com.google.gson.TypeAdapter | ||||||
|  | import com.google.gson.stream.JsonReader | ||||||
|  | import com.google.gson.stream.JsonWriter | ||||||
|  | import java.io.IOException | ||||||
|  | 
 | ||||||
|  | class UriTypeAdapter : TypeAdapter<Uri>() { | ||||||
|  |     @Throws(IOException::class) | ||||||
|  |     override fun write(out: JsonWriter, value: Uri) { | ||||||
|  |         out.value(value.toString()) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @Throws(IOException::class) | ||||||
|  |     override fun read(reader: JsonReader): Uri { | ||||||
|  |         return Uri.parse(reader.nextString()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,63 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata.json; |  | ||||||
| 
 |  | ||||||
| import android.net.Uri; |  | ||||||
| 
 |  | ||||||
| import com.google.gson.JsonParseException; |  | ||||||
| import com.google.gson.TypeAdapter; |  | ||||||
| import com.google.gson.stream.JsonReader; |  | ||||||
| import com.google.gson.stream.JsonToken; |  | ||||||
| import com.google.gson.stream.JsonWriter; |  | ||||||
| 
 |  | ||||||
| import fr.free.nrw.commons.wikidata.model.WikiSite; |  | ||||||
| 
 |  | ||||||
| import java.io.IOException; |  | ||||||
| 
 |  | ||||||
| public class WikiSiteTypeAdapter extends TypeAdapter<WikiSite> { |  | ||||||
|     private static final String DOMAIN = "domain"; |  | ||||||
|     private static final String LANGUAGE_CODE = "languageCode"; |  | ||||||
| 
 |  | ||||||
|     @Override public void write(JsonWriter out, WikiSite value) throws IOException { |  | ||||||
|         out.beginObject(); |  | ||||||
|         out.name(DOMAIN); |  | ||||||
|         out.value(value.url()); |  | ||||||
| 
 |  | ||||||
|         out.name(LANGUAGE_CODE); |  | ||||||
|         out.value(value.languageCode()); |  | ||||||
|         out.endObject(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Override public WikiSite read(JsonReader in) throws IOException { |  | ||||||
|         // todo: legacy; remove in June 2018 |  | ||||||
|         if (in.peek() == JsonToken.STRING) { |  | ||||||
|             return new WikiSite(Uri.parse(in.nextString())); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         String domain = null; |  | ||||||
|         String languageCode = null; |  | ||||||
|         in.beginObject(); |  | ||||||
|         while (in.hasNext()) { |  | ||||||
|             String field = in.nextName(); |  | ||||||
|             String val = in.nextString(); |  | ||||||
|             switch (field) { |  | ||||||
|                 case DOMAIN: |  | ||||||
|                     domain = val; |  | ||||||
|                     break; |  | ||||||
|                 case LANGUAGE_CODE: |  | ||||||
|                     languageCode = val; |  | ||||||
|                     break; |  | ||||||
|                 default: break; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         in.endObject(); |  | ||||||
| 
 |  | ||||||
|         if (domain == null) { |  | ||||||
|             throw new JsonParseException("Missing domain"); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // todo: legacy; remove in June 2018 |  | ||||||
|         if (languageCode == null) { |  | ||||||
|             return new WikiSite(domain); |  | ||||||
|         } |  | ||||||
|         return new WikiSite(domain, languageCode); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,61 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata.json | ||||||
|  | 
 | ||||||
|  | import android.net.Uri | ||||||
|  | import com.google.gson.JsonParseException | ||||||
|  | import com.google.gson.TypeAdapter | ||||||
|  | import com.google.gson.stream.JsonReader | ||||||
|  | import com.google.gson.stream.JsonToken | ||||||
|  | import com.google.gson.stream.JsonWriter | ||||||
|  | import fr.free.nrw.commons.wikidata.model.WikiSite | ||||||
|  | import java.io.IOException | ||||||
|  | 
 | ||||||
|  | class WikiSiteTypeAdapter : TypeAdapter<WikiSite>() { | ||||||
|  |     @Throws(IOException::class) | ||||||
|  |     override fun write(out: JsonWriter, value: WikiSite) { | ||||||
|  |         out.beginObject() | ||||||
|  |         out.name(DOMAIN) | ||||||
|  |         out.value(value.url()) | ||||||
|  | 
 | ||||||
|  |         out.name(LANGUAGE_CODE) | ||||||
|  |         out.value(value.languageCode()) | ||||||
|  |         out.endObject() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @Throws(IOException::class) | ||||||
|  |     override fun read(reader: JsonReader): WikiSite { | ||||||
|  |         // todo: legacy; remove reader June 2018 | ||||||
|  |         if (reader.peek() == JsonToken.STRING) { | ||||||
|  |             return WikiSite(Uri.parse(reader.nextString())) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         var domain: String? = null | ||||||
|  |         var languageCode: String? = null | ||||||
|  |         reader.beginObject() | ||||||
|  |         while (reader.hasNext()) { | ||||||
|  |             val field = reader.nextName() | ||||||
|  |             val value = reader.nextString() | ||||||
|  |             when (field) { | ||||||
|  |                 DOMAIN -> domain = value | ||||||
|  |                 LANGUAGE_CODE -> languageCode = value | ||||||
|  |                 else -> {} | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         reader.endObject() | ||||||
|  | 
 | ||||||
|  |         if (domain == null) { | ||||||
|  |             throw JsonParseException("Missing domain") | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // todo: legacy; remove reader June 2018 | ||||||
|  |         return if (languageCode == null) { | ||||||
|  |             WikiSite(domain) | ||||||
|  |         } else { | ||||||
|  |             WikiSite(domain, languageCode) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         private const val DOMAIN = "domain" | ||||||
|  |         private const val LANGUAGE_CODE = "languageCode" | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,21 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata.json.annotations; |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| import java.lang.annotation.Documented; |  | ||||||
| import java.lang.annotation.Retention; |  | ||||||
| import java.lang.annotation.RetentionPolicy; |  | ||||||
| import java.lang.annotation.Target; |  | ||||||
| 
 |  | ||||||
| import static java.lang.annotation.ElementType.FIELD; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return |  | ||||||
|  * an instantiated object. |  | ||||||
|  * |  | ||||||
|  * E.g.: @NonNull @Required private String title; |  | ||||||
|  */ |  | ||||||
| @Documented |  | ||||||
| @Retention(RetentionPolicy.RUNTIME) |  | ||||||
| @Target(FIELD) |  | ||||||
| public @interface Required { |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,12 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata.json.annotations | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return | ||||||
|  |  * an instantiated object. | ||||||
|  |  * | ||||||
|  |  * E.g.: @NonNull @Required private String title; | ||||||
|  |  */ | ||||||
|  | @Retention(AnnotationRetention.RUNTIME) | ||||||
|  | @Target(AnnotationTarget.FIELD) | ||||||
|  | annotation class Required  | ||||||
|  | @ -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; |                 return null; | ||||||
|             } |             } | ||||||
|             if (primaryLink == null && primary instanceof JsonObject) { |             if (primaryLink == null && primary instanceof JsonObject) { | ||||||
|                 primaryLink = GsonUtil.getDefaultGson().fromJson(primary, Link.class); |                 primaryLink = GsonUtil.INSTANCE.getDefaultGson().fromJson(primary, Link.class); | ||||||
|             } |             } | ||||||
|             return primaryLink; |             return primaryLink; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | @ -1,34 +0,0 @@ | ||||||
| package fr.free.nrw.commons.wikidata.mwapi; |  | ||||||
| 
 |  | ||||||
| import androidx.annotation.NonNull; |  | ||||||
| import androidx.annotation.Nullable; |  | ||||||
| import java.util.Map; |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| public class UserInfo { |  | ||||||
|     @NonNull private String name; |  | ||||||
|     @NonNull private int id; |  | ||||||
| 
 |  | ||||||
|     //Block information |  | ||||||
|     private int blockid; |  | ||||||
|     private String blockedby; |  | ||||||
|     private int blockedbyid; |  | ||||||
|     private String blockreason; |  | ||||||
|     private String blocktimestamp; |  | ||||||
|     private String blockexpiry; |  | ||||||
| 
 |  | ||||||
|     // Object type is any JSON type. |  | ||||||
|     @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") |  | ||||||
|     @Nullable private Map<String, ?> options; |  | ||||||
| 
 |  | ||||||
|     public int id() { |  | ||||||
|         return id; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @NonNull |  | ||||||
|     public String blockexpiry() { |  | ||||||
|         if (blockexpiry != null) |  | ||||||
|             return blockexpiry; |  | ||||||
|         else return ""; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,21 @@ | ||||||
|  | package fr.free.nrw.commons.wikidata.mwapi | ||||||
|  | 
 | ||||||
|  | data class UserInfo( | ||||||
|  |     val name: String = "", | ||||||
|  |     val id: Int = 0, | ||||||
|  | 
 | ||||||
|  |     //Block information | ||||||
|  |     val blockid: Int = 0, | ||||||
|  |     val blockedby: String? = null, | ||||||
|  |     val blockedbyid: Int = 0, | ||||||
|  |     val blockreason: String? = null, | ||||||
|  |     val blocktimestamp: String? = null, | ||||||
|  |     val blockexpiry: String? = null, | ||||||
|  | 
 | ||||||
|  |     // Object type is any JSON type. | ||||||
|  |     val options: Map<String, *>? = null | ||||||
|  | ) { | ||||||
|  |     fun id(): Int = id | ||||||
|  | 
 | ||||||
|  |     fun blockexpiry(): String = blockexpiry ?: "" | ||||||
|  | } | ||||||
|  | @ -1,640 +1,368 @@ | ||||||
| <?xml version="1.0" encoding="utf-8"?> | <?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:app="http://schemas.android.com/apk/res-auto" | ||||||
|   xmlns:tools="http://schemas.android.com/tools" |   xmlns:tools="http://schemas.android.com/tools" | ||||||
|   android:id="@+id/drawer_layout" |  | ||||||
|   android:layout_width="match_parent" |   android:layout_width="match_parent" | ||||||
|   android:layout_height="match_parent" |   android:layout_height="match_parent" | ||||||
|   android:background="?attr/achievementBackground"> |   android:background="?attr/achievementBackground" | ||||||
|  |   android:fillViewport="true" | ||||||
|  |   tools:ignore="ContentDescription" > | ||||||
| 
 | 
 | ||||||
|     <ScrollView | <!-- 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"> | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     <androidx.appcompat.widget.AppCompatTextView | ||||||
|  |       android:id="@+id/tv_achievements_of_user" | ||||||
|  |       style="@style/MediaDetailTextLabel" | ||||||
|  |       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" /> | ||||||
|  | 
 | ||||||
|  |     <ImageView | ||||||
|  |       android:id="@+id/achievement_info" | ||||||
|       android:layout_width="wrap_content" |       android:layout_width="wrap_content" | ||||||
|       android:layout_height="wrap_content"> |       android:layout_height="wrap_content" | ||||||
|  |       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||||
|  |       app:layout_constraintEnd_toEndOf="parent" | ||||||
|  |       app:layout_constraintTop_toBottomOf="@+id/tv_achievements_of_user" | ||||||
|  |       app:srcCompat="@drawable/ic_info_outline_24dp" | ||||||
|  |       app:tint="@color/black" | ||||||
|  |       tools:ignore="ContentDescription" /> | ||||||
| 
 | 
 | ||||||
|       <LinearLayout |     <ImageView | ||||||
|         android:layout_width="match_parent" |       android:id="@+id/achievement_badge_image" | ||||||
|         android:layout_height="match_parent" |       android:layout_width="150dp" | ||||||
|         android:orientation="vertical"> |       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" | ||||||
|  |       tools:layout_height="100dp" | ||||||
|  |       tools:layout_width="100dp" /> | ||||||
| 
 | 
 | ||||||
|       <androidx.appcompat.widget.AppCompatTextView |     <TextView | ||||||
|         android:id="@+id/tv_achievements_of_user" |       android:id="@+id/achievement_badge_text" | ||||||
|         style="@style/MediaDetailTextLabel" |       android:layout_width="wrap_content" | ||||||
|  |       android:layout_height="wrap_content" | ||||||
|  |       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" /> | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     <TextView | ||||||
|  |       android:id="@+id/achievement_level" | ||||||
|  |       android:layout_width="wrap_content" | ||||||
|  |       android:layout_height="wrap_content" | ||||||
|  |       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" | ||||||
|  |       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: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" | ||||||
|  |       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" | ||||||
|  |       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"> | ||||||
|  | 
 | ||||||
|  |       <ProgressBar | ||||||
|  |         android:id="@+id/images_uploaded_progressbar" | ||||||
|  |         style="?android:attr/progressBarStyleHorizontal" | ||||||
|         android:layout_width="match_parent" |         android:layout_width="match_parent" | ||||||
|         android:layout_height="wrap_content" |         android:layout_height="wrap_content" | ||||||
|         android:padding="10dp" |         android:layout_centerInParent="true" | ||||||
|         tools:text="Achievements of user : Ashish" /> |         android:progressDrawable="@android:drawable/progress_horizontal" | ||||||
| 
 |         android:progressBackgroundTintMode="multiply" | ||||||
| 
 |         android:progressTint="#5ce65c" | ||||||
|         <RelativeLayout |         tools:progress="50" /> | ||||||
|           android:layout_width="match_parent" | 
 | ||||||
|           android:layout_height="wrap_content" |       <TextView | ||||||
|           android:background="?attr/achievementBackground" |         android:id="@+id/imageUploadedTVCount" | ||||||
|           android:orientation="vertical"> |         style="?android:textAppearanceMedium" | ||||||
| 
 |         android:layout_width="wrap_content" | ||||||
|           <TextView |         android:layout_height="wrap_content" | ||||||
|             style="?android:textAppearanceLarge" |         android:layout_centerInParent="true" | ||||||
|             android:layout_width="wrap_content" |         android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||||
|             android:layout_height="wrap_content" |         tools:text="10/15" /> | ||||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal" | 
 | ||||||
|             android:layout_marginTop="@dimen/activity_margin_horizontal" |     </RelativeLayout> | ||||||
|             android:text="@string/level" | 
 | ||||||
|             android:id="@+id/achievement_level" |     <com.google.android.material.divider.MaterialDivider | ||||||
|             android:textAllCaps="true"/> |       android:id="@+id/materialDivider" | ||||||
| 
 |       android:layout_width="match_parent" | ||||||
|           <ImageView |       android:layout_height="wrap_content" | ||||||
|             android:id="@+id/achievement_info" |       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||||
|             android:layout_width="wrap_content" |       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||||
|             android:layout_height="wrap_content" |       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||||
|             android:layout_marginTop="@dimen/activity_margin_vertical" |       app:layout_constraintEnd_toEndOf="parent" | ||||||
|             android:layout_marginEnd="@dimen/activity_margin_horizontal" |       app:layout_constraintStart_toStartOf="parent" | ||||||
|             android:layout_alignParentEnd="true" |       app:layout_constraintTop_toBottomOf="@+id/rl_images_Uploaded" /> | ||||||
|             app:srcCompat="@drawable/ic_info_outline_24dp" | 
 | ||||||
|             android:layout_marginVertical="@dimen/activity_margin_vertical" | 
 | ||||||
|             app:tint="@color/black" /> |     <!-- Image's Not Reverted --> | ||||||
| 
 |     <TextView | ||||||
|           <androidx.constraintlayout.widget.ConstraintLayout |       android:id="@+id/images_reverted_text" | ||||||
|             android:id="@+id/badge_layout" |       style="?android:textAppearanceMedium" | ||||||
|             android:layout_width="wrap_content" |       android:layout_width="wrap_content" | ||||||
|             android:layout_height="wrap_content" |       android:layout_height="wrap_content" | ||||||
|             android:layout_below="@id/achievement_info" |       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||||
|             android:layout_centerHorizontal="true"> |       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||||
| 
 |       android:text="@string/image_reverts" | ||||||
|             <ImageView |       android:textStyle="bold" | ||||||
|               android:id="@+id/achievement_badge_image" |       app:layout_constraintStart_toStartOf="parent" | ||||||
|               android:layout_width="wrap_content" |       app:layout_constraintTop_toBottomOf="@+id/materialDivider" /> | ||||||
|               android:layout_height="wrap_content" | 
 | ||||||
|               app:layout_constraintLeft_toLeftOf="parent" |     <TextView | ||||||
|               app:layout_constraintRight_toRightOf="parent" |       android:id="@+id/images_revert_limit_text" | ||||||
|               app:layout_constraintTop_toTopOf="parent" |       android:layout_width="0dp" | ||||||
|               app:srcCompat="@drawable/badge" /> |       android:layout_height="wrap_content" | ||||||
| 
 |       android:layout_marginStart="4dp" | ||||||
|             <TextView |       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||||
|               android:id="@+id/achievement_badge_text" |       android:text="@string/achievements_revert_limit_message" | ||||||
|               android:layout_width="wrap_content" |       app:layout_constraintBottom_toBottomOf="@+id/images_reverted_text" | ||||||
|               android:layout_height="wrap_content" |       app:layout_constraintEnd_toStartOf="@+id/images_reverted_info_icon" | ||||||
|               android:textAlignment="center" |       app:layout_constraintStart_toEndOf="@+id/images_reverted_text" | ||||||
|               android:textColor="@color/achievement_badge_text" |       app:layout_constraintTop_toTopOf="@+id/images_reverted_text" /> | ||||||
|               android:textSize="75sp" | 
 | ||||||
|               app:layout_constraintBottom_toBottomOf="@+id/achievement_badge_image" |     <ImageView | ||||||
|               app:layout_constraintEnd_toEndOf="@+id/achievement_badge_image" |       android:id="@+id/images_reverted_info_icon" | ||||||
|               app:layout_constraintStart_toStartOf="@+id/achievement_badge_image" |       android:layout_width="wrap_content" | ||||||
|               app:layout_constraintTop_toTopOf="@+id/achievement_badge_image" |       android:layout_height="wrap_content" | ||||||
|               app:layout_constraintVertical_bias="0.58" /> |       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||||
|           </androidx.constraintlayout.widget.ConstraintLayout> |       app:layout_constraintBottom_toBottomOf="@+id/images_revert_limit_text" | ||||||
| 
 |       app:layout_constraintEnd_toEndOf="parent" | ||||||
|           <RelativeLayout |       app:layout_constraintTop_toTopOf="@+id/images_revert_limit_text" | ||||||
|             android:id="@+id/layout_image_uploaded" |       app:srcCompat="@drawable/ic_info_outline_24dp" | ||||||
|             android:layout_width="match_parent" |       app:tint="@color/black" /> | ||||||
|             android:layout_height="wrap_content" | 
 | ||||||
|             android:layout_below="@+id/badge_layout" |     <!--  Image's Not Reverted Progress Bar  --> | ||||||
|             android:layout_marginEnd="@dimen/activity_margin_horizontal" |     <RelativeLayout | ||||||
|             android:layout_marginLeft="@dimen/activity_margin_horizontal" |       android:id="@+id/rl_images_reverted" | ||||||
|             android:layout_marginRight="@dimen/activity_margin_horizontal" |       android:layout_width="match_parent" | ||||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal"> |       android:layout_height="wrap_content" | ||||||
| 
 |       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||||
| 
 |       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||||
|             <LinearLayout |       app:layout_constraintEnd_toEndOf="parent" | ||||||
|               android:layout_width="wrap_content" |       app:layout_constraintStart_toStartOf="parent" | ||||||
|               android:layout_height="wrap_content" |       app:layout_constraintTop_toBottomOf="@+id/images_reverted_text"> | ||||||
|               android:id="@+id/images_upload_info" | 
 | ||||||
|               android:orientation="horizontal" |       <ProgressBar | ||||||
|               > |         android:id="@+id/image_reverts_progressbar" | ||||||
| 
 |         style="?android:attr/progressBarStyleHorizontal" | ||||||
|               <TextView |         android:layout_width="match_parent" | ||||||
|                 style="?android:textAppearanceMedium" |         android:layout_height="wrap_content" | ||||||
|                 android:layout_width="wrap_content" |         android:layout_centerInParent="true" | ||||||
|                 android:layout_height="wrap_content" |         android:progressDrawable="@android:drawable/progress_horizontal" | ||||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" |         android:progressBackgroundTintMode="multiply" | ||||||
|                 android:id="@+id/images_upload_text_param" |         android:progressTint="#5ce65c" | ||||||
|                 android:layout_marginTop="@dimen/achievements_activity_margin_vertical" |         tools:progress="50" /> | ||||||
|                 android:text="@string/images_uploaded" /> | 
 | ||||||
| 
 |       <TextView | ||||||
|               <ImageView |         android:id="@+id/imageRevertTVCount" | ||||||
|                 android:layout_width="@dimen/quarter_standard_height" |         style="?android:textAppearanceMedium" | ||||||
|                 android:layout_height="@dimen/quarter_standard_height" |         android:layout_width="wrap_content" | ||||||
|                 android:layout_marginTop="@dimen/activity_margin_horizontal" |         android:layout_height="wrap_content" | ||||||
|                 android:layout_marginRight="@dimen/activity_margin_horizontal" |         android:layout_centerInParent="true" | ||||||
|                 android:layout_marginEnd="@dimen/activity_margin_horizontal" |         tools:text="10/15" /> | ||||||
|                 app:srcCompat="@drawable/ic_info_outline_24dp" |     </RelativeLayout> | ||||||
|                 android:layout_marginLeft="@dimen/activity_margin_horizontal" | 
 | ||||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" | 
 | ||||||
|                 app:tint="@color/primaryLightColor" /> |     <com.google.android.material.divider.MaterialDivider | ||||||
| 
 |       android:id="@+id/materialDivider1" | ||||||
|             </LinearLayout> |       android:layout_width="match_parent" | ||||||
| 
 |       android:layout_height="wrap_content" | ||||||
|             <FrameLayout |       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||||
|               android:layout_width="@dimen/dimen_40" |       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||||
|               android:layout_height="@dimen/dimen_40" |       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||||
|               android:layout_alignParentEnd="true" |       app:layout_constraintEnd_toEndOf="parent" | ||||||
|               android:layout_marginEnd="32dp"> |       app:layout_constraintStart_toStartOf="parent" | ||||||
| 
 |       app:layout_constraintTop_toBottomOf="@+id/rl_images_reverted" /> | ||||||
|               <com.google.android.material.progressindicator.CircularProgressIndicator | 
 | ||||||
|                 android:id="@+id/images_uploaded_progressbar" |     <!-- Image Used --> | ||||||
|                 android:layout_width="@dimen/dimen_40" |     <TextView | ||||||
|                 android:layout_height="@dimen/dimen_40" |       android:id="@+id/images_used_tv" | ||||||
|                 android:indeterminate="false" |       style="?android:textAppearanceMedium" | ||||||
|                 android:layout_marginEnd="@dimen/large_gap" |       android:layout_width="wrap_content" | ||||||
|                 app:showAnimationBehavior="outward" |       android:layout_height="wrap_content" | ||||||
|                 app:indicatorColor="@color/primaryColor" |       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||||
|                 app:indicatorSize="32dp" |       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||||
|                 app:trackThickness="@dimen/progressbar_stroke" |       android:text="@string/images_used_by_wiki" | ||||||
|                 app:trackColor="#B7B6B6" |       android:textStyle="bold" | ||||||
|                 android:visibility="gone"/> |       app:layout_constraintStart_toStartOf="parent" | ||||||
| 
 |       app:layout_constraintTop_toBottomOf="@+id/materialDivider1" /> | ||||||
|               <androidx.appcompat.widget.AppCompatTextView | 
 | ||||||
|                 android:id="@+id/tv_uploaded_images" |     <ImageView | ||||||
|                 android:layout_width="match_parent" |       android:id="@+id/images_used_by_wiki_info_icon" | ||||||
|                 android:layout_height="match_parent" |       android:layout_width="wrap_content" | ||||||
|                 android:padding="@dimen/progressbar_padding" |       android:layout_height="wrap_content" | ||||||
|                 android:gravity="center" |       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||||
|                 android:maxLines="1" |       app:layout_constraintBottom_toBottomOf="@+id/images_used_tv" | ||||||
|                 android:textColor="@color/secondaryColor" |       app:layout_constraintEnd_toEndOf="parent" | ||||||
|                 app:autoSizeMaxTextSize="@dimen/progressbar_text" |       app:layout_constraintTop_toTopOf="@+id/images_used_tv" | ||||||
|                 app:autoSizeMinTextSize="2sp" |       app:srcCompat="@drawable/ic_info_outline_24dp" | ||||||
|                 app:autoSizeStepGranularity="1sp" |       app:tint="@color/black" /> | ||||||
|                 app:autoSizeTextType="uniform" /> | 
 | ||||||
| 
 |     <!--  Image's Used Progress Bar  --> | ||||||
|             </FrameLayout> |     <RelativeLayout | ||||||
| 
 |       android:id="@+id/rl_images_used" | ||||||
| 
 |       android:layout_width="match_parent" | ||||||
| 
 |       android:layout_height="wrap_content" | ||||||
|           </RelativeLayout> |       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||||
| 
 |       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||||
|           <RelativeLayout |       app:layout_constraintEnd_toEndOf="parent" | ||||||
|             android:id="@+id/layout_image_reverts" |       app:layout_constraintStart_toStartOf="parent" | ||||||
|             android:layout_width="match_parent" |       app:layout_constraintTop_toBottomOf="@+id/images_used_tv"> | ||||||
|             android:layout_height="wrap_content" | 
 | ||||||
|             android:layout_marginTop="@dimen/tiny_margin" |       <ProgressBar | ||||||
|             android:layout_below="@+id/layout_image_uploaded" |         android:id="@+id/images_used_by_wiki_progress_bar" | ||||||
|             android:layout_marginEnd="@dimen/activity_margin_horizontal" |         style="?android:attr/progressBarStyleHorizontal" | ||||||
|             android:layout_marginLeft="@dimen/activity_margin_horizontal" |         android:layout_width="match_parent" | ||||||
|             android:layout_marginRight="@dimen/activity_margin_horizontal" |         android:layout_height="wrap_content" | ||||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal"> |         android:layout_centerInParent="true" | ||||||
| 
 |         android:progressDrawable="@android:drawable/progress_horizontal" | ||||||
|             <LinearLayout |         android:progressBackgroundTintMode="multiply" | ||||||
|               android:layout_width="wrap_content" |         android:progressTint="#5ce65c" | ||||||
|               android:layout_height="wrap_content" |         tools:progress="50" /> | ||||||
|               android:id="@+id/images_reverted_info" | 
 | ||||||
|               android:orientation="horizontal" |       <TextView | ||||||
|               > |         android:id="@+id/imagesUsedCount" | ||||||
| 
 |         style="?android:textAppearanceMedium" | ||||||
|               <TextView |         android:layout_width="wrap_content" | ||||||
|                 style="?android:textAppearanceMedium" |         android:layout_height="wrap_content" | ||||||
|                 android:layout_width="wrap_content" |         android:layout_centerInParent="true" | ||||||
|                 android:layout_height="wrap_content" |         tools:text="10/15" /> | ||||||
|                 android:id="@+id/images_reverted_text" |     </RelativeLayout> | ||||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" | 
 | ||||||
|                 android:text="@string/image_reverts" /> |     <com.google.android.material.divider.MaterialDivider | ||||||
| 
 |       android:id="@+id/materialDivider2" | ||||||
|               <ImageView |       android:layout_width="match_parent" | ||||||
|                 android:layout_width="@dimen/medium_width" |       android:layout_height="wrap_content" | ||||||
|                 android:layout_height="@dimen/medium_height" |       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||||
|                 android:layout_marginTop="@dimen/activity_margin_horizontal" |       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||||
|                 android:layout_marginRight="@dimen/activity_margin_horizontal" |       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||||
|                 android:layout_marginEnd="@dimen/activity_margin_horizontal" |       app:layout_constraintEnd_toEndOf="parent" | ||||||
|                 app:srcCompat="@drawable/ic_info_outline_24dp" |       app:layout_constraintStart_toStartOf="parent" | ||||||
|                 android:layout_marginLeft="@dimen/activity_margin_horizontal" |       app:layout_constraintTop_toBottomOf="@+id/rl_images_used" /> | ||||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" app:tint="@color/primaryLightColor" /> | 
 | ||||||
| 
 |     <!-- Statistics --> | ||||||
|             </LinearLayout> |     <TextView | ||||||
| 
 |       android:id="@+id/tv_statistics" | ||||||
| 
 |       android:layout_width="wrap_content" | ||||||
|             <TextView |       android:layout_height="wrap_content" | ||||||
|               android:layout_width="wrap_content" |       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||||
|               android:layout_height="wrap_content" |       android:layout_marginTop="@dimen/activity_margin_horizontal" | ||||||
|               android:text="@string/achievements_revert_limit_message" |       android:text="@string/badges" | ||||||
|               android:textSize="@dimen/small_text" |       android:textAllCaps="true" | ||||||
|               android:id="@+id/images_revert_limit_text" |       android:textSize="16sp" | ||||||
|               android:layout_marginStart="@dimen/activity_margin_horizontal" |       android:textStyle="bold" | ||||||
|               android:layout_below="@id/images_reverted_info"/> |       app:layout_constraintStart_toStartOf="parent" | ||||||
| 
 |       app:layout_constraintTop_toBottomOf="@+id/materialDivider2" /> | ||||||
|             <FrameLayout | 
 | ||||||
|               android:layout_width="@dimen/dimen_40" |     <LinearLayout | ||||||
|               android:layout_height="@dimen/dimen_40" |       android:id="@+id/badgesItems" | ||||||
|               android:layout_alignParentEnd="true" |       android:layout_width="match_parent" | ||||||
|               android:layout_marginEnd="32dp"> |       android:layout_height="wrap_content" | ||||||
| 
 |       android:layout_marginStart="@dimen/activity_margin_horizontal" | ||||||
|               <com.google.android.material.progressindicator.CircularProgressIndicator |       android:layout_marginEnd="@dimen/activity_margin_horizontal" | ||||||
|                 android:id="@+id/image_reverts_progressbar" |       android:orientation="horizontal" | ||||||
|                 android:layout_width="@dimen/dimen_40" |       android:padding="@dimen/activity_margin_horizontal" | ||||||
|                 android:layout_height="@dimen/dimen_40" |       app:layout_constraintEnd_toEndOf="parent" | ||||||
|                 android:indeterminate="false" |       app:layout_constraintStart_toStartOf="parent" | ||||||
|                 android:layout_marginEnd="@dimen/large_gap" |       app:layout_constraintTop_toBottomOf="@+id/tv_statistics"> | ||||||
|                 app:showAnimationBehavior="outward" | 
 | ||||||
|                 app:indicatorColor="@color/primaryColor" |       <!--Nearby Places Statistics--> | ||||||
|                 app:indicatorSize="32dp" |       <ImageView | ||||||
|                 app:trackThickness="@dimen/progressbar_stroke" |         android:id="@+id/wikidata_edits_icon" | ||||||
|                 app:trackColor="#B7B6B6" |         android:layout_width="@dimen/dimen_40" | ||||||
|                 android:visibility="gone"/> |         android:layout_height="@dimen/dimen_40" | ||||||
| 
 |         android:layout_marginEnd="@dimen/large_gap" | ||||||
|               <androidx.appcompat.widget.AppCompatTextView |         app:srcCompat="@drawable/ic_custom_map_marker" /> | ||||||
|                 android:id="@+id/tv_reverted_images" | 
 | ||||||
|                 android:layout_width="match_parent" |       <!--Featured Image Statistics--> | ||||||
|                 android:layout_height="match_parent" |       <ImageView | ||||||
|                 android:padding="@dimen/progressbar_padding" |         android:id="@+id/featured_image_icon" | ||||||
|                 android:gravity="center" |         android:layout_width="@dimen/dimen_40" | ||||||
|                 android:maxLines="1" |         android:layout_height="@dimen/dimen_40" | ||||||
|                 android:textColor="@color/secondaryColor" |         android:layout_marginEnd="@dimen/large_gap" | ||||||
|                 app:autoSizeMaxTextSize="@dimen/progressbar_text" |         app:srcCompat="@drawable/featured" /> | ||||||
|                 app:autoSizeMinTextSize="2sp" | 
 | ||||||
|                 app:autoSizeStepGranularity="1sp" |       <!--Quality Image Statistics--> | ||||||
|                 app:autoSizeTextType="uniform" /> |       <ImageView | ||||||
|             </FrameLayout> |         android:id="@+id/quality_image_icon" | ||||||
|           </RelativeLayout> |         android:layout_width="@dimen/dimen_40" | ||||||
| 
 |         android:layout_height="@dimen/dimen_40" | ||||||
|           <RelativeLayout |         android:layout_marginEnd="@dimen/large_gap" | ||||||
|             android:id="@+id/layout_image_used_by_wiki" |         app:srcCompat="@drawable/ic_quality_images_logo" /> | ||||||
|             android:layout_width="match_parent" | 
 | ||||||
|             android:layout_height="wrap_content" |       <!--Thank Image Statistics--> | ||||||
|             android:layout_marginTop="@dimen/tiny_margin" |       <ImageView | ||||||
|             android:layout_below="@+id/layout_image_reverts" |         android:id="@+id/thanks_image_icon" | ||||||
|             android:layout_marginBottom="@dimen/activity_margin_vertical" |         android:layout_width="@dimen/dimen_40" | ||||||
|             android:layout_marginEnd="@dimen/activity_margin_horizontal" |         android:layout_height="@dimen/dimen_40" | ||||||
|             android:layout_marginLeft="@dimen/activity_margin_horizontal" |         android:layout_marginEnd="@dimen/large_gap" | ||||||
|             android:layout_marginRight="@dimen/activity_margin_horizontal" |         app:srcCompat="@drawable/ic_thanks" /> | ||||||
|             android:layout_marginStart="@dimen/activity_margin_horizontal"> |     </LinearLayout> | ||||||
| 
 | 
 | ||||||
|             <LinearLayout |     <ProgressBar | ||||||
|               android:layout_width="wrap_content" |       android:id="@+id/progressBar" | ||||||
|               android:layout_height="wrap_content" |       android:layout_width="wrap_content" | ||||||
|               android:id="@+id/images_used_by_wiki_info" |       android:layout_height="wrap_content" | ||||||
|               android:orientation="horizontal"> |       app:layout_constraintBottom_toBottomOf="parent" | ||||||
| 
 |       app:layout_constraintEnd_toEndOf="parent" | ||||||
|               <TextView |       app:layout_constraintStart_toStartOf="parent" | ||||||
|                 style="?android:textAppearanceMedium" |       app:layout_constraintTop_toTopOf="parent" | ||||||
|                 android:layout_width="wrap_content" |       app:layout_constraintVertical_bias="0.5" | ||||||
|                 android:layout_height="wrap_content" |       app:layout_constraintHorizontal_bias="0.5" | ||||||
|                 android:id="@+id/images_used_by_wiki_text" |       android:visibility="gone"/> | ||||||
|                 android:layout_marginStart="@dimen/activity_margin_horizontal" | 
 | ||||||
|                 android:layout_marginTop="@dimen/achievements_activity_margin_vertical" |   </androidx.constraintlayout.widget.ConstraintLayout> | ||||||
|                 android:text="@string/images_used_by_wiki" /> | </ScrollView> | ||||||
| 
 |  | ||||||
|               <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> |  | ||||||
| 
 |  | ||||||
|             <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" |  | ||||||
|                 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> |  | ||||||
| 
 |  | ||||||
|           <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_constraintTop_toTopOf="parent" |  | ||||||
|                 app:layout_constraintRight_toRightOf="parent" |  | ||||||
|                 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_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 | * Okkerem | ||||||
| * Oyuncu | * Oyuncu | ||||||
| * Rapsar | * Rapsar | ||||||
|  | * RuzDD | ||||||
| * SaldırganSincap | * SaldırganSincap | ||||||
| * Sayginer | * Sayginer | ||||||
| * Sezgin İbiş | * Sezgin İbiş | ||||||
|  | @ -146,6 +147,7 @@ | ||||||
|   <string name="categories_search_text_hint">Kategori ara</string> |   <string name="categories_search_text_hint">Kategori ara</string> | ||||||
|   <string name="depicts_search_text_hint">Medyanızın tasvir ettiği ögeleri arayın (dağ, Tac Mahal, vb.)</string> |   <string name="depicts_search_text_hint">Medyanızın tasvir ettiği ögeleri arayın (dağ, Tac Mahal, vb.)</string> | ||||||
|   <string name="menu_save_categories">Kaydet</string> |   <string name="menu_save_categories">Kaydet</string> | ||||||
|  |   <string name="menu_overflow_desc">Taşma menüsü</string> | ||||||
|   <string name="refresh_button">Yenile</string> |   <string name="refresh_button">Yenile</string> | ||||||
|   <string name="display_list_button">Liste</string> |   <string name="display_list_button">Liste</string> | ||||||
|   <string name="contributions_subtitle_zero">!Henüz yükleme yok)</string> |   <string name="contributions_subtitle_zero">!Henüz yükleme yok)</string> | ||||||
|  | @ -800,6 +802,7 @@ | ||||||
|   <string name="please_enter_some_comments">Lütfen bir yorum girin</string> |   <string name="please_enter_some_comments">Lütfen bir yorum girin</string> | ||||||
|   <string name="talk">Tartışma</string> |   <string name="talk">Tartışma</string> | ||||||
|   <string name="write_something_about_the_item">\' %1$s \' öğesi hakkında bir şeyler yazın. Herkes tarafından görülebilir olacaktır.</string> |   <string name="write_something_about_the_item">\' %1$s \' öğesi hakkında bir şeyler yazın. Herkes tarafından görülebilir olacaktır.</string> | ||||||
|  |   <string name="does_not_exist_anymore_no_picture_can_ever_be_taken_of_it">\'%1$s\' artık yok, dolayısı ile resmi çekilemez.</string> | ||||||
|   <string name="other_problem_or_information_please_explain_below">Diğer sorun veya bilgi (lütfen aşağıda açıklayınız).</string> |   <string name="other_problem_or_information_please_explain_below">Diğer sorun veya bilgi (lütfen aşağıda açıklayınız).</string> | ||||||
|   <string name="feedback_destination_note">Geri bildiriminiz aşağıdaki wiki sayfasına gönderilir:  <a href=\"https://commons.wikimedia.org/wiki/Commons:Mobile_app/Feedback\">Commons:Mobile app/Feedback</a></string> |   <string name="feedback_destination_note">Geri bildiriminiz aşağıdaki wiki sayfasına gönderilir:  <a href=\"https://commons.wikimedia.org/wiki/Commons:Mobile_app/Feedback\">Commons:Mobile app/Feedback</a></string> | ||||||
|   <string name="are_you_sure_that_you_want_cancel_all_the_uploads">Tüm yüklemeleri iptal etmek istediğinizden emin misiniz?</string> |   <string name="are_you_sure_that_you_want_cancel_all_the_uploads">Tüm yüklemeleri iptal etmek istediğinizden emin misiniz?</string> | ||||||
|  | @ -807,5 +810,10 @@ | ||||||
|   <string name="uploads">Yüklemeler</string> |   <string name="uploads">Yüklemeler</string> | ||||||
|   <string name="pending">Beklemede</string> |   <string name="pending">Beklemede</string> | ||||||
|   <string name="failed">Başarısız</string> |   <string name="failed">Başarısız</string> | ||||||
|  |   <string name="custom_selector_delete">Sil</string> | ||||||
|  |   <string name="custom_selector_cancel">İptal</string> | ||||||
|  |   <string name="custom_selector_folder_deleted_success">%1$s klasörü başarıyla silindi</string> | ||||||
|  |   <string name="custom_selector_folder_deleted_failure">%1$s klasörü silinemedi</string> | ||||||
|   <string name="green_pin">Bu yerin zaten bir resmi var.</string> |   <string name="green_pin">Bu yerin zaten bir resmi var.</string> | ||||||
|  |   <string name="grey_pin">Şimdi bu yerin bir resime sahip olup olmadığı denetleniyor.</string> | ||||||
| </resources> | </resources> | ||||||
|  |  | ||||||
|  | @ -371,11 +371,13 @@ | ||||||
|   <string name="delete">Delete</string> |   <string name="delete">Delete</string> | ||||||
|   <string name="Achievements">Achievements</string> |   <string name="Achievements">Achievements</string> | ||||||
|   <string name="Profile">Profile</string> |   <string name="Profile">Profile</string> | ||||||
|  |   <string name="badges">Badges</string> | ||||||
|   <string name="statistics">Statistics</string> |   <string name="statistics">Statistics</string> | ||||||
|   <string name="statistics_thanks">Thanks Received</string> |   <string name="statistics_thanks">Thanks Received</string> | ||||||
|   <string name="statistics_featured">Featured Images</string> |   <string name="statistics_featured">Featured Images</string> | ||||||
|   <string name="statistics_wikidata_edits">Images via \"Nearby Places\"</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="images_uploaded">Images Uploaded</string> | ||||||
|   <string name="image_reverts">Images Not Reverted</string> |   <string name="image_reverts">Images Not Reverted</string> | ||||||
|   <string name="images_used_by_wiki">Images Used</string> |   <string name="images_used_by_wiki">Images Used</string> | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| <resources> | <resources> | ||||||
| 
 | 
 | ||||||
|     <style name="DarkAppTheme" parent="Theme.AppCompat.NoActionBar"> |     <style name="DarkAppTheme" parent="Theme.MaterialComponents.NoActionBar.Bridge"> | ||||||
|         <item name="contributionsListBackground">@color/contributionListDarkBackground</item> |         <item name="contributionsListBackground">@color/contributionListDarkBackground</item> | ||||||
|         <item name="tabBackground">@color/contributionListDarkBackground</item> |         <item name="tabBackground">@color/contributionListDarkBackground</item> | ||||||
|         <item name="tabIndicatorColor">@color/white</item> |         <item name="tabIndicatorColor">@color/white</item> | ||||||
|  | @ -62,7 +62,7 @@ | ||||||
|         <item name="android:splitMotionEvents">false</item> |         <item name="android:splitMotionEvents">false</item> | ||||||
|     </style> |     </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="contributionsListBackground">@color/white</item> | ||||||
|         <item name="tabBackground">@color/card_light_grey</item> |         <item name="tabBackground">@color/card_light_grey</item> | ||||||
|         <item name="tabIndicatorColor">@color/primaryDarkColor</item> |         <item name="tabIndicatorColor">@color/primaryDarkColor</item> | ||||||
|  | @ -73,6 +73,7 @@ | ||||||
|         <item name="drawerHeaderBackground">@color/drawerHeader_background_light</item> |         <item name="drawerHeaderBackground">@color/drawerHeader_background_light</item> | ||||||
|         <item name="tutorialBackground">@color/tutorial_background_light</item> |         <item name="tutorialBackground">@color/tutorial_background_light</item> | ||||||
|         <item name="icon">@color/secondaryTextColor</item> |         <item name="icon">@color/secondaryTextColor</item> | ||||||
|  |         <item name="colorPrimary">@color/primaryDarkColor</item> | ||||||
|         <item name="colorPrimaryDark">@color/primaryDarkColor</item> |         <item name="colorPrimaryDark">@color/primaryDarkColor</item> | ||||||
|         <item name="colorAccent">@color/primaryColor</item> |         <item name="colorAccent">@color/primaryColor</item> | ||||||
|         <item name="colorButtonNormal">@color/primaryColor</item> |         <item name="colorButtonNormal">@color/primaryColor</item> | ||||||
|  |  | ||||||
|  | @ -69,7 +69,7 @@ public abstract class MockWebServerTest { | ||||||
|                 .baseUrl(url) |                 .baseUrl(url) | ||||||
|                 .callbackExecutor(new ImmediateExecutor()) |                 .callbackExecutor(new ImmediateExecutor()) | ||||||
|                 .client(okHttpClient) |                 .client(okHttpClient) | ||||||
|                 .addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson())) |                 .addConverterFactory(GsonConverterFactory.create(GsonUtil.INSTANCE.getDefaultGson())) | ||||||
|                 .build() |                 .build() | ||||||
|                 .create(clazz); |                 .create(clazz); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -49,13 +49,13 @@ class CampaignsPresenterTest { | ||||||
|         campaignsSingle = Single.just(campaignResponseDTO) |         campaignsSingle = Single.just(campaignResponseDTO) | ||||||
|         campaignsPresenter = CampaignsPresenter(okHttpJsonApiClient, testScheduler, testScheduler) |         campaignsPresenter = CampaignsPresenter(okHttpJsonApiClient, testScheduler, testScheduler) | ||||||
|         campaignsPresenter.onAttachView(view) |         campaignsPresenter.onAttachView(view) | ||||||
|         Mockito.`when`(okHttpJsonApiClient.campaigns).thenReturn(campaignsSingle) |         Mockito.`when`(okHttpJsonApiClient.getCampaigns()).thenReturn(campaignsSingle) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun getCampaignsTestNoCampaigns() { |     fun getCampaignsTestNoCampaigns() { | ||||||
|         campaignsPresenter.getCampaigns() |         campaignsPresenter.getCampaigns() | ||||||
|         verify(okHttpJsonApiClient).campaigns |         verify(okHttpJsonApiClient).getCampaigns() | ||||||
|         testScheduler.triggerActions() |         testScheduler.triggerActions() | ||||||
|         verify(view).showCampaigns(null) |         verify(view).showCampaigns(null) | ||||||
|     } |     } | ||||||
|  | @ -77,7 +77,7 @@ class CampaignsPresenterTest { | ||||||
|         Mockito.`when`(campaign.endDate).thenReturn(endDateString) |         Mockito.`when`(campaign.endDate).thenReturn(endDateString) | ||||||
|         Mockito.`when`(campaign.startDate).thenReturn(startDateString) |         Mockito.`when`(campaign.startDate).thenReturn(startDateString) | ||||||
|         Mockito.`when`(campaignResponseDTO.campaigns).thenReturn(campaigns) |         Mockito.`when`(campaignResponseDTO.campaigns).thenReturn(campaigns) | ||||||
|         verify(okHttpJsonApiClient).campaigns |         verify(okHttpJsonApiClient).getCampaigns() | ||||||
|         testScheduler.triggerActions() |         testScheduler.triggerActions() | ||||||
|         verify(view).showCampaigns(campaign) |         verify(view).showCampaigns(campaign) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -1,32 +0,0 @@ | ||||||
| package fr.free.nrw.commons.filepicker; |  | ||||||
| 
 |  | ||||||
| import android.database.Cursor; |  | ||||||
| import android.database.MatrixCursor; |  | ||||||
| import android.net.Uri; |  | ||||||
| import android.provider.OpenableColumns; |  | ||||||
| import androidx.core.content.FileProvider; |  | ||||||
| import org.robolectric.annotation.Implementation; |  | ||||||
| import org.robolectric.annotation.Implements; |  | ||||||
| 
 |  | ||||||
| @Implements(FileProvider.class) |  | ||||||
| public class ShadowFileProvider { |  | ||||||
| 
 |  | ||||||
|     @Implementation |  | ||||||
|     public Cursor query(final Uri uri, final String[] projection, final String selection, |  | ||||||
|         final String[] selectionArgs, |  | ||||||
|         final String sortOrder) { |  | ||||||
| 
 |  | ||||||
|         if (uri == null) { |  | ||||||
|             return null; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         final String[] columns = {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}; |  | ||||||
|         final Object[] values = {"dummy", 500}; |  | ||||||
|         final MatrixCursor cursor = new MatrixCursor(columns, 1); |  | ||||||
| 
 |  | ||||||
|         if (!uri.equals(Uri.EMPTY)) { |  | ||||||
|             cursor.addRow(values); |  | ||||||
|         } |  | ||||||
|         return cursor; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -0,0 +1,36 @@ | ||||||
|  | package fr.free.nrw.commons.filepicker | ||||||
|  | 
 | ||||||
|  | import android.database.Cursor | ||||||
|  | import android.database.MatrixCursor | ||||||
|  | import android.net.Uri | ||||||
|  | import android.provider.OpenableColumns | ||||||
|  | import androidx.core.content.FileProvider | ||||||
|  | import org.robolectric.annotation.Implementation | ||||||
|  | import org.robolectric.annotation.Implements | ||||||
|  | 
 | ||||||
|  | @Implements(FileProvider::class) | ||||||
|  | class ShadowFileProvider { | ||||||
|  | 
 | ||||||
|  |     @Implementation | ||||||
|  |     fun query( | ||||||
|  |         uri: Uri?, | ||||||
|  |         projection: Array<String>?, | ||||||
|  |         selection: String?, | ||||||
|  |         selectionArgs: Array<String>?, | ||||||
|  |         sortOrder: String? | ||||||
|  |     ): Cursor? { | ||||||
|  | 
 | ||||||
|  |         if (uri == null) { | ||||||
|  |             return null | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         val columns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) | ||||||
|  |         val values = arrayOf<Any>("dummy", 500) | ||||||
|  |         val cursor = MatrixCursor(columns, 1) | ||||||
|  | 
 | ||||||
|  |         if (uri != Uri.EMPTY) { | ||||||
|  |             cursor.addRow(values) | ||||||
|  |         } | ||||||
|  |         return cursor | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -30,8 +30,7 @@ class UserClientTest { | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun isUserBlockedFromCommonsForInfinitelyBlockedUser() { |     fun isUserBlockedFromCommonsForInfinitelyBlockedUser() { | ||||||
|         val userInfo = Mockito.mock(UserInfo::class.java) |         val userInfo = UserInfo(blockexpiry = "infinite") | ||||||
|         Mockito.`when`(userInfo.blockexpiry()).thenReturn("infinite") |  | ||||||
|         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) |         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) | ||||||
|         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) |         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) | ||||||
|         val mockResponse = Mockito.mock(MwQueryResponse::class.java) |         val mockResponse = Mockito.mock(MwQueryResponse::class.java) | ||||||
|  | @ -49,8 +48,7 @@ class UserClientTest { | ||||||
|         val currentDate = Date() |         val currentDate = Date() | ||||||
|         val expiredDate = Date(currentDate.time + 10000) |         val expiredDate = Date(currentDate.time + 10000) | ||||||
| 
 | 
 | ||||||
|         val userInfo = Mockito.mock(UserInfo::class.java) |         val userInfo = UserInfo(blockexpiry = DateUtil.iso8601DateFormat(expiredDate)) | ||||||
|         Mockito.`when`(userInfo.blockexpiry()).thenReturn(DateUtil.iso8601DateFormat(expiredDate)) |  | ||||||
|         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) |         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) | ||||||
|         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) |         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) | ||||||
|         val mockResponse = Mockito.mock(MwQueryResponse::class.java) |         val mockResponse = Mockito.mock(MwQueryResponse::class.java) | ||||||
|  | @ -65,8 +63,7 @@ class UserClientTest { | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     fun isUserBlockedFromCommonsForNeverBlockedUser() { |     fun isUserBlockedFromCommonsForNeverBlockedUser() { | ||||||
|         val userInfo = Mockito.mock(UserInfo::class.java) |         val userInfo = UserInfo(blockexpiry = "") | ||||||
|         Mockito.`when`(userInfo.blockexpiry()).thenReturn("") |  | ||||||
|         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) |         val mwQueryResult = Mockito.mock(MwQueryResult::class.java) | ||||||
|         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) |         Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) | ||||||
|         val mockResponse = Mockito.mock(MwQueryResponse::class.java) |         val mockResponse = Mockito.mock(MwQueryResponse::class.java) | ||||||
|  |  | ||||||
|  | @ -325,7 +325,7 @@ class NearbyParentFragmentUnitTest { | ||||||
|     @Throws(Exception::class) |     @Throws(Exception::class) | ||||||
|     fun testOnDestroy() { |     fun testOnDestroy() { | ||||||
|         fragment.onDestroy() |         fragment.onDestroy() | ||||||
|         verify(wikidataEditListener).setAuthenticationStateListener(null) |         verify(wikidataEditListener).authenticationStateListener = null | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Test @Ignore |     @Test @Ignore | ||||||
|  |  | ||||||
|  | @ -120,26 +120,16 @@ class NotificationClientTest { | ||||||
|     ) = Notification().apply { |     ) = Notification().apply { | ||||||
|         setId(notificationId) |         setId(notificationId) | ||||||
| 
 | 
 | ||||||
|         setTimestamp( |         setTimestamp(Notification.Timestamp().apply { setUtciso8601(timestamp) }) | ||||||
|             Notification.Timestamp().apply { |  | ||||||
|                 setUtciso8601(timestamp) |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
| 
 | 
 | ||||||
|         contents = |         contents = Notification.Contents().apply { | ||||||
|             Notification.Contents().apply { |             setCompactHeader(compactHeader) | ||||||
|                 setCompactHeader(compactHeader) |  | ||||||
| 
 | 
 | ||||||
|                 links = |             links = Notification.Links().apply { | ||||||
|                     Notification.Links().apply { |                 setPrimary(GsonUtil.defaultGson.toJsonTree( | ||||||
|                         setPrimary( |                     Notification.Link().apply { setUrl(primaryUrl) } | ||||||
|                             GsonUtil.getDefaultGson().toJsonTree( |                 )) | ||||||
|                                 Notification.Link().apply { |  | ||||||
|                                     setUrl(primaryUrl) |  | ||||||
|                                 }, |  | ||||||
|                             ), |  | ||||||
|                         ) |  | ||||||
|                     } |  | ||||||
|             } |             } | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -79,7 +79,7 @@ class AchievementsFragmentUnitTests { | ||||||
|         fragmentTransaction.commitNowAllowingStateLoss() |         fragmentTransaction.commitNowAllowingStateLoss() | ||||||
| 
 | 
 | ||||||
|         layoutInflater = LayoutInflater.from(activity) |         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) |         achievements = Achievements(0, 0, 0, 0, 0, 0, 0) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -62,7 +62,7 @@ class UploadPresenterTest { | ||||||
|         `when`(repository.buildContributions()).thenReturn(Observable.just(contribution)) |         `when`(repository.buildContributions()).thenReturn(Observable.just(contribution)) | ||||||
|         uploadableFiles.add(uploadableFile) |         uploadableFiles.add(uploadableFile) | ||||||
|         `when`(view.uploadableFiles).thenReturn(uploadableFiles) |         `when`(view.uploadableFiles).thenReturn(uploadableFiles) | ||||||
|         `when`(uploadableFile.filePath).thenReturn("data://test") |         `when`(uploadableFile.getFilePath()).thenReturn("data://test") | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Neel Doshi
						Neel Doshi