Merge branch 'main' into Migrate-Feedback-Module-from-java-to-kt

This commit is contained in:
Neel Doshi 2024-12-06 22:12:53 +05:30 committed by GitHub
commit 2f13588d73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 3821 additions and 4285 deletions

View file

@ -47,7 +47,7 @@ dependencies {
implementation 'com.jakewharton.timber:timber:4.7.1'
implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0'
implementation "com.google.android.material:material:1.9.0"
implementation "com.google.android.material:material:1.12.0"
implementation 'com.karumi:dexter:5.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'

View file

@ -105,7 +105,7 @@ class AboutActivityTest {
fun testLaunchTranslate() {
Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click())
val langCode = CommonsApplication.instance.languageLookUpTable!!.codes[0]
val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0]
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),

View file

@ -52,12 +52,12 @@ class CampaignsPresenter @Inject constructor(
return
}
okHttpJsonApiClient.campaigns
okHttpJsonApiClient.getCampaigns()
.observeOn(mainThreadScheduler)
.subscribeOn(ioScheduler)
.doOnSubscribe { disposable = it }
.subscribe({ campaignResponseDTO ->
val campaigns = campaignResponseDTO.campaigns?.toMutableList()
val campaigns = campaignResponseDTO?.campaigns?.toMutableList()
if (campaigns.isNullOrEmpty()) {
Timber.e("The campaigns list is empty")
view!!.showCampaigns(null)

View file

@ -44,7 +44,6 @@ import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.logging.HttpLoggingInterceptor.Level
import timber.log.Timber
import java.io.File
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Named
import javax.inject.Singleton
@ -170,14 +169,13 @@ class NetworkingModule {
@Named(NAMED_WIKI_DATA_WIKI_SITE)
fun provideWikidataWikiSite(): WikiSite = WikiSite(BuildConfig.WIKIDATA_URL)
/**
* Gson objects are very heavy. The app should ideally be using just one instance of it instead of creating new instances everywhere.
* @return returns a singleton Gson instance
*/
@Provides
@Singleton
fun provideGson(): Gson = GsonUtil.getDefaultGson()
fun provideGson(): Gson = GsonUtil.defaultGson
@Provides
@Singleton
@ -294,9 +292,8 @@ class NetworkingModule {
@Provides
@Singleton
@Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE)
fun provideLanguageWikipediaSite(): WikiSite {
return WikiSite.forLanguageCode(Locale.getDefault().language)
}
fun provideLanguageWikipediaSite(): WikiSite =
WikiSite.forDefaultLocaleLanguageCode()
companion object {
private const val WIKIDATA_SPARQL_QUERY_URL = "https://query.wikidata.org/sparql"

View file

@ -6,9 +6,9 @@ import android.animation.ValueAnimator
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.Matrix
//noinspection ExifInterface TODO Issue : #5994
import android.media.ExifInterface
import android.os.Bundle
import android.util.Log
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.ImageView
import android.widget.Toast
@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModelProvider
import fr.free.nrw.commons.databinding.ActivityEditBinding
import timber.log.Timber
import java.io.File
import kotlin.math.ceil
/**
* An activity class for editing and rotating images using LLJTran with EXIF attribute preservation.
@ -42,8 +43,11 @@ class EditActivity : AppCompatActivity() {
supportActionBar?.title = ""
val intent = intent
imageUri = intent.getStringExtra("image") ?: ""
vm = ViewModelProvider(this).get(EditViewModel::class.java)
vm = ViewModelProvider(this)[EditViewModel::class.java]
val sourceExif = imageUri.toUri().path?.let { ExifInterface(it) }
//TODO(Deprecation : 'TAG_APERTURE: String' is deprecated. Deprecated in Java) Issue : #6001
// TODO(Deprecation : 'TAG_ISO: String' is deprecated. Deprecated in Java) Issue : #6001
@Suppress("DEPRECATION")
val exifTags =
arrayOf(
ExifInterface.TAG_APERTURE,
@ -88,38 +92,36 @@ class EditActivity : AppCompatActivity() {
private fun init() {
binding.iv.adjustViewBounds = true
binding.iv.scaleType = ImageView.ScaleType.MATRIX
binding.iv.post(
Runnable {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(imageUri, options)
binding.iv.post {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(imageUri, options)
val bitmapWidth = options.outWidth
val bitmapHeight = options.outHeight
val bitmapWidth = options.outWidth
val bitmapHeight = options.outHeight
// Check if the bitmap dimensions exceed a certain threshold
val maxBitmapSize = 2000 // Set your maximum size here
if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) {
val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize)
options.inSampleSize = scaleFactor
options.inJustDecodeBounds = false
val scaledBitmap = BitmapFactory.decodeFile(imageUri, options)
binding.iv.setImageBitmap(scaledBitmap)
// Update the ImageView with the scaled bitmap
val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat()
binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt()
binding.iv.imageMatrix = scaleMatrix(scale, scale)
} else {
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFile(imageUri, options)
binding.iv.setImageBitmap(bitmap)
// Check if the bitmap dimensions exceed a certain threshold
val maxBitmapSize = 2000 // Set your maximum size here
if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) {
val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize)
options.inSampleSize = scaleFactor
options.inJustDecodeBounds = false
val scaledBitmap = BitmapFactory.decodeFile(imageUri, options)
binding.iv.setImageBitmap(scaledBitmap)
// Update the ImageView with the scaled bitmap
val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat()
binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt()
binding.iv.imageMatrix = scaleMatrix(scale, scale)
} else {
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFile(imageUri, options)
binding.iv.setImageBitmap(bitmap)
val scale = binding.iv.measuredWidth.toFloat() / bitmapWidth.toFloat()
binding.iv.layoutParams.height = (scale * bitmapHeight).toInt()
binding.iv.imageMatrix = scaleMatrix(scale, scale)
}
},
)
val scale = binding.iv.measuredWidth.toFloat() / bitmapWidth.toFloat()
binding.iv.layoutParams.height = (scale * bitmapHeight).toInt()
binding.iv.imageMatrix = scaleMatrix(scale, scale)
}
}
binding.rotateBtn.setOnClickListener {
animateImageHeight()
}
@ -143,15 +145,15 @@ class EditActivity : AppCompatActivity() {
val drawableWidth: Float =
binding.iv
.getDrawable()
.getIntrinsicWidth()
.intrinsicWidth
.toFloat()
val drawableHeight: Float =
binding.iv
.getDrawable()
.getIntrinsicHeight()
.intrinsicHeight
.toFloat()
val viewWidth: Float = binding.iv.getMeasuredWidth().toFloat()
val viewHeight: Float = binding.iv.getMeasuredHeight().toFloat()
val viewWidth: Float = binding.iv.measuredWidth.toFloat()
val viewHeight: Float = binding.iv.measuredHeight.toFloat()
val rotation = imageRotation % 360
val newRotation = rotation + 90
@ -162,16 +164,23 @@ class EditActivity : AppCompatActivity() {
Timber.d("Rotation $rotation")
Timber.d("new Rotation $newRotation")
if (rotation == 0 || rotation == 180) {
imageScale = viewWidth / drawableWidth
newImageScale = viewWidth / drawableHeight
newViewHeight = (drawableWidth * newImageScale).toInt()
} else if (rotation == 90 || rotation == 270) {
imageScale = viewWidth / drawableHeight
newImageScale = viewWidth / drawableWidth
newViewHeight = (drawableHeight * newImageScale).toInt()
} else {
throw UnsupportedOperationException("rotation can 0, 90, 180 or 270. \${rotation} is unsupported")
when (rotation) {
0, 180 -> {
imageScale = viewWidth / drawableWidth
newImageScale = viewWidth / drawableHeight
newViewHeight = (drawableWidth * newImageScale).toInt()
}
90, 270 -> {
imageScale = viewWidth / drawableHeight
newImageScale = viewWidth / drawableWidth
newViewHeight = (drawableHeight * newImageScale).toInt()
}
else -> {
throw
UnsupportedOperationException(
"rotation can 0, 90, 180 or 270. \${rotation} is unsupported"
)
}
}
val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L)
@ -204,7 +213,7 @@ class EditActivity : AppCompatActivity() {
(complementaryAnimVal * viewHeight + animVal * newViewHeight).toInt()
val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale
val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation
binding.iv.getLayoutParams().height = animatedHeight
binding.iv.layoutParams.height = animatedHeight
val matrix: Matrix =
rotationMatrix(
animatedRotation,
@ -218,8 +227,8 @@ class EditActivity : AppCompatActivity() {
drawableHeight / 2,
)
matrix.postTranslate(
-(drawableWidth - binding.iv.getMeasuredWidth()) / 2,
-(drawableHeight - binding.iv.getMeasuredHeight()) / 2,
-(drawableWidth - binding.iv.measuredWidth) / 2,
-(drawableHeight - binding.iv.measuredHeight) / 2,
)
binding.iv.setImageMatrix(matrix)
binding.iv.requestLayout()
@ -267,9 +276,9 @@ class EditActivity : AppCompatActivity() {
*/
private fun copyExifData(editedImageExif: ExifInterface?) {
for (attr in sourceExifAttributeList) {
Log.d("Tag is ${attr.first}", "Value is ${attr.second}")
Timber.d("Value is ${attr.second}")
editedImageExif!!.setAttribute(attr.first, attr.second)
Log.d("Tag is ${attr.first}", "Value is ${attr.second}")
Timber.d("Value is ${attr.second}")
}
editedImageExif?.saveAttributes()
@ -298,9 +307,10 @@ class EditActivity : AppCompatActivity() {
var scaleFactor = 1
if (originalWidth > maxSize || originalHeight > maxSize) {
// Calculate the largest power of 2 that is less than or equal to the desired width and height
val widthRatio = Math.ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt()
val heightRatio = Math.ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt()
// Calculate the largest power of 2 that is less than or equal to the desired
// width and height
val widthRatio = ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt()
val heightRatio = ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt()
scaleFactor = if (widthRatio > heightRatio) widthRatio else heightRatio
}

View file

@ -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";
}
}

View 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"
}
}
}

View file

@ -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) {
}
}

View file

@ -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) {}
}

View file

@ -1,7 +0,0 @@
package fr.free.nrw.commons.filepicker;
import androidx.core.content.FileProvider;
public class ExtendedFileProvider extends FileProvider {
}

View file

@ -0,0 +1,5 @@
package fr.free.nrw.commons.filepicker
import androidx.core.content.FileProvider
class ExtendedFileProvider: FileProvider() {}

View file

@ -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);
}
}

View 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)
}
}

View file

@ -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);
}
}

View file

@ -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)
}
}

View file

@ -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);
}
}

View file

@ -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)
}
}
}

View file

@ -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);
}
}

View 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)
}
}

View 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;
}
}
}

View file

@ -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)
}
}
}

View file

@ -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();
}
}

View 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()
}

View file

@ -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("&", "&amp;");
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("&", "&amp;");
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;
});
}
}

View file

@ -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("&", "&amp;")
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("&", "&amp;")
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
}
}
}

View file

@ -111,10 +111,18 @@ class MoreBottomSheetFragment : BottomSheetDialogFragment() {
private fun setUserName() {
val store = BasicKvStore(requireContext(), getUserName())
val level = store.getString("userAchievementsLevel", "0")
binding?.moreProfile?.text = if (level == "0") {
"${getUserName()} (${getString(R.string.see_your_achievements)})"
if (level == "0"){
binding?.moreProfile?.text = getString(
R.string.profileLevel,
getUserName(),
getString(R.string.see_your_achievements) // Second argument
)
} else {
"${getUserName()} (${getString(R.string.level)} $level)"
binding?.moreProfile?.text = getString(
R.string.profileLevel,
getUserName(),
level
)
}
}

View file

@ -16,7 +16,6 @@ import androidx.annotation.NonNull;
import androidx.core.content.FileProvider;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import com.google.android.material.tabs.TabLayout;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.ViewPagerAdapter;

View file

@ -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;
}
}

View file

@ -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"
}
}

View file

@ -38,6 +38,7 @@ import fr.free.nrw.commons.campaigns.CampaignView
import fr.free.nrw.commons.contributions.ContributionController
import fr.free.nrw.commons.contributions.MainActivity
import fr.free.nrw.commons.di.ApplicationlessInjection
import fr.free.nrw.commons.filepicker.FilePicker
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LocationServiceManager
import fr.free.nrw.commons.logging.CommonsLogSender
@ -83,9 +84,17 @@ class SettingsFragment : PreferenceFragmentCompat() {
private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> =
registerForActivityResult(StartActivityForResult()) { result ->
contributionController.handleActivityResultWithCallback(requireActivity()) { callbacks ->
contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks)
}
contributionController.handleActivityResultWithCallback(
requireActivity(),
object: FilePicker.HandleActivityResult {
override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) {
contributionController.onPictureReturnedFromCamera(
result,
requireActivity(),
callbacks
)
}
})
}
/**

View file

@ -194,7 +194,7 @@ class FileProcessor
requireNotNull(imageCoordinates.decimalCoords)
compositeDisposable.add(
apiCall
.request(imageCoordinates.decimalCoords)
.request(imageCoordinates.decimalCoords!!)
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.subscribe(
@ -220,7 +220,7 @@ class FileProcessor
.concatMap {
Observable.fromCallable {
okHttpJsonApiClient.getNearbyPlaces(
imageCoordinates.latLng,
imageCoordinates.latLng!!,
Locale.getDefault().language,
it,
)

View file

@ -496,14 +496,14 @@ class UploadWorker(
withContext(Dispatchers.Main) {
wikidataEditService.handleImageClaimResult(
contribution.wikidataPlace,
contribution.wikidataPlace!!,
revisionID,
)
}
} else {
withContext(Dispatchers.Main) {
wikidataEditService.handleImageClaimResult(
contribution.wikidataPlace,
contribution.wikidataPlace!!,
null,
)
}

View file

@ -63,7 +63,7 @@ class CustomSelectorUtils {
fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact())
val sha1 =
fileUtilsWrapper.getSHA1(
fileUtilsWrapper.getFileInputStream(uploadableFile.filePath),
fileUtilsWrapper.getFileInputStream(uploadableFile.getFilePath()),
)
uploadableFile.file.delete()
sha1

View file

@ -10,11 +10,10 @@ class CommonsServiceFactory(
) {
val builder: Retrofit.Builder by lazy {
// All instances of retrofit share this configuration, but create it lazily
Retrofit
.Builder()
Retrofit.Builder()
.client(okHttpClient)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson()))
.addConverterFactory(GsonConverterFactory.create(GsonUtil.defaultGson))
}
val retrofitCache: MutableMap<String, Retrofit> = mutableMapOf()

View file

@ -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() { }
}

View 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() }
}

View file

@ -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/";
}

View file

@ -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/"
}

View file

@ -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();
}
}

View file

@ -0,0 +1,11 @@
package fr.free.nrw.commons.wikidata
abstract class WikidataEditListener {
var authenticationStateListener: WikidataP18EditListener? = null
abstract fun onSuccessfulWikidataEdit()
interface WikidataP18EditListener {
fun onWikidataEditSuccessful()
}
}

View file

@ -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();
}
}
}

View file

@ -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()
}
}

View file

@ -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);
}
}

View file

@ -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"
}
}

View file

@ -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());
}
}

View file

@ -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())
}
}

View file

@ -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;
}
};
}
}

View file

@ -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
}
}
}
}

View file

@ -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;
}
}
}

View file

@ -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
}
}
}

View file

@ -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();
}
}

View file

@ -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)
}
}

View file

@ -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);
}
}

View file

@ -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())
}
}

View file

@ -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);
}
}

View file

@ -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"
}
}

View file

@ -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 {
}

View file

@ -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

View file

@ -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;
}
}

View file

@ -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>
)

View file

@ -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);
}
}
}

View file

@ -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)
}
}

View file

@ -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.");
}
}

View 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
}
}
}

View file

@ -148,7 +148,7 @@ public class Notification {
return null;
}
if (primaryLink == null && primary instanceof JsonObject) {
primaryLink = GsonUtil.getDefaultGson().fromJson(primary, Link.class);
primaryLink = GsonUtil.INSTANCE.getDefaultGson().fromJson(primary, Link.class);
}
return primaryLink;
}

View file

@ -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 "";
}
}

View file

@ -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 ?: ""
}

View file

@ -1,640 +1,368 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/achievementBackground">
android:background="?attr/achievementBackground"
android:fillViewport="true"
tools:ignore="ContentDescription" >
<ScrollView
<!-- 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_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
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ImageView
android:id="@+id/achievement_badge_image"
android:layout_width="150dp"
android:layout_height="150dp"
android:layout_marginTop="16dp"
android:background="@drawable/badge"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:layout_height="100dp"
tools:layout_width="100dp" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_achievements_of_user"
style="@style/MediaDetailTextLabel"
<TextView
android:id="@+id/achievement_badge_text"
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_height="wrap_content"
android:padding="10dp"
tools:text="Achievements of user : Ashish" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/achievementBackground"
android:orientation="vertical">
<TextView
style="?android:textAppearanceLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:layout_marginTop="@dimen/activity_margin_horizontal"
android:text="@string/level"
android:id="@+id/achievement_level"
android:textAllCaps="true"/>
<ImageView
android:id="@+id/achievement_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/activity_margin_vertical"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
android:layout_alignParentEnd="true"
app:srcCompat="@drawable/ic_info_outline_24dp"
android:layout_marginVertical="@dimen/activity_margin_vertical"
app:tint="@color/black" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/badge_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/achievement_info"
android:layout_centerHorizontal="true">
<ImageView
android:id="@+id/achievement_badge_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/badge" />
<TextView
android:id="@+id/achievement_badge_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center"
android:textColor="@color/achievement_badge_text"
android:textSize="75sp"
app:layout_constraintBottom_toBottomOf="@+id/achievement_badge_image"
app:layout_constraintEnd_toEndOf="@+id/achievement_badge_image"
app:layout_constraintStart_toStartOf="@+id/achievement_badge_image"
app:layout_constraintTop_toTopOf="@+id/achievement_badge_image"
app:layout_constraintVertical_bias="0.58" />
</androidx.constraintlayout.widget.ConstraintLayout>
<RelativeLayout
android:id="@+id/layout_image_uploaded"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/badge_layout"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
android:layout_marginLeft="@dimen/activity_margin_horizontal"
android:layout_marginRight="@dimen/activity_margin_horizontal"
android:layout_marginStart="@dimen/activity_margin_horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/images_upload_info"
android:orientation="horizontal"
>
<TextView
style="?android:textAppearanceMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:id="@+id/images_upload_text_param"
android:layout_marginTop="@dimen/achievements_activity_margin_vertical"
android:text="@string/images_uploaded" />
<ImageView
android:layout_width="@dimen/quarter_standard_height"
android:layout_height="@dimen/quarter_standard_height"
android:layout_marginTop="@dimen/activity_margin_horizontal"
android:layout_marginRight="@dimen/activity_margin_horizontal"
android: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_uploaded_progressbar"
android:layout_width="@dimen/dimen_40"
android:layout_height="@dimen/dimen_40"
android:indeterminate="false"
android:layout_marginEnd="@dimen/large_gap"
app:showAnimationBehavior="outward"
app:indicatorColor="@color/primaryColor"
app:indicatorSize="32dp"
app:trackThickness="@dimen/progressbar_stroke"
app:trackColor="#B7B6B6"
android:visibility="gone"/>
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_uploaded_images"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/progressbar_padding"
android:gravity="center"
android:maxLines="1"
android:textColor="@color/secondaryColor"
app:autoSizeMaxTextSize="@dimen/progressbar_text"
app:autoSizeMinTextSize="2sp"
app:autoSizeStepGranularity="1sp"
app:autoSizeTextType="uniform" />
</FrameLayout>
</RelativeLayout>
<RelativeLayout
android:id="@+id/layout_image_reverts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/tiny_margin"
android:layout_below="@+id/layout_image_uploaded"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
android:layout_marginLeft="@dimen/activity_margin_horizontal"
android:layout_marginRight="@dimen/activity_margin_horizontal"
android:layout_marginStart="@dimen/activity_margin_horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/images_reverted_info"
android:orientation="horizontal"
>
<TextView
style="?android:textAppearanceMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/images_reverted_text"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:text="@string/image_reverts" />
<ImageView
android:layout_width="@dimen/medium_width"
android:layout_height="@dimen/medium_height"
android:layout_marginTop="@dimen/activity_margin_horizontal"
android:layout_marginRight="@dimen/activity_margin_horizontal"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
app:srcCompat="@drawable/ic_info_outline_24dp"
android:layout_marginLeft="@dimen/activity_margin_horizontal"
android:layout_marginStart="@dimen/activity_margin_horizontal" app:tint="@color/primaryLightColor" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/achievements_revert_limit_message"
android:textSize="@dimen/small_text"
android:id="@+id/images_revert_limit_text"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:layout_below="@id/images_reverted_info"/>
<FrameLayout
android:layout_width="@dimen/dimen_40"
android:layout_height="@dimen/dimen_40"
android:layout_alignParentEnd="true"
android:layout_marginEnd="32dp">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/image_reverts_progressbar"
android:layout_width="@dimen/dimen_40"
android:layout_height="@dimen/dimen_40"
android:indeterminate="false"
android:layout_marginEnd="@dimen/large_gap"
app:showAnimationBehavior="outward"
app:indicatorColor="@color/primaryColor"
app:indicatorSize="32dp"
app:trackThickness="@dimen/progressbar_stroke"
app:trackColor="#B7B6B6"
android:visibility="gone"/>
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_reverted_images"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/progressbar_padding"
android:gravity="center"
android:maxLines="1"
android:textColor="@color/secondaryColor"
app:autoSizeMaxTextSize="@dimen/progressbar_text"
app:autoSizeMinTextSize="2sp"
app:autoSizeStepGranularity="1sp"
app:autoSizeTextType="uniform" />
</FrameLayout>
</RelativeLayout>
<RelativeLayout
android:id="@+id/layout_image_used_by_wiki"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/tiny_margin"
android:layout_below="@+id/layout_image_reverts"
android:layout_marginBottom="@dimen/activity_margin_vertical"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
android:layout_marginLeft="@dimen/activity_margin_horizontal"
android:layout_marginRight="@dimen/activity_margin_horizontal"
android:layout_marginStart="@dimen/activity_margin_horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/images_used_by_wiki_info"
android:orientation="horizontal">
<TextView
style="?android:textAppearanceMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/images_used_by_wiki_text"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:layout_marginTop="@dimen/achievements_activity_margin_vertical"
android:text="@string/images_used_by_wiki" />
<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>
android:layout_centerInParent="true"
android:progressDrawable="@android:drawable/progress_horizontal"
android:progressBackgroundTintMode="multiply"
android:progressTint="#5ce65c"
tools:progress="50" />
<TextView
android:id="@+id/imageUploadedTVCount"
style="?android:textAppearanceMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
tools:text="10/15" />
</RelativeLayout>
<com.google.android.material.divider.MaterialDivider
android:id="@+id/materialDivider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:layout_marginTop="@dimen/activity_margin_horizontal"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/rl_images_Uploaded" />
<!-- Image's Not Reverted -->
<TextView
android:id="@+id/images_reverted_text"
style="?android:textAppearanceMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:layout_marginTop="@dimen/activity_margin_horizontal"
android:text="@string/image_reverts"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/materialDivider" />
<TextView
android:id="@+id/images_revert_limit_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
android:text="@string/achievements_revert_limit_message"
app:layout_constraintBottom_toBottomOf="@+id/images_reverted_text"
app:layout_constraintEnd_toStartOf="@+id/images_reverted_info_icon"
app:layout_constraintStart_toEndOf="@+id/images_reverted_text"
app:layout_constraintTop_toTopOf="@+id/images_reverted_text" />
<ImageView
android:id="@+id/images_reverted_info_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
app:layout_constraintBottom_toBottomOf="@+id/images_revert_limit_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/images_revert_limit_text"
app:srcCompat="@drawable/ic_info_outline_24dp"
app:tint="@color/black" />
<!-- Image's Not Reverted Progress Bar -->
<RelativeLayout
android:id="@+id/rl_images_reverted"
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_reverted_text">
<ProgressBar
android:id="@+id/image_reverts_progressbar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:progressDrawable="@android:drawable/progress_horizontal"
android:progressBackgroundTintMode="multiply"
android:progressTint="#5ce65c"
tools:progress="50" />
<TextView
android:id="@+id/imageRevertTVCount"
style="?android:textAppearanceMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
tools:text="10/15" />
</RelativeLayout>
<com.google.android.material.divider.MaterialDivider
android:id="@+id/materialDivider1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:layout_marginTop="@dimen/activity_margin_horizontal"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/rl_images_reverted" />
<!-- Image Used -->
<TextView
android:id="@+id/images_used_tv"
style="?android:textAppearanceMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:layout_marginTop="@dimen/activity_margin_horizontal"
android:text="@string/images_used_by_wiki"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/materialDivider1" />
<ImageView
android:id="@+id/images_used_by_wiki_info_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
app:layout_constraintBottom_toBottomOf="@+id/images_used_tv"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/images_used_tv"
app:srcCompat="@drawable/ic_info_outline_24dp"
app:tint="@color/black" />
<!-- Image's Used Progress Bar -->
<RelativeLayout
android:id="@+id/rl_images_used"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/images_used_tv">
<ProgressBar
android:id="@+id/images_used_by_wiki_progress_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:progressDrawable="@android:drawable/progress_horizontal"
android:progressBackgroundTintMode="multiply"
android:progressTint="#5ce65c"
tools:progress="50" />
<TextView
android:id="@+id/imagesUsedCount"
style="?android:textAppearanceMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
tools:text="10/15" />
</RelativeLayout>
<com.google.android.material.divider.MaterialDivider
android:id="@+id/materialDivider2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:layout_marginTop="@dimen/activity_margin_horizontal"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/rl_images_used" />
<!-- Statistics -->
<TextView
android:id="@+id/tv_statistics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:layout_marginTop="@dimen/activity_margin_horizontal"
android:text="@string/badges"
android:textAllCaps="true"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/materialDivider2" />
<LinearLayout
android:id="@+id/badgesItems"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_margin_horizontal"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
android:orientation="horizontal"
android:padding="@dimen/activity_margin_horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tv_statistics">
<!--Nearby Places Statistics-->
<ImageView
android:id="@+id/wikidata_edits_icon"
android:layout_width="@dimen/dimen_40"
android:layout_height="@dimen/dimen_40"
android:layout_marginEnd="@dimen/large_gap"
app:srcCompat="@drawable/ic_custom_map_marker" />
<!--Featured Image Statistics-->
<ImageView
android:id="@+id/featured_image_icon"
android:layout_width="@dimen/dimen_40"
android:layout_height="@dimen/dimen_40"
android:layout_marginEnd="@dimen/large_gap"
app:srcCompat="@drawable/featured" />
<!--Quality Image Statistics-->
<ImageView
android:id="@+id/quality_image_icon"
android:layout_width="@dimen/dimen_40"
android:layout_height="@dimen/dimen_40"
android:layout_marginEnd="@dimen/large_gap"
app:srcCompat="@drawable/ic_quality_images_logo" />
<!--Thank Image Statistics-->
<ImageView
android:id="@+id/thanks_image_icon"
android:layout_width="@dimen/dimen_40"
android:layout_height="@dimen/dimen_40"
android:layout_marginEnd="@dimen/large_gap"
app:srcCompat="@drawable/ic_thanks" />
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.5"
app:layout_constraintHorizontal_bias="0.5"
android:visibility="gone"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -21,6 +21,7 @@
* Okkerem
* Oyuncu
* Rapsar
* RuzDD
* SaldırganSincap
* Sayginer
* Sezgin İbiş
@ -146,6 +147,7 @@
<string name="categories_search_text_hint">Kategori ara</string>
<string name="depicts_search_text_hint">Medyanızın tasvir ettiği ögeleri arayın (dağ, Tac Mahal, vb.)</string>
<string name="menu_save_categories">Kaydet</string>
<string name="menu_overflow_desc">Taşma menüsü</string>
<string name="refresh_button">Yenile</string>
<string name="display_list_button">Liste</string>
<string name="contributions_subtitle_zero">!Henüz yükleme yok)</string>
@ -800,6 +802,7 @@
<string name="please_enter_some_comments">Lütfen bir yorum girin</string>
<string name="talk">Tartışma</string>
<string name="write_something_about_the_item">\' %1$s \' öğesi hakkında bir şeyler yazın. Herkes tarafından görülebilir olacaktır.</string>
<string name="does_not_exist_anymore_no_picture_can_ever_be_taken_of_it">\'%1$s\' artık yok, dolayısı ile resmi çekilemez.</string>
<string name="other_problem_or_information_please_explain_below">Diğer sorun veya bilgi (lütfen aşağıda açıklayınız).</string>
<string name="feedback_destination_note">Geri bildiriminiz aşağıdaki wiki sayfasına gönderilir: &lt;a href=\"https://commons.wikimedia.org/wiki/Commons:Mobile_app/Feedback\"&gt;Commons:Mobile app/Feedback&lt;/a&gt;</string>
<string name="are_you_sure_that_you_want_cancel_all_the_uploads">Tüm yüklemeleri iptal etmek istediğinizden emin misiniz?</string>
@ -807,5 +810,10 @@
<string name="uploads">Yüklemeler</string>
<string name="pending">Beklemede</string>
<string name="failed">Başarısız</string>
<string name="custom_selector_delete">Sil</string>
<string name="custom_selector_cancel">İptal</string>
<string name="custom_selector_folder_deleted_success">%1$s klasörü başarıyla silindi</string>
<string name="custom_selector_folder_deleted_failure">%1$s klasörü silinemedi</string>
<string name="green_pin">Bu yerin zaten bir resmi var.</string>
<string name="grey_pin">Şimdi bu yerin bir resime sahip olup olmadığı denetleniyor.</string>
</resources>

View file

@ -371,11 +371,13 @@
<string name="delete">Delete</string>
<string name="Achievements">Achievements</string>
<string name="Profile">Profile</string>
<string name="badges">Badges</string>
<string name="statistics">Statistics</string>
<string name="statistics_thanks">Thanks Received</string>
<string name="statistics_featured">Featured Images</string>
<string name="statistics_wikidata_edits">Images via \"Nearby Places\"</string>
<string name="level">Level</string>
<string name="level">Level %d</string>
<string name="profileLevel">%s (Level %s)</string>
<string name="images_uploaded">Images Uploaded</string>
<string name="image_reverts">Images Not Reverted</string>
<string name="images_used_by_wiki">Images Used</string>

View file

@ -1,6 +1,6 @@
<resources>
<style name="DarkAppTheme" parent="Theme.AppCompat.NoActionBar">
<style name="DarkAppTheme" parent="Theme.MaterialComponents.NoActionBar.Bridge">
<item name="contributionsListBackground">@color/contributionListDarkBackground</item>
<item name="tabBackground">@color/contributionListDarkBackground</item>
<item name="tabIndicatorColor">@color/white</item>
@ -62,7 +62,7 @@
<item name="android:splitMotionEvents">false</item>
</style>
<style name="LightAppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<style name="LightAppTheme" parent="Theme.MaterialComponents.Light.NoActionBar.Bridge">
<item name="contributionsListBackground">@color/white</item>
<item name="tabBackground">@color/card_light_grey</item>
<item name="tabIndicatorColor">@color/primaryDarkColor</item>
@ -73,6 +73,7 @@
<item name="drawerHeaderBackground">@color/drawerHeader_background_light</item>
<item name="tutorialBackground">@color/tutorial_background_light</item>
<item name="icon">@color/secondaryTextColor</item>
<item name="colorPrimary">@color/primaryDarkColor</item>
<item name="colorPrimaryDark">@color/primaryDarkColor</item>
<item name="colorAccent">@color/primaryColor</item>
<item name="colorButtonNormal">@color/primaryColor</item>

View file

@ -69,7 +69,7 @@ public abstract class MockWebServerTest {
.baseUrl(url)
.callbackExecutor(new ImmediateExecutor())
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson()))
.addConverterFactory(GsonConverterFactory.create(GsonUtil.INSTANCE.getDefaultGson()))
.build()
.create(clazz);
}

View file

@ -49,13 +49,13 @@ class CampaignsPresenterTest {
campaignsSingle = Single.just(campaignResponseDTO)
campaignsPresenter = CampaignsPresenter(okHttpJsonApiClient, testScheduler, testScheduler)
campaignsPresenter.onAttachView(view)
Mockito.`when`(okHttpJsonApiClient.campaigns).thenReturn(campaignsSingle)
Mockito.`when`(okHttpJsonApiClient.getCampaigns()).thenReturn(campaignsSingle)
}
@Test
fun getCampaignsTestNoCampaigns() {
campaignsPresenter.getCampaigns()
verify(okHttpJsonApiClient).campaigns
verify(okHttpJsonApiClient).getCampaigns()
testScheduler.triggerActions()
verify(view).showCampaigns(null)
}
@ -77,7 +77,7 @@ class CampaignsPresenterTest {
Mockito.`when`(campaign.endDate).thenReturn(endDateString)
Mockito.`when`(campaign.startDate).thenReturn(startDateString)
Mockito.`when`(campaignResponseDTO.campaigns).thenReturn(campaigns)
verify(okHttpJsonApiClient).campaigns
verify(okHttpJsonApiClient).getCampaigns()
testScheduler.triggerActions()
verify(view).showCampaigns(campaign)
}

View file

@ -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;
}
}

View file

@ -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
}
}

View file

@ -30,8 +30,7 @@ class UserClientTest {
@Test
fun isUserBlockedFromCommonsForInfinitelyBlockedUser() {
val userInfo = Mockito.mock(UserInfo::class.java)
Mockito.`when`(userInfo.blockexpiry()).thenReturn("infinite")
val userInfo = UserInfo(blockexpiry = "infinite")
val mwQueryResult = Mockito.mock(MwQueryResult::class.java)
Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo)
val mockResponse = Mockito.mock(MwQueryResponse::class.java)
@ -49,8 +48,7 @@ class UserClientTest {
val currentDate = Date()
val expiredDate = Date(currentDate.time + 10000)
val userInfo = Mockito.mock(UserInfo::class.java)
Mockito.`when`(userInfo.blockexpiry()).thenReturn(DateUtil.iso8601DateFormat(expiredDate))
val userInfo = UserInfo(blockexpiry = DateUtil.iso8601DateFormat(expiredDate))
val mwQueryResult = Mockito.mock(MwQueryResult::class.java)
Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo)
val mockResponse = Mockito.mock(MwQueryResponse::class.java)
@ -65,8 +63,7 @@ class UserClientTest {
@Test
fun isUserBlockedFromCommonsForNeverBlockedUser() {
val userInfo = Mockito.mock(UserInfo::class.java)
Mockito.`when`(userInfo.blockexpiry()).thenReturn("")
val userInfo = UserInfo(blockexpiry = "")
val mwQueryResult = Mockito.mock(MwQueryResult::class.java)
Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo)
val mockResponse = Mockito.mock(MwQueryResponse::class.java)

View file

@ -325,7 +325,7 @@ class NearbyParentFragmentUnitTest {
@Throws(Exception::class)
fun testOnDestroy() {
fragment.onDestroy()
verify(wikidataEditListener).setAuthenticationStateListener(null)
verify(wikidataEditListener).authenticationStateListener = null
}
@Test @Ignore

View file

@ -120,26 +120,16 @@ class NotificationClientTest {
) = Notification().apply {
setId(notificationId)
setTimestamp(
Notification.Timestamp().apply {
setUtciso8601(timestamp)
},
)
setTimestamp(Notification.Timestamp().apply { setUtciso8601(timestamp) })
contents =
Notification.Contents().apply {
setCompactHeader(compactHeader)
contents = Notification.Contents().apply {
setCompactHeader(compactHeader)
links =
Notification.Links().apply {
setPrimary(
GsonUtil.getDefaultGson().toJsonTree(
Notification.Link().apply {
setUrl(primaryUrl)
},
),
)
}
links = Notification.Links().apply {
setPrimary(GsonUtil.defaultGson.toJsonTree(
Notification.Link().apply { setUrl(primaryUrl) }
))
}
}
}
}

View file

@ -79,7 +79,7 @@ class AchievementsFragmentUnitTests {
fragmentTransaction.commitNowAllowingStateLoss()
layoutInflater = LayoutInflater.from(activity)
view = fragment.onCreateView(layoutInflater, activity.findViewById(R.id.container), null)!!
view = fragment.onCreateView(layoutInflater, activity.findViewById(R.id.container), null)
achievements = Achievements(0, 0, 0, 0, 0, 0, 0)
}

View file

@ -62,7 +62,7 @@ class UploadPresenterTest {
`when`(repository.buildContributions()).thenReturn(Observable.just(contribution))
uploadableFiles.add(uploadableFile)
`when`(view.uploadableFiles).thenReturn(uploadableFiles)
`when`(uploadableFile.filePath).thenReturn("data://test")
`when`(uploadableFile.getFilePath()).thenReturn("data://test")
}
/**