Merge branch 'main' into recent-search

This commit is contained in:
Nicolas Raoul 2024-12-10 12:21:57 +09:00 committed by GitHub
commit 59fe558ad4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
223 changed files with 8059 additions and 7995 deletions

View file

@ -47,18 +47,19 @@ 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'
// Jetpack Compose
def composeBom = platform('androidx.compose:compose-bom:2024.08.00')
def composeBom = platform('androidx.compose:compose-bom:2024.11.00')
implementation "androidx.activity:activity-compose:1.9.1"
implementation "androidx.activity:activity-compose:1.9.3"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4"
implementation (composeBom)
implementation "androidx.compose.runtime:runtime"
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.ui:ui-viewbinding"
implementation "androidx.compose.ui:ui-graphics"
implementation "androidx.compose.ui:ui-tooling"
implementation "androidx.compose.foundation:foundation"
@ -138,7 +139,7 @@ dependencies {
implementation "androidx.browser:browser:1.3.0"
implementation "androidx.cardview:cardview:1.0.0"
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation "androidx.exifinterface:exifinterface:1.3.2"
implementation 'androidx.exifinterface:exifinterface:1.3.7'
implementation "androidx.core:core-ktx:$CORE_KTX_VERSION"
implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1'
@ -313,6 +314,7 @@ android {
buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\""
buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\""
buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\""
buildConfigField "String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\""
buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\""
buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\""
buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\""
@ -348,6 +350,7 @@ android {
buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\""
buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\""
buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\""
buildConfigField "String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\""
buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\""
buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\""
buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\""

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

@ -237,7 +237,7 @@ class LoginClient(
.subscribe({ response: MwQueryResponse? ->
loginResult.userId = response?.query()?.userInfo()?.id() ?: 0
loginResult.groups =
response?.query()?.getUserResponse(userName)?.groups ?: emptySet()
response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet()
cb.success(loginResult)
}, { caught: Throwable ->
Timber.e(caught, "Login succeeded but getting group information failed. ")

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,7 @@ import android.animation.ValueAnimator
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.Matrix
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
@ -16,10 +14,12 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.rotationMatrix
import androidx.core.graphics.scaleMatrix
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
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,11 +42,12 @@ 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) }
val exifTags =
arrayOf(
ExifInterface.TAG_APERTURE,
ExifInterface.TAG_F_NUMBER,
ExifInterface.TAG_DATETIME,
ExifInterface.TAG_EXPOSURE_TIME,
ExifInterface.TAG_FLASH,
@ -62,13 +63,13 @@ class EditActivity : AppCompatActivity() {
ExifInterface.TAG_GPS_TIMESTAMP,
ExifInterface.TAG_IMAGE_LENGTH,
ExifInterface.TAG_IMAGE_WIDTH,
ExifInterface.TAG_ISO,
ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY,
ExifInterface.TAG_MAKE,
ExifInterface.TAG_MODEL,
ExifInterface.TAG_ORIENTATION,
ExifInterface.TAG_WHITE_BALANCE,
ExifInterface.WHITEBALANCE_AUTO,
ExifInterface.WHITEBALANCE_MANUAL,
ExifInterface.WHITE_BALANCE_AUTO,
ExifInterface.WHITE_BALANCE_MANUAL,
)
for (tag in exifTags) {
val attribute = sourceExif?.getAttribute(tag.toString())
@ -88,38 +89,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 +142,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 +161,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 +210,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 +224,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 +273,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 +304,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

@ -11,7 +11,6 @@ import fr.free.nrw.commons.wikidata.model.Entities
import fr.free.nrw.commons.wikidata.model.gallery.ExtMetadata
import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage
import org.apache.commons.lang3.StringUtils
import java.text.ParseException
import java.util.Date
import javax.inject.Inject
@ -24,7 +23,7 @@ class MediaConverter
entity: Entities.Entity,
imageInfo: ImageInfo,
): Media {
val metadata = imageInfo.metadata
val metadata = imageInfo.getMetadata()
requireNotNull(metadata) { "No metadata" }
// Stores mapping of title attribute to hidden attribute of each category
val myMap = mutableMapOf<String, Boolean>()
@ -32,8 +31,8 @@ class MediaConverter
return Media(
page.pageId().toString(),
imageInfo.thumbUrl.takeIf { it.isNotBlank() } ?: imageInfo.originalUrl,
imageInfo.originalUrl,
imageInfo.getThumbUrl().takeIf { it.isNotBlank() } ?: imageInfo.getOriginalUrl(),
imageInfo.getOriginalUrl(),
page.title(),
metadata.imageDescription(),
safeParseDate(metadata.dateTime()),
@ -41,7 +40,7 @@ class MediaConverter
metadata.prefixedLicenseUrl,
getAuthor(metadata),
getAuthor(metadata),
MediaDataExtractorUtil.extractCategoriesFromList(metadata.categories),
MediaDataExtractorUtil.extractCategoriesFromList(metadata.categories()),
metadata.latLng,
entity.labels().mapValues { it.value.value() },
entity.descriptions().mapValues { it.value.value() },
@ -104,9 +103,5 @@ private val ExtMetadata.prefixedLicenseUrl: String
}
private val ExtMetadata.latLng: LatLng?
get() =
if (!StringUtils.isBlank(gpsLatitude) && !StringUtils.isBlank(gpsLongitude)) {
LatLng(gpsLatitude.toDouble(), gpsLongitude.toDouble(), 0.0f)
} else {
null
}
get() = LatLng.latLongOrNull(gpsLatitude(), gpsLongitude())

View file

@ -1,120 +0,0 @@
package fr.free.nrw.commons.feedback;
import android.content.Context;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.AccountUtilKt;
import fr.free.nrw.commons.feedback.model.Feedback;
import fr.free.nrw.commons.utils.LangCodeUtils;
import java.util.Locale;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;
/**
* Creates a wikimedia recognizable format
* from feedback information
*/
public class FeedbackContentCreator {
private StringBuilder sectionTextBuilder;
private StringBuilder sectionTitleBuilder;
private Feedback feedback;
private Context context;
public FeedbackContentCreator(Context context, Feedback feedback) {
this.feedback = feedback;
this.context = context;
init();
}
/**
* Initializes the string buffer object to append content from feedback object
*/
public void init() {
// Localization is not needed here, because this ends up on a page where developers read the feedback, so English is the most convenient.
/*
* Construct the feedback section title
*/
//Get the UTC Date and Time and add it to the Title
final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.ENGLISH);
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
final String UTC_FormattedDate = dateFormat.format(new Date());
sectionTitleBuilder = new StringBuilder();
sectionTitleBuilder.append("Feedback from ");
sectionTitleBuilder.append(AccountUtilKt.getUserName(context));
sectionTitleBuilder.append(" for version ");
sectionTitleBuilder.append(feedback.getVersion());
sectionTitleBuilder.append(" on ");
sectionTitleBuilder.append(UTC_FormattedDate);
/*
* Construct the feedback section text
*/
sectionTextBuilder = new StringBuilder();
sectionTextBuilder.append("\n");
sectionTextBuilder.append(feedback.getTitle());
sectionTextBuilder.append("\n");
sectionTextBuilder.append("\n");
if (feedback.getApiLevel() != null) {
sectionTextBuilder.append("* ");
sectionTextBuilder.append(LangCodeUtils.getLocalizedResources(context,
Locale.ENGLISH).getString(R.string.api_level));
sectionTextBuilder.append(": ");
sectionTextBuilder.append(feedback.getApiLevel());
sectionTextBuilder.append("\n");
}
if (feedback.getAndroidVersion() != null) {
sectionTextBuilder.append("* ");
sectionTextBuilder.append(LangCodeUtils.getLocalizedResources(context,
Locale.ENGLISH).getString(R.string.android_version));
sectionTextBuilder.append(": ");
sectionTextBuilder.append(feedback.getAndroidVersion());
sectionTextBuilder.append("\n");
}
if (feedback.getDeviceManufacturer() != null) {
sectionTextBuilder.append("* ");
sectionTextBuilder.append(LangCodeUtils.getLocalizedResources(context,
Locale.ENGLISH).getString(R.string.device_manufacturer));
sectionTextBuilder.append(": ");
sectionTextBuilder.append(feedback.getDeviceManufacturer());
sectionTextBuilder.append("\n");
}
if (feedback.getDeviceModel() != null) {
sectionTextBuilder.append("* ");
sectionTextBuilder.append(LangCodeUtils.getLocalizedResources(context,
Locale.ENGLISH).getString(R.string.device_model));
sectionTextBuilder.append(": ");
sectionTextBuilder.append(feedback.getDeviceModel());
sectionTextBuilder.append("\n");
}
if (feedback.getDevice() != null) {
sectionTextBuilder.append("* ");
sectionTextBuilder.append(LangCodeUtils.getLocalizedResources(context,
Locale.ENGLISH).getString(R.string.device_name));
sectionTextBuilder.append(": ");
sectionTextBuilder.append(feedback.getDevice());
sectionTextBuilder.append("\n");
}
if (feedback.getNetworkType() != null) {
sectionTextBuilder.append("* ");
sectionTextBuilder.append(LangCodeUtils.getLocalizedResources(context,
Locale.ENGLISH).getString(R.string.network_type));
sectionTextBuilder.append(": ");
sectionTextBuilder.append(feedback.getNetworkType());
sectionTextBuilder.append("\n");
}
sectionTextBuilder.append("~~~~");
sectionTextBuilder.append("\n");
}
public String getSectionText() {
return sectionTextBuilder.toString();
}
public String getSectionTitle() {
return sectionTitleBuilder.toString();
}
}

View file

@ -0,0 +1,123 @@
package fr.free.nrw.commons.feedback
import android.content.Context
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.getUserName
import fr.free.nrw.commons.feedback.model.Feedback
import fr.free.nrw.commons.utils.LangCodeUtils.getLocalizedResources
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class FeedbackContentCreator(context: Context, feedback: Feedback) {
private var sectionTitleBuilder = StringBuilder()
private var sectionTextBuilder = StringBuilder()
init {
// Localization is not needed here
// because this ends up on a page where developers read the feedback,
// so English is the most convenient.
//Get the UTC Date and Time and add it to the Title
val dateFormat = SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.ENGLISH)
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
val utcFormattedDate = dateFormat.format(Date())
// Construct the feedback section title
sectionTitleBuilder.append("Feedback from ")
sectionTitleBuilder.append(getUserName(context))
sectionTitleBuilder.append(" for version ")
sectionTitleBuilder.append(feedback.version)
sectionTitleBuilder.append(" on ")
sectionTitleBuilder.append(utcFormattedDate)
// Construct the feedback section text
sectionTextBuilder = StringBuilder()
sectionTextBuilder.append("\n")
sectionTextBuilder.append(feedback.title)
sectionTextBuilder.append("\n")
sectionTextBuilder.append("\n")
if (feedback.apiLevel != null) {
sectionTextBuilder.append("* ")
sectionTextBuilder.append(
getLocalizedResources(
context,
Locale.ENGLISH
).getString(R.string.api_level)
)
sectionTextBuilder.append(": ")
sectionTextBuilder.append(feedback.apiLevel)
sectionTextBuilder.append("\n")
}
if (feedback.androidVersion != null) {
sectionTextBuilder.append("* ")
sectionTextBuilder.append(
getLocalizedResources(
context,
Locale.ENGLISH
).getString(R.string.android_version)
)
sectionTextBuilder.append(": ")
sectionTextBuilder.append(feedback.androidVersion)
sectionTextBuilder.append("\n")
}
if (feedback.deviceManufacturer != null) {
sectionTextBuilder.append("* ")
sectionTextBuilder.append(
getLocalizedResources(
context,
Locale.ENGLISH
).getString(R.string.device_manufacturer)
)
sectionTextBuilder.append(": ")
sectionTextBuilder.append(feedback.deviceManufacturer)
sectionTextBuilder.append("\n")
}
if (feedback.deviceModel != null) {
sectionTextBuilder.append("* ")
sectionTextBuilder.append(
getLocalizedResources(
context,
Locale.ENGLISH
).getString(R.string.device_model)
)
sectionTextBuilder.append(": ")
sectionTextBuilder.append(feedback.deviceModel)
sectionTextBuilder.append("\n")
}
if (feedback.device != null) {
sectionTextBuilder.append("* ")
sectionTextBuilder.append(
getLocalizedResources(
context,
Locale.ENGLISH
).getString(R.string.device_name)
)
sectionTextBuilder.append(": ")
sectionTextBuilder.append(feedback.device)
sectionTextBuilder.append("\n")
}
if (feedback.networkType != null) {
sectionTextBuilder.append("* ")
sectionTextBuilder.append(
getLocalizedResources(
context,
Locale.ENGLISH
).getString(R.string.network_type)
)
sectionTextBuilder.append(": ")
sectionTextBuilder.append(feedback.networkType)
sectionTextBuilder.append("\n")
}
sectionTextBuilder.append("~~~~")
sectionTextBuilder.append("\n")
}
fun getSectionText(): String {
return sectionTextBuilder.toString()
}
fun getSectionTitle(): String {
return sectionTitleBuilder.toString()
}
}

View file

@ -1,75 +0,0 @@
package fr.free.nrw.commons.feedback;
import android.app.Dialog;
import android.content.Context;
import android.os.Bundle;
import android.text.Html;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.view.View;
import android.view.WindowManager.LayoutParams;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.databinding.DialogFeedbackBinding;
import fr.free.nrw.commons.feedback.model.Feedback;
import fr.free.nrw.commons.utils.ConfigUtils;
import fr.free.nrw.commons.utils.DeviceInfoUtil;
import java.util.Objects;
/**
* Feedback dialog that asks user for message and
* other device specifications
*/
public class FeedbackDialog extends Dialog {
DialogFeedbackBinding dialogFeedbackBinding;
private OnFeedbackSubmitCallback onFeedbackSubmitCallback;
private Spanned feedbackDestinationHtml;
public FeedbackDialog(Context context, OnFeedbackSubmitCallback onFeedbackSubmitCallback) {
super(context);
this.onFeedbackSubmitCallback = onFeedbackSubmitCallback;
feedbackDestinationHtml = Html.fromHtml(context.getString(R.string.feedback_destination_note));
}
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
dialogFeedbackBinding = DialogFeedbackBinding.inflate(getLayoutInflater());
dialogFeedbackBinding.feedbackDestination.setText(feedbackDestinationHtml);
dialogFeedbackBinding.feedbackDestination.setMovementMethod(LinkMovementMethod.getInstance());
Objects.requireNonNull(getWindow()).setSoftInputMode(LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
final View view = dialogFeedbackBinding.getRoot();
setContentView(view);
dialogFeedbackBinding.btnSubmitFeedback.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
submitFeedback();
}
});
}
/**
* When the button is clicked, it will create a feedback object
* and give a callback to calling activity/fragment
*/
void submitFeedback() {
if(dialogFeedbackBinding.feedbackItemEditText.getText().toString().equals("")) {
dialogFeedbackBinding.feedbackItemEditText.setError(getContext().getString(R.string.enter_description));
return;
}
String appVersion = ConfigUtils.getVersionNameWithSha(getContext());
String androidVersion = dialogFeedbackBinding.androidVersionCheckbox.isChecked() ? DeviceInfoUtil.getAndroidVersion() : null;
String apiLevel = dialogFeedbackBinding.apiLevelCheckbox.isChecked() ? DeviceInfoUtil.getAPILevel() : null;
String deviceManufacturer = dialogFeedbackBinding.deviceManufacturerCheckbox.isChecked() ? DeviceInfoUtil.getDeviceManufacturer() : null;
String deviceModel = dialogFeedbackBinding.deviceModelCheckbox.isChecked() ? DeviceInfoUtil.getDeviceModel() : null;
String deviceName = dialogFeedbackBinding.deviceNameCheckbox.isChecked() ? DeviceInfoUtil.getDevice() : null;
String networkType = dialogFeedbackBinding.networkTypeCheckbox.isChecked() ? DeviceInfoUtil.getConnectionType(getContext()).toString() : null;
Feedback feedback = new Feedback(appVersion, apiLevel
, dialogFeedbackBinding.feedbackItemEditText.getText().toString()
, androidVersion, deviceModel, deviceManufacturer, deviceName, networkType);
onFeedbackSubmitCallback.onFeedbackSubmit(feedback);
dismiss();
}
}

View file

@ -0,0 +1,82 @@
package fr.free.nrw.commons.feedback
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.text.Html
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.view.WindowManager
import fr.free.nrw.commons.R
import fr.free.nrw.commons.databinding.DialogFeedbackBinding
import fr.free.nrw.commons.feedback.model.Feedback
import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha
import fr.free.nrw.commons.utils.DeviceInfoUtil.getAPILevel
import fr.free.nrw.commons.utils.DeviceInfoUtil.getAndroidVersion
import fr.free.nrw.commons.utils.DeviceInfoUtil.getConnectionType
import fr.free.nrw.commons.utils.DeviceInfoUtil.getDevice
import fr.free.nrw.commons.utils.DeviceInfoUtil.getDeviceManufacturer
import fr.free.nrw.commons.utils.DeviceInfoUtil.getDeviceModel
class FeedbackDialog(
context: Context,
private val onFeedbackSubmitCallback: OnFeedbackSubmitCallback) : Dialog(context) {
private var _binding: DialogFeedbackBinding? = null
private val binding get() = _binding!!
// TODO("Remove Deprecation") Issue : #6002
// 'fromHtml(String!): Spanned!' is deprecated. Deprecated in Java
@Suppress("DEPRECATION")
private var feedbackDestinationHtml: Spanned = Html.fromHtml(
context.getString(R.string.feedback_destination_note))
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
_binding = DialogFeedbackBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.feedbackDestination.text = feedbackDestinationHtml
binding.feedbackDestination.movementMethod = LinkMovementMethod.getInstance()
// TODO("DEPRECATION") Issue : #6002
// 'SOFT_INPUT_ADJUST_RESIZE: Int' is deprecated. Deprecated in Java
@Suppress("DEPRECATION")
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
binding.btnSubmitFeedback.setOnClickListener {
submitFeedback()
}
}
fun submitFeedback() {
if (binding.feedbackItemEditText.getText().toString() == "") {
binding.feedbackItemEditText.error = context.getString(R.string.enter_description)
return
}
val appVersion = context.getVersionNameWithSha()
val androidVersion =
if (binding.androidVersionCheckbox.isChecked) getAndroidVersion() else null
val apiLevel =
if (binding.apiLevelCheckbox.isChecked) getAPILevel() else null
val deviceManufacturer =
if (binding.deviceManufacturerCheckbox.isChecked) getDeviceManufacturer() else null
val deviceModel =
if (binding.deviceModelCheckbox.isChecked) getDeviceModel() else null
val deviceName =
if (binding.deviceNameCheckbox.isChecked) getDevice() else null
val networkType =
if (binding.networkTypeCheckbox.isChecked) getConnectionType(
context
).toString() else null
val feedback = Feedback(
appVersion, apiLevel,
binding.feedbackItemEditText.getText().toString(),
androidVersion, deviceModel, deviceManufacturer, deviceName, networkType
)
onFeedbackSubmitCallback.onFeedbackSubmit(feedback)
dismiss()
}
override fun dismiss() {
super.dismiss()
_binding = null
}
}

View file

@ -1,15 +0,0 @@
package fr.free.nrw.commons.feedback;
import fr.free.nrw.commons.feedback.model.Feedback;
/**
* This interface is used to provide callback
* from Feedback dialog whenever submit button is clicked
*/
public interface OnFeedbackSubmitCallback {
/**
* callback function, called when user clicks on submit
*/
void onFeedbackSubmit(Feedback feedback);
}

View file

@ -0,0 +1,7 @@
package fr.free.nrw.commons.feedback
import fr.free.nrw.commons.feedback.model.Feedback
interface OnFeedbackSubmitCallback {
fun onFeedbackSubmit(feedback: Feedback)
}

View file

@ -1,171 +0,0 @@
package fr.free.nrw.commons.feedback.model;
/**
* Pojo class for storing information that are required while uploading a feedback
*/
public class Feedback {
/**
* Version of app
*/
private String version;
/**
* API level of user's phone
*/
private String apiLevel;
/**
* Title/Description entered by user
*/
private String title;
/**
* Android version of user's device
*/
private String androidVersion;
/**
* Device Model of user's device
*/
private String deviceModel;
/**
* Device manufacturer name
*/
private String deviceManufacturer;
/**
* Device name stored on user's device
*/
private String device;
/**
* network type user is having (Ex: Wifi)
*/
private String networkType;
public Feedback(final String version, final String apiLevel, final String title, final String androidVersion,
final String deviceModel, final String deviceManufacturer, final String device, final String networkType
) {
this.version = version;
this.apiLevel = apiLevel;
this.title = title;
this.androidVersion = androidVersion;
this.deviceModel = deviceModel;
this.deviceManufacturer = deviceManufacturer;
this.device = device;
this.networkType = networkType;
}
/**
* Get the version from which this piece of feedback is being sent.
* Ex: 3.0.1
*/
public String getVersion() {
return version;
}
/**
* Set the version of app to given version
*/
public void setVersion(final String version) {
this.version = version;
}
/**
* gets api level of device
* Ex: 28
*/
public String getApiLevel() {
return apiLevel;
}
/**
* sets api level value to given value
*/
public void setApiLevel(final String apiLevel) {
this.apiLevel = apiLevel;
}
/**
* gets feedback text entered by user
*/
public String getTitle() {
return title;
}
/**
* sets feedback text
*/
public void setTitle(final String title) {
this.title = title;
}
/**
* gets android version of device
* Ex: 9
*/
public String getAndroidVersion() {
return androidVersion;
}
/**
* sets value of android version
*/
public void setAndroidVersion(final String androidVersion) {
this.androidVersion = androidVersion;
}
/**
* get device model of current device
* Ex: Redmi 6 Pro
*/
public String getDeviceModel() {
return deviceModel;
}
/**
* sets value of device model to a given value
*/
public void setDeviceModel(final String deviceModel) {
this.deviceModel = deviceModel;
}
/**
* get device manufacturer of user's device
* Ex: Redmi
*/
public String getDeviceManufacturer() {
return deviceManufacturer;
}
/**
* set device manufacturer value to a given value
*/
public void setDeviceManufacturer(final String deviceManufacturer) {
this.deviceManufacturer = deviceManufacturer;
}
/**
* get device name of user's device
*/
public String getDevice() {
return device;
}
/**
* sets device name value to a given value
*/
public void setDevice(final String device) {
this.device = device;
}
/**
* get network type of user's network
* Ex: wifi
*/
public String getNetworkType() {
return networkType;
}
/**
* sets network type to a given value
*/
public void setNetworkType(final String networkType) {
this.networkType = networkType;
}
}

View file

@ -0,0 +1,30 @@
package fr.free.nrw.commons.feedback.model
/**
* Pojo class for storing information that are required while uploading a feedback
*/
data class Feedback (
// Version of app
var version : String? = null,
// API level of user's phone
var apiLevel: String? = null,
// Title/Description entered by user
var title: String? = null,
// Android version of user's device
var androidVersion: String? = null,
// Device Model of user's device
var deviceModel: String? = null,
// Device manufacturer name
var deviceManufacturer: String? = null,
// Device name stored on user's device
var device: String? = null,
// network type user is having (Ex: Wifi)
var networkType: String? = null
)

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

@ -0,0 +1,35 @@
package fr.free.nrw.commons.fileusages
import com.google.gson.annotations.SerializedName
/**
* Show where file is being used on Commons and oher wikis.
*/
data class FileUsagesResponse(
@SerializedName("continue") val continueResponse: CommonsContinue?,
@SerializedName("batchcomplete") val batchComplete: Boolean,
@SerializedName("query") val query: Query,
)
data class CommonsContinue(
@SerializedName("fucontinue") val fuContinue: String,
@SerializedName("continue") val continueKey: String
)
data class Query(
@SerializedName("pages") val pages: List<Page>
)
data class Page(
@SerializedName("pageid") val pageId: Int,
@SerializedName("ns") val nameSpace: Int,
@SerializedName("title") val title: String,
@SerializedName("fileusage") val fileUsage: List<FileUsage>
)
data class FileUsage(
@SerializedName("pageid") val pageId: Int,
@SerializedName("ns") val nameSpace: Int,
@SerializedName("title") val title: String,
@SerializedName("redirect") val redirect: Boolean
)

View file

@ -0,0 +1,18 @@
package fr.free.nrw.commons.fileusages
/**
* Show where file is being used on Commons and oher wikis.
*/
data class FileUsagesUiModel(
val title: String,
val link: String?
)
fun FileUsage.toUiModel(): FileUsagesUiModel {
return FileUsagesUiModel(title = title, link = "https://commons.wikimedia.org/wiki/$title")
}
fun GlobalFileUsage.toUiModel(): FileUsagesUiModel {
// link is associated with sub items under wiki group (which is not used ATM)
return FileUsagesUiModel(title = wiki, link = null)
}

View file

@ -0,0 +1,34 @@
package fr.free.nrw.commons.fileusages
import com.google.gson.annotations.SerializedName
/**
* Show where file is being used on Commons and oher wikis.
*/
data class GlobalFileUsagesResponse(
@SerializedName("continue") val continueResponse: GlobalContinue?,
@SerializedName("batchcomplete") val batchComplete: Boolean,
@SerializedName("query") val query: GlobalQuery,
)
data class GlobalContinue(
@SerializedName("gucontinue") val guContinue: String,
@SerializedName("continue") val continueKey: String
)
data class GlobalQuery(
@SerializedName("pages") val pages: List<GlobalPage>
)
data class GlobalPage(
@SerializedName("pageid") val pageId: Int,
@SerializedName("ns") val nameSpace: Int,
@SerializedName("title") val title: String,
@SerializedName("globalusage") val fileUsage: List<GlobalFileUsage>
)
data class GlobalFileUsage(
@SerializedName("title") val title: String,
@SerializedName("wiki") val wiki: String,
@SerializedName("url") val url: String
)

View file

@ -41,6 +41,13 @@ data class LatLng(
* Accepts a non-null [Location] and converts it to a [LatLng].
*/
companion object {
fun latLongOrNull(latitude: String?, longitude: String?): LatLng? =
if (!latitude.isNullOrBlank() && !longitude.isNullOrBlank()) {
LatLng(latitude.toDouble(), longitude.toDouble(), 0.0f)
} else {
null
}
/**
* gets the latitude and longitude of a given non-null location
* @param location the non-null location of the user

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,116 @@
package fr.free.nrw.commons.media
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import fr.free.nrw.commons.R
import fr.free.nrw.commons.fileusages.FileUsagesUiModel
import fr.free.nrw.commons.fileusages.toUiModel
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
/**
* Show where file is being used on Commons and oher wikis.
*/
class MediaDetailViewModel(
private val applicationContext: Context,
private val okHttpJsonApiClient: OkHttpJsonApiClient
) :
ViewModel() {
private val _commonsContainerState =
MutableStateFlow<FileUsagesContainerState>(FileUsagesContainerState.Initial)
val commonsContainerState = _commonsContainerState.asStateFlow()
private val _globalContainerState =
MutableStateFlow<FileUsagesContainerState>(FileUsagesContainerState.Initial)
val globalContainerState = _globalContainerState.asStateFlow()
fun loadFileUsagesCommons(fileName: String) {
viewModelScope.launch {
_commonsContainerState.update { FileUsagesContainerState.Loading }
try {
val result =
okHttpJsonApiClient.getFileUsagesOnCommons(fileName, 10)
val data = result?.query?.pages?.first()?.fileUsage?.map { it.toUiModel() }
_commonsContainerState.update { FileUsagesContainerState.Success(data = data) }
} catch (e: Exception) {
_commonsContainerState.update {
FileUsagesContainerState.Error(
errorMessage = applicationContext.getString(
R.string.error_while_loading
)
)
}
Timber.e(e, javaClass.simpleName)
}
}
}
fun loadGlobalFileUsages(fileName: String) {
viewModelScope.launch {
_globalContainerState.update { FileUsagesContainerState.Loading }
try {
val result = okHttpJsonApiClient.getGlobalFileUsages(fileName, 10)
val data = result?.query?.pages?.first()?.fileUsage?.map { it.toUiModel() }
_globalContainerState.update { FileUsagesContainerState.Success(data = data) }
} catch (e: Exception) {
_globalContainerState.update {
FileUsagesContainerState.Error(
errorMessage = applicationContext.getString(
R.string.error_while_loading
)
)
}
Timber.e(e, javaClass.simpleName)
}
}
}
sealed class FileUsagesContainerState {
object Initial : FileUsagesContainerState()
object Loading : FileUsagesContainerState()
data class Success(val data: List<FileUsagesUiModel>?) : FileUsagesContainerState()
data class Error(val errorMessage: String) : FileUsagesContainerState()
}
class MediaDetailViewModelProviderFactory
@Inject constructor(
private val okHttpJsonApiClient: OkHttpJsonApiClient,
private val applicationContext: Context
) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MediaDetailViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return MediaDetailViewModel(applicationContext, okHttpJsonApiClient) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
}

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,624 @@
package fr.free.nrw.commons.mwapi
import android.text.TextUtils
import com.google.gson.Gson
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.campaigns.CampaignResponseDTO
import fr.free.nrw.commons.explore.depictions.DepictsClient
import fr.free.nrw.commons.fileusages.FileUsagesResponse
import fr.free.nrw.commons.fileusages.GlobalFileUsagesResponse
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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()
})
}
/**
* Show where file is being used on Commons.
*/
suspend fun getFileUsagesOnCommons(
fileName: String?,
pageSize: Int
): FileUsagesResponse? {
return withContext(Dispatchers.IO) {
return@withContext try {
val urlBuilder = BuildConfig.FILE_USAGES_BASE_URL.toHttpUrlOrNull()!!.newBuilder()
urlBuilder.addQueryParameter("prop", "fileusage")
urlBuilder.addQueryParameter("titles", fileName)
urlBuilder.addQueryParameter("fulimit", pageSize.toString())
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()
gson.fromJson<FileUsagesResponse>(
json,
FileUsagesResponse::class.java
)
} else null
} catch (e: Exception) {
Timber.e(e)
null
}
}
}
/**
* Show where file is being used on non-Commons wikis, typically the Wikipedias in various languages.
*/
suspend fun getGlobalFileUsages(
fileName: String?,
pageSize: Int
): GlobalFileUsagesResponse? {
return withContext(Dispatchers.IO) {
return@withContext try {
val urlBuilder = BuildConfig.FILE_USAGES_BASE_URL.toHttpUrlOrNull()!!.newBuilder()
urlBuilder.addQueryParameter("prop", "globalusage")
urlBuilder.addQueryParameter("titles", fileName)
urlBuilder.addQueryParameter("gulimit", pageSize.toString())
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()
gson.fromJson<GlobalFileUsagesResponse>(
json,
GlobalFileUsagesResponse::class.java
)
} else null
} catch (e: Exception) {
Timber.e(e)
null
}
}
}
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

@ -10,7 +10,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
@ -25,6 +24,7 @@ import fr.free.nrw.commons.databinding.FragmentMoreBottomSheetBinding
import fr.free.nrw.commons.di.ApplicationlessInjection
import fr.free.nrw.commons.feedback.FeedbackContentCreator
import fr.free.nrw.commons.feedback.FeedbackDialog
import fr.free.nrw.commons.feedback.OnFeedbackSubmitCallback
import fr.free.nrw.commons.feedback.model.Feedback
import fr.free.nrw.commons.kvstore.BasicKvStore
import fr.free.nrw.commons.kvstore.JsonKvStore
@ -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
)
}
}
@ -148,7 +156,11 @@ class MoreBottomSheetFragment : BottomSheetDialogFragment() {
* Creates and shows a dialog asking feedback from users
*/
private fun showFeedbackDialog() {
FeedbackDialog(requireContext()) { uploadFeedback(it) }.show()
FeedbackDialog(requireContext(), object : OnFeedbackSubmitCallback{
override fun onFeedbackSubmit(feedback: Feedback) {
uploadFeedback(feedback)
}
}).show()
}
/**
@ -160,8 +172,8 @@ class MoreBottomSheetFragment : BottomSheetDialogFragment() {
val single = pageEditClient.createNewSection(
"Commons:Mobile_app/Feedback",
feedbackContentCreator.sectionTitle,
feedbackContentCreator.sectionText,
feedbackContentCreator.getSectionTitle(),
feedbackContentCreator.getSectionText(),
"New feedback on version ${feedback.version} of the app"
)
.flatMapSingle { Single.just(it) }

View file

@ -64,8 +64,8 @@ class NotificationClient
return Notification(
notificationType = notificationType,
notificationText = notificationText,
date = DateUtil.getMonthOnlyDateString(timestamp),
link = contents?.links?.primary?.url ?: "",
date = DateUtil.getMonthOnlyDateString(getTimestamp()),
link = contents?.links?.getPrimary()?.url ?: "",
iconUrl = "",
notificationId = id().toString(),
)

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

@ -201,7 +201,7 @@ class ReviewActivity : BaseActivity() {
val caption = getString(
R.string.review_is_uploaded_by,
fileName,
revision.user
revision.user()
)
binding.tvImageCaption.text = caption
binding.pbReviewImage.visibility = View.GONE

View file

@ -12,7 +12,6 @@ import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage
import java.util.ArrayList
import java.util.concurrent.Callable
import javax.inject.Inject
import javax.inject.Named
@ -27,7 +26,6 @@ import fr.free.nrw.commons.delete.DeleteHelper
import fr.free.nrw.commons.di.ApplicationlessInjection
import fr.free.nrw.commons.utils.ViewUtil
import io.reactivex.Observable
import io.reactivex.ObservableSource
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
@ -175,7 +173,7 @@ class ReviewController @Inject constructor(
if (firstRevision == null) return
Observable.defer {
thanksClient.thank(firstRevision!!.revisionId)
thanksClient.thank(firstRevision!!.revisionId())
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())

View file

@ -34,7 +34,7 @@ class ReviewHelper
reviewInterface
.getRecentChanges()
.map { it.query()?.pages() }
.map(MutableList<MwQueryPage>::shuffled)
.map { it.shuffled() }
.flatMapIterable { changes: List<MwQueryPage>? -> changes }
.filter { isChangeReviewable(it) }

View file

@ -3,18 +3,15 @@ package fr.free.nrw.commons.review
import android.graphics.Color
import android.os.Bundle
import android.text.Html
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import fr.free.nrw.commons.databinding.FragmentReviewImageBinding
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
import java.util.ArrayList
import javax.inject.Inject
@ -126,7 +123,7 @@ class ReviewImageFragment : CommonsDaggerSupportFragment() {
enableButtons()
question = getString(R.string.review_thanks)
user = reviewActivity.reviewController.firstRevision?.user
user = reviewActivity.reviewController.firstRevision?.user()
?: savedInstanceState?.getString(SAVED_USER)
//if the user is null because of whatsoever reason, review will not be sent anyways

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

@ -270,7 +270,7 @@ class UploadClient
if (uploadResult.upload == null) {
val exception = gson.fromJson(uploadResponse, MwException::class.java)
Timber.e(exception, "Error in uploading file from stash")
throw Exception(exception.getErrorCode())
throw Exception(exception.errorCode)
}
uploadResult.upload
}

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

@ -1,36 +0,0 @@
package fr.free.nrw.commons.wikidata.model.edit;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse;
public class Edit extends MwPostResponse {
@Nullable private Result edit;
@Nullable public Result edit() {
return edit;
}
public class Result {
@Nullable private String result;
@Nullable private String code;
@Nullable private String info;
@Nullable private String warning;
public boolean editSucceeded() {
return "Success".equals(result);
}
@Nullable public String code() {
return code;
}
@Nullable public String info() {
return info;
}
@Nullable public String warning() {
return warning;
}
}
}

View file

@ -0,0 +1,25 @@
package fr.free.nrw.commons.wikidata.model.edit
import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse
class Edit : MwPostResponse() {
private val edit: Result? = null
fun edit(): Result? = edit
class Result {
private val result: String? = null
private val code: String? = null
private val info: String? = null
private val warning: String? = null
fun editSucceeded(): Boolean =
"Success" == result
fun code(): String? = code
fun info(): String? = info
fun warning(): String? = warning
}
}

View file

@ -1,31 +0,0 @@
package fr.free.nrw.commons.wikidata.model.edit;
import android.os.Parcel;
import android.os.Parcelable;
import fr.free.nrw.commons.wikidata.model.BaseModel;
public abstract class EditResult extends BaseModel implements Parcelable {
private final String result;
public EditResult(String result) {
this.result = result;
}
protected EditResult(Parcel in) {
this.result = in.readString();
}
public String getResult() {
return result;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(result);
}
}

View file

@ -1,102 +0,0 @@
package fr.free.nrw.commons.wikidata.model.gallery;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.annotations.SerializedName;
import org.apache.commons.lang3.StringUtils;
public class ExtMetadata {
@SerializedName("DateTime") @Nullable private Values dateTime;
@SerializedName("ObjectName") @Nullable private Values objectName;
@SerializedName("CommonsMetadataExtension") @Nullable private Values commonsMetadataExtension;
@SerializedName("Categories") @Nullable private Values categories;
@SerializedName("Assessments") @Nullable private Values assessments;
@SerializedName("GPSLatitude") @Nullable private Values gpsLatitude;
@SerializedName("GPSLongitude") @Nullable private Values gpsLongitude;
@SerializedName("ImageDescription") @Nullable private Values imageDescription;
@SerializedName("DateTimeOriginal") @Nullable private Values dateTimeOriginal;
@SerializedName("Artist") @Nullable private Values artist;
@SerializedName("Credit") @Nullable private Values credit;
@SerializedName("Permission") @Nullable private Values permission;
@SerializedName("AuthorCount") @Nullable private Values authorCount;
@SerializedName("LicenseShortName") @Nullable private Values licenseShortName;
@SerializedName("UsageTerms") @Nullable private Values usageTerms;
@SerializedName("LicenseUrl") @Nullable private Values licenseUrl;
@SerializedName("AttributionRequired") @Nullable private Values attributionRequired;
@SerializedName("Copyrighted") @Nullable private Values copyrighted;
@SerializedName("Restrictions") @Nullable private Values restrictions;
@SerializedName("License") @Nullable private Values license;
@NonNull public String licenseShortName() {
return StringUtils.defaultString(licenseShortName == null ? null : licenseShortName.value());
}
@NonNull public String licenseUrl() {
return StringUtils.defaultString(licenseUrl == null ? null : licenseUrl.value());
}
@NonNull public String license() {
return StringUtils.defaultString(license == null ? null : license.value());
}
@NonNull public String imageDescription() {
return StringUtils.defaultString(imageDescription == null ? null : imageDescription.value());
}
@NonNull public String imageDescriptionSource() {
return StringUtils.defaultString(imageDescription == null ? null : imageDescription.source());
}
@NonNull public String objectName() {
return StringUtils.defaultString(objectName == null ? null : objectName.value());
}
@NonNull public String usageTerms() {
return StringUtils.defaultString(usageTerms == null ? null : usageTerms.value());
}
@NonNull public String dateTimeOriginal() {
return StringUtils.defaultString(dateTimeOriginal == null ? null : dateTimeOriginal.value());
}
@NonNull public String dateTime() {
return StringUtils.defaultString(dateTime == null ? null : dateTime.value());
}
@NonNull public String artist() {
return StringUtils.defaultString(artist == null ? null : artist.value());
}
@NonNull public String getCategories() {
return StringUtils.defaultString(categories == null ? null : categories.value());
}
@NonNull public String getGpsLatitude() {
return StringUtils.defaultString(gpsLatitude == null ? null : gpsLatitude.value());
}
@NonNull public String getGpsLongitude() {
return StringUtils.defaultString(gpsLongitude == null ? null : gpsLongitude.value());
}
@NonNull public String credit() {
return StringUtils.defaultString(credit == null ? null : credit.value());
}
public class Values {
@Nullable private String value;
@Nullable private String source;
@Nullable private String hidden;
@Nullable public String value() {
return value;
}
@Nullable public String source() {
return source;
}
}
}

View file

@ -0,0 +1,61 @@
package fr.free.nrw.commons.wikidata.model.gallery
import com.google.gson.annotations.SerializedName
import org.apache.commons.lang3.StringUtils
class ExtMetadata {
@SerializedName("DateTime") private val dateTime: Values? = null
@SerializedName("ObjectName") private val objectName: Values? = null
@SerializedName("CommonsMetadataExtension") private val commonsMetadataExtension: Values? = null
@SerializedName("Categories") private val categories: Values? = null
@SerializedName("Assessments") private val assessments: Values? = null
@SerializedName("GPSLatitude") private val gpsLatitude: Values? = null
@SerializedName("GPSLongitude") private val gpsLongitude: Values? = null
@SerializedName("ImageDescription") private val imageDescription: Values? = null
@SerializedName("DateTimeOriginal") private val dateTimeOriginal: Values? = null
@SerializedName("Artist") private val artist: Values? = null
@SerializedName("Credit") private val credit: Values? = null
@SerializedName("Permission") private val permission: Values? = null
@SerializedName("AuthorCount") private val authorCount: Values? = null
@SerializedName("LicenseShortName") private val licenseShortName: Values? = null
@SerializedName("UsageTerms") private val usageTerms: Values? = null
@SerializedName("LicenseUrl") private val licenseUrl: Values? = null
@SerializedName("AttributionRequired") private val attributionRequired: Values? = null
@SerializedName("Copyrighted") private val copyrighted: Values? = null
@SerializedName("Restrictions") private val restrictions: Values? = null
@SerializedName("License") private val license: Values? = null
fun licenseShortName(): String = licenseShortName?.value ?: ""
fun licenseUrl(): String = licenseUrl?.value ?: ""
fun license(): String = license?.value ?: ""
fun imageDescription(): String = imageDescription?.value ?: ""
fun imageDescriptionSource(): String = imageDescription?.source ?: ""
fun objectName(): String = objectName?.value ?: ""
fun usageTerms(): String = usageTerms?.value ?: ""
fun dateTimeOriginal(): String = dateTimeOriginal?.value ?: ""
fun dateTime(): String = dateTime?.value ?: ""
fun artist(): String = artist?.value ?: ""
fun categories(): String = categories?.value ?: ""
fun gpsLatitude(): String = gpsLatitude?.value ?: ""
fun gpsLongitude(): String = gpsLongitude?.value ?: ""
fun credit(): String = credit?.value ?: ""
class Values {
val value: String? = null
val source: String? = null
val hidden: String? = null
}
}

View file

@ -1,121 +0,0 @@
package fr.free.nrw.commons.wikidata.model.gallery;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.annotations.SerializedName;
import org.apache.commons.lang3.StringUtils;
import java.io.Serializable;
/**
* Gson POJO for a standard image info object as returned by the API ImageInfo module
*/
public class ImageInfo implements Serializable {
private int size;
private int width;
private int height;
@Nullable private String source;
@SerializedName("thumburl") @Nullable private String thumbUrl;
@SerializedName("thumbwidth") private int thumbWidth;
@SerializedName("thumbheight") private int thumbHeight;
@SerializedName("url") @Nullable private String originalUrl;
@SerializedName("descriptionurl") @Nullable private String descriptionUrl;
@SerializedName("descriptionshorturl") @Nullable private String descriptionShortUrl;
@SerializedName("mime") @Nullable private String mimeType;
@SerializedName("extmetadata")@Nullable private ExtMetadata metadata;
@Nullable private String user;
@Nullable private String timestamp;
/**
* Query width, default width parameter of the API query in pixels.
*/
final private static int QUERY_WIDTH = 640;
/**
* Threshold height, the minimum height of the image in pixels.
*/
final private static int THRESHOLD_HEIGHT = 220;
@NonNull
public String getSource() {
return StringUtils.defaultString(source);
}
public void setSource(@Nullable String source) {
this.source = source;
}
public int getSize() {
return size;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
/**
* Get the thumbnail width.
* @return
*/
public int getThumbWidth() { return thumbWidth; }
/**
* Get the thumbnail height.
* @return
*/
public int getThumbHeight() { return thumbHeight; }
@NonNull public String getMimeType() {
return StringUtils.defaultString(mimeType, "*/*");
}
@NonNull public String getThumbUrl() {
updateThumbUrl();
return StringUtils.defaultString(thumbUrl);
}
@NonNull public String getOriginalUrl() {
return StringUtils.defaultString(originalUrl);
}
@NonNull public String getUser() {
return StringUtils.defaultString(user);
}
@NonNull public String getTimestamp() {
return StringUtils.defaultString(timestamp);
}
@Nullable public ExtMetadata getMetadata() {
return metadata;
}
/**
* Updates the ThumbUrl if image dimensions are not sufficient.
* Specifically, in panoramic images the height retrieved is less than required due to large width to height ratio,
* so we update the thumb url keeping a minimum height threshold.
*/
private void updateThumbUrl() {
// If thumbHeight retrieved from API is less than THRESHOLD_HEIGHT
if(getThumbHeight() < THRESHOLD_HEIGHT){
// If thumbWidthRetrieved is same as queried width ( If not tells us that the image has no larger dimensions. )
if(getThumbWidth() == QUERY_WIDTH){
// Calculate new width depending on the aspect ratio.
final int finalWidth = (int)(THRESHOLD_HEIGHT * getThumbWidth() * 1.0 / getThumbHeight());
thumbHeight = THRESHOLD_HEIGHT;
thumbWidth = finalWidth;
final String toReplace = "/" + QUERY_WIDTH + "px";
final int position = thumbUrl.lastIndexOf(toReplace);
thumbUrl = (new StringBuilder(thumbUrl)).replace(position, position + toReplace.length(), "/" + thumbWidth + "px").toString();
}
}
}
}

View file

@ -0,0 +1,129 @@
package fr.free.nrw.commons.wikidata.model.gallery
import com.google.gson.annotations.SerializedName
import org.apache.commons.lang3.StringUtils
import java.io.Serializable
/**
* Gson POJO for a standard image info object as returned by the API ImageInfo module
*/
open class ImageInfo : Serializable {
private val size = 0
private val width = 0
private val height = 0
private var source: String? = null
@SerializedName("thumburl")
private var thumbUrl: String? = null
@SerializedName("thumbwidth")
private var thumbWidth = 0
@SerializedName("thumbheight")
private var thumbHeight = 0
@SerializedName("url")
private val originalUrl: String? = null
@SerializedName("descriptionurl")
private val descriptionUrl: String? = null
@SerializedName("descriptionshorturl")
private val descriptionShortUrl: String? = null
@SerializedName("mime")
private val mimeType: String? = null
@SerializedName("extmetadata")
private val metadata: ExtMetadata? = null
private val user: String? = null
private val timestamp: String? = null
fun getSource(): String {
return source ?: ""
}
fun setSource(source: String?) {
this.source = source
}
fun getSize(): Int {
return size
}
fun getWidth(): Int {
return width
}
fun getHeight(): Int {
return height
}
fun getThumbWidth(): Int {
return thumbWidth
}
fun getThumbHeight(): Int {
return thumbHeight
}
fun getMimeType(): String {
return mimeType ?: "*/*"
}
fun getThumbUrl(): String {
updateThumbUrl()
return thumbUrl ?: ""
}
fun getOriginalUrl(): String {
return originalUrl ?: ""
}
fun getUser(): String {
return user ?: ""
}
fun getTimestamp(): String {
return timestamp ?: ""
}
fun getMetadata(): ExtMetadata? = metadata
/**
* Updates the ThumbUrl if image dimensions are not sufficient. Specifically, in panoramic
* images the height retrieved is less than required due to large width to height ratio, so we
* update the thumb url keeping a minimum height threshold.
*/
private fun updateThumbUrl() {
// If thumbHeight retrieved from API is less than THRESHOLD_HEIGHT
if (getThumbHeight() < THRESHOLD_HEIGHT) {
// If thumbWidthRetrieved is same as queried width ( If not tells us that the image has no larger dimensions. )
if (getThumbWidth() == QUERY_WIDTH) {
// Calculate new width depending on the aspect ratio.
val finalWidth = (THRESHOLD_HEIGHT * getThumbWidth() * 1.0
/ getThumbHeight()).toInt()
thumbHeight = THRESHOLD_HEIGHT
thumbWidth = finalWidth
val toReplace = "/" + QUERY_WIDTH + "px"
val position = thumbUrl!!.lastIndexOf(toReplace)
thumbUrl = (StringBuilder(thumbUrl ?: "")).replace(
position,
position + toReplace.length, "/" + thumbWidth + "px"
).toString()
}
}
}
companion object {
/**
* Query width, default width parameter of the API query in pixels.
*/
private const val QUERY_WIDTH = 640
/**
* Threshold height, the minimum height of the image in pixels.
*/
private const val THRESHOLD_HEIGHT = 220
}
}

View file

@ -1,16 +0,0 @@
package fr.free.nrw.commons.wikidata.model.gallery;
import androidx.annotation.Nullable;
import com.google.gson.annotations.SerializedName;
import java.util.List;
/**
* Gson POJO for a standard video info object as returned by the API VideoInfo module
*/
public class VideoInfo extends ImageInfo {
@Nullable private List<String> codecs;
@SuppressWarnings("unused,NullableProblems") @Nullable private String name;
@SuppressWarnings("unused,NullableProblems") @Nullable @SerializedName("short_name") private String shortName;
}

View file

@ -1,190 +0,0 @@
package fr.free.nrw.commons.wikidata.model.notifications;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.annotations.SerializedName;
import fr.free.nrw.commons.utils.DateUtil;
import org.apache.commons.lang3.StringUtils;
import fr.free.nrw.commons.wikidata.GsonUtil;
import java.text.ParseException;
import java.util.Date;
import timber.log.Timber;
public class Notification {
@Nullable private String wiki;
private long id;
@Nullable private String type;
@Nullable private String category;
@Nullable private Title title;
@Nullable private Timestamp timestamp;
@SerializedName("*") @Nullable private Contents contents;
@NonNull public String wiki() {
return StringUtils.defaultString(wiki);
}
public long id() {
return id;
}
public void setId(final long id) {
this.id = id;
}
public long key() {
return id + wiki().hashCode();
}
@NonNull public String type() {
return StringUtils.defaultString(type);
}
@Nullable public Title title() {
return title;
}
@Nullable public Contents getContents() {
return contents;
}
public void setContents(@Nullable final Contents contents) {
this.contents = contents;
}
@NonNull public Date getTimestamp() {
return timestamp != null ? timestamp.date() : new Date();
}
public void setTimestamp(@Nullable final Timestamp timestamp) {
this.timestamp = timestamp;
}
@NonNull String getUtcIso8601() {
return StringUtils.defaultString(timestamp != null ? timestamp.utciso8601 : null);
}
public boolean isFromWikidata() {
return wiki().equals("wikidatawiki");
}
@Override public String toString() {
return Long.toString(id);
}
public static class Title {
@Nullable private String full;
@Nullable private String text;
@NonNull public String text() {
return StringUtils.defaultString(text);
}
@NonNull public String full() {
return StringUtils.defaultString(full);
}
}
public static class Timestamp {
@Nullable private String utciso8601;
public void setUtciso8601(@Nullable final String utciso8601) {
this.utciso8601 = utciso8601;
}
public Date date() {
try {
return DateUtil.iso8601DateParse(utciso8601);
} catch (ParseException e) {
Timber.e(e);
return new Date();
}
}
}
public static class Link {
@Nullable private String url;
@Nullable private String label;
@Nullable private String tooltip;
@Nullable private String description;
@Nullable private String icon;
@NonNull public String getUrl() {
return StringUtils.defaultString(url);
}
public void setUrl(@Nullable final String url) {
this.url = url;
}
@NonNull public String getTooltip() {
return StringUtils.defaultString(tooltip);
}
@NonNull public String getLabel() {
return StringUtils.defaultString(label);
}
@NonNull public String getIcon() {
return StringUtils.defaultString(icon);
}
}
public static class Links {
@Nullable private JsonElement primary;
private Link primaryLink;
public void setPrimary(@Nullable final JsonElement primary) {
this.primary = primary;
}
@Nullable public Link getPrimary() {
if (primary == null) {
return null;
}
if (primaryLink == null && primary instanceof JsonObject) {
primaryLink = GsonUtil.getDefaultGson().fromJson(primary, Link.class);
}
return primaryLink;
}
}
public static class Contents {
@Nullable private String header;
@Nullable private String compactHeader;
@Nullable private String body;
@Nullable private String icon;
@Nullable private Links links;
@NonNull public String getHeader() {
return StringUtils.defaultString(header);
}
@NonNull public String getCompactHeader() {
return StringUtils.defaultString(compactHeader);
}
public void setCompactHeader(@Nullable final String compactHeader) {
this.compactHeader = compactHeader;
}
@NonNull public String getBody() {
return StringUtils.defaultString(body);
}
@Nullable public Links getLinks() {
return links;
}
public void setLinks(@Nullable final Links links) {
this.links = links;
}
}
}

View file

@ -0,0 +1,124 @@
package fr.free.nrw.commons.wikidata.model.notifications
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.annotations.SerializedName
import fr.free.nrw.commons.utils.DateUtil.iso8601DateParse
import fr.free.nrw.commons.wikidata.GsonUtil.defaultGson
import org.apache.commons.lang3.StringUtils
import timber.log.Timber
import java.text.ParseException
import java.util.Date
class Notification {
private val wiki: String? = null
private var id: Long = 0
private val type: String? = null
private val category: String? = null
private val title: Title? = null
private var timestamp: Timestamp? = null
@SerializedName("*")
var contents: Contents? = null
fun wiki(): String = wiki ?: ""
fun id(): Long = id
fun setId(id: Long) {
this.id = id
}
fun key(): Long =
id + wiki().hashCode()
fun type(): String =
type ?: ""
fun title(): Title? = title
fun getTimestamp(): Date =
timestamp?.date() ?: Date()
fun setTimestamp(timestamp: Timestamp?) {
this.timestamp = timestamp
}
val utcIso8601: String
get() = timestamp?.utciso8601 ?: ""
val isFromWikidata: Boolean
get() = wiki() == "wikidatawiki"
override fun toString(): String =
id.toString()
class Title {
private val full: String? = null
private val text: String? = null
fun text(): String = text ?: ""
fun full(): String = full ?: ""
}
class Timestamp {
internal var utciso8601: String? = null
fun setUtciso8601(utciso8601: String?) {
this.utciso8601 = utciso8601
}
fun date(): Date {
try {
return iso8601DateParse(utciso8601 ?: "")
} catch (e: ParseException) {
Timber.e(e)
return Date()
}
}
}
class Link {
var url: String? = null
get() = field ?: ""
val label: String? = null
get() = field ?: ""
val tooltip: String? = null
get() = field ?: ""
private val description: String? = null
val icon: String? = null
get() = field ?: ""
}
class Links {
private var primary: JsonElement? = null
private var primaryLink: Link? = null
fun setPrimary(primary: JsonElement?) {
this.primary = primary
}
fun getPrimary(): Link? {
if (primary == null) {
return null
}
if (primaryLink == null && primary is JsonObject) {
primaryLink = defaultGson.fromJson(primary, Link::class.java)
}
return primaryLink
}
}
class Contents {
val header: String? = null
get() = field ?: ""
var compactHeader: String? = null
get() = field ?: ""
val body: String? = null
get() = field ?: ""
private val icon: String? = null
var links: Links? = null
}
}

View file

@ -1,28 +0,0 @@
package fr.free.nrw.commons.wikidata.model.page;
import android.location.Location;
import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject;
public final class GeoMarshaller {
@Nullable
public static String marshal(@Nullable Location object) {
if (object == null) {
return null;
}
JSONObject jsonObj = new JSONObject();
try {
jsonObj.put(GeoUnmarshaller.LATITUDE, object.getLatitude());
jsonObj.put(GeoUnmarshaller.LONGITUDE, object.getLongitude());
} catch (JSONException e) {
throw new RuntimeException(e);
}
return jsonObj.toString();
}
private GeoMarshaller() { }
}

Some files were not shown because too many files have changed in this diff Show more