mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-30 22:34:02 +01:00
Merge branch 'main' into recent-search
This commit is contained in:
commit
59fe558ad4
223 changed files with 8059 additions and 7995 deletions
|
|
@ -47,18 +47,19 @@ dependencies {
|
||||||
|
|
||||||
implementation 'com.jakewharton.timber:timber:4.7.1'
|
implementation 'com.jakewharton.timber:timber:4.7.1'
|
||||||
implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0'
|
implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0'
|
||||||
implementation "com.google.android.material:material:1.9.0"
|
implementation "com.google.android.material:material:1.12.0"
|
||||||
implementation 'com.karumi:dexter:5.0.0'
|
implementation 'com.karumi:dexter:5.0.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||||
|
|
||||||
// Jetpack Compose
|
// 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 "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4"
|
||||||
implementation (composeBom)
|
implementation (composeBom)
|
||||||
implementation "androidx.compose.runtime:runtime"
|
implementation "androidx.compose.runtime:runtime"
|
||||||
implementation "androidx.compose.ui:ui"
|
implementation "androidx.compose.ui:ui"
|
||||||
|
implementation "androidx.compose.ui:ui-viewbinding"
|
||||||
implementation "androidx.compose.ui:ui-graphics"
|
implementation "androidx.compose.ui:ui-graphics"
|
||||||
implementation "androidx.compose.ui:ui-tooling"
|
implementation "androidx.compose.ui:ui-tooling"
|
||||||
implementation "androidx.compose.foundation:foundation"
|
implementation "androidx.compose.foundation:foundation"
|
||||||
|
|
@ -138,7 +139,7 @@ dependencies {
|
||||||
implementation "androidx.browser:browser:1.3.0"
|
implementation "androidx.browser:browser:1.3.0"
|
||||||
implementation "androidx.cardview:cardview:1.0.0"
|
implementation "androidx.cardview:cardview:1.0.0"
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
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 "androidx.core:core-ktx:$CORE_KTX_VERSION"
|
||||||
implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1'
|
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", "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", "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", "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", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\""
|
||||||
buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\""
|
buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\""
|
||||||
buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.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", "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", "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", "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", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\""
|
||||||
buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\""
|
buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\""
|
||||||
buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\""
|
buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\""
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ class AboutActivityTest {
|
||||||
fun testLaunchTranslate() {
|
fun testLaunchTranslate() {
|
||||||
Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click())
|
Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click())
|
||||||
Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click())
|
Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click())
|
||||||
val langCode = CommonsApplication.instance.languageLookUpTable!!.codes[0]
|
val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0]
|
||||||
Intents.intended(
|
Intents.intended(
|
||||||
CoreMatchers.allOf(
|
CoreMatchers.allOf(
|
||||||
IntentMatchers.hasAction(Intent.ACTION_VIEW),
|
IntentMatchers.hasAction(Intent.ACTION_VIEW),
|
||||||
|
|
|
||||||
|
|
@ -237,7 +237,7 @@ class LoginClient(
|
||||||
.subscribe({ response: MwQueryResponse? ->
|
.subscribe({ response: MwQueryResponse? ->
|
||||||
loginResult.userId = response?.query()?.userInfo()?.id() ?: 0
|
loginResult.userId = response?.query()?.userInfo()?.id() ?: 0
|
||||||
loginResult.groups =
|
loginResult.groups =
|
||||||
response?.query()?.getUserResponse(userName)?.groups ?: emptySet()
|
response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet()
|
||||||
cb.success(loginResult)
|
cb.success(loginResult)
|
||||||
}, { caught: Throwable ->
|
}, { caught: Throwable ->
|
||||||
Timber.e(caught, "Login succeeded but getting group information failed. ")
|
Timber.e(caught, "Login succeeded but getting group information failed. ")
|
||||||
|
|
|
||||||
|
|
@ -52,12 +52,12 @@ class CampaignsPresenter @Inject constructor(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
okHttpJsonApiClient.campaigns
|
okHttpJsonApiClient.getCampaigns()
|
||||||
.observeOn(mainThreadScheduler)
|
.observeOn(mainThreadScheduler)
|
||||||
.subscribeOn(ioScheduler)
|
.subscribeOn(ioScheduler)
|
||||||
.doOnSubscribe { disposable = it }
|
.doOnSubscribe { disposable = it }
|
||||||
.subscribe({ campaignResponseDTO ->
|
.subscribe({ campaignResponseDTO ->
|
||||||
val campaigns = campaignResponseDTO.campaigns?.toMutableList()
|
val campaigns = campaignResponseDTO?.campaigns?.toMutableList()
|
||||||
if (campaigns.isNullOrEmpty()) {
|
if (campaigns.isNullOrEmpty()) {
|
||||||
Timber.e("The campaigns list is empty")
|
Timber.e("The campaigns list is empty")
|
||||||
view!!.showCampaigns(null)
|
view!!.showCampaigns(null)
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import okhttp3.logging.HttpLoggingInterceptor.Level
|
import okhttp3.logging.HttpLoggingInterceptor.Level
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Named
|
import javax.inject.Named
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
@ -170,14 +169,13 @@ class NetworkingModule {
|
||||||
@Named(NAMED_WIKI_DATA_WIKI_SITE)
|
@Named(NAMED_WIKI_DATA_WIKI_SITE)
|
||||||
fun provideWikidataWikiSite(): WikiSite = WikiSite(BuildConfig.WIKIDATA_URL)
|
fun provideWikidataWikiSite(): WikiSite = WikiSite(BuildConfig.WIKIDATA_URL)
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gson objects are very heavy. The app should ideally be using just one instance of it instead of creating new instances everywhere.
|
* Gson objects are very heavy. The app should ideally be using just one instance of it instead of creating new instances everywhere.
|
||||||
* @return returns a singleton Gson instance
|
* @return returns a singleton Gson instance
|
||||||
*/
|
*/
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideGson(): Gson = GsonUtil.getDefaultGson()
|
fun provideGson(): Gson = GsonUtil.defaultGson
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|
@ -294,9 +292,8 @@ class NetworkingModule {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE)
|
@Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE)
|
||||||
fun provideLanguageWikipediaSite(): WikiSite {
|
fun provideLanguageWikipediaSite(): WikiSite =
|
||||||
return WikiSite.forLanguageCode(Locale.getDefault().language)
|
WikiSite.forDefaultLocaleLanguageCode()
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val WIKIDATA_SPARQL_QUERY_URL = "https://query.wikidata.org/sparql"
|
private const val WIKIDATA_SPARQL_QUERY_URL = "https://query.wikidata.org/sparql"
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ import android.animation.ValueAnimator
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import android.media.ExifInterface
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import android.view.animation.AccelerateDecelerateInterpolator
|
import android.view.animation.AccelerateDecelerateInterpolator
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
|
@ -16,10 +14,12 @@ import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.graphics.rotationMatrix
|
import androidx.core.graphics.rotationMatrix
|
||||||
import androidx.core.graphics.scaleMatrix
|
import androidx.core.graphics.scaleMatrix
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import fr.free.nrw.commons.databinding.ActivityEditBinding
|
import fr.free.nrw.commons.databinding.ActivityEditBinding
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An activity class for editing and rotating images using LLJTran with EXIF attribute preservation.
|
* An activity class for editing and rotating images using LLJTran with EXIF attribute preservation.
|
||||||
|
|
@ -42,11 +42,12 @@ class EditActivity : AppCompatActivity() {
|
||||||
supportActionBar?.title = ""
|
supportActionBar?.title = ""
|
||||||
val intent = intent
|
val intent = intent
|
||||||
imageUri = intent.getStringExtra("image") ?: ""
|
imageUri = intent.getStringExtra("image") ?: ""
|
||||||
vm = ViewModelProvider(this).get(EditViewModel::class.java)
|
vm = ViewModelProvider(this)[EditViewModel::class.java]
|
||||||
val sourceExif = imageUri.toUri().path?.let { ExifInterface(it) }
|
val sourceExif = imageUri.toUri().path?.let { ExifInterface(it) }
|
||||||
|
|
||||||
val exifTags =
|
val exifTags =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
ExifInterface.TAG_APERTURE,
|
ExifInterface.TAG_F_NUMBER,
|
||||||
ExifInterface.TAG_DATETIME,
|
ExifInterface.TAG_DATETIME,
|
||||||
ExifInterface.TAG_EXPOSURE_TIME,
|
ExifInterface.TAG_EXPOSURE_TIME,
|
||||||
ExifInterface.TAG_FLASH,
|
ExifInterface.TAG_FLASH,
|
||||||
|
|
@ -62,13 +63,13 @@ class EditActivity : AppCompatActivity() {
|
||||||
ExifInterface.TAG_GPS_TIMESTAMP,
|
ExifInterface.TAG_GPS_TIMESTAMP,
|
||||||
ExifInterface.TAG_IMAGE_LENGTH,
|
ExifInterface.TAG_IMAGE_LENGTH,
|
||||||
ExifInterface.TAG_IMAGE_WIDTH,
|
ExifInterface.TAG_IMAGE_WIDTH,
|
||||||
ExifInterface.TAG_ISO,
|
ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY,
|
||||||
ExifInterface.TAG_MAKE,
|
ExifInterface.TAG_MAKE,
|
||||||
ExifInterface.TAG_MODEL,
|
ExifInterface.TAG_MODEL,
|
||||||
ExifInterface.TAG_ORIENTATION,
|
ExifInterface.TAG_ORIENTATION,
|
||||||
ExifInterface.TAG_WHITE_BALANCE,
|
ExifInterface.TAG_WHITE_BALANCE,
|
||||||
ExifInterface.WHITEBALANCE_AUTO,
|
ExifInterface.WHITE_BALANCE_AUTO,
|
||||||
ExifInterface.WHITEBALANCE_MANUAL,
|
ExifInterface.WHITE_BALANCE_MANUAL,
|
||||||
)
|
)
|
||||||
for (tag in exifTags) {
|
for (tag in exifTags) {
|
||||||
val attribute = sourceExif?.getAttribute(tag.toString())
|
val attribute = sourceExif?.getAttribute(tag.toString())
|
||||||
|
|
@ -88,8 +89,7 @@ class EditActivity : AppCompatActivity() {
|
||||||
private fun init() {
|
private fun init() {
|
||||||
binding.iv.adjustViewBounds = true
|
binding.iv.adjustViewBounds = true
|
||||||
binding.iv.scaleType = ImageView.ScaleType.MATRIX
|
binding.iv.scaleType = ImageView.ScaleType.MATRIX
|
||||||
binding.iv.post(
|
binding.iv.post {
|
||||||
Runnable {
|
|
||||||
val options = BitmapFactory.Options()
|
val options = BitmapFactory.Options()
|
||||||
options.inJustDecodeBounds = true
|
options.inJustDecodeBounds = true
|
||||||
BitmapFactory.decodeFile(imageUri, options)
|
BitmapFactory.decodeFile(imageUri, options)
|
||||||
|
|
@ -118,8 +118,7 @@ class EditActivity : AppCompatActivity() {
|
||||||
binding.iv.layoutParams.height = (scale * bitmapHeight).toInt()
|
binding.iv.layoutParams.height = (scale * bitmapHeight).toInt()
|
||||||
binding.iv.imageMatrix = scaleMatrix(scale, scale)
|
binding.iv.imageMatrix = scaleMatrix(scale, scale)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
|
||||||
binding.rotateBtn.setOnClickListener {
|
binding.rotateBtn.setOnClickListener {
|
||||||
animateImageHeight()
|
animateImageHeight()
|
||||||
}
|
}
|
||||||
|
|
@ -143,15 +142,15 @@ class EditActivity : AppCompatActivity() {
|
||||||
val drawableWidth: Float =
|
val drawableWidth: Float =
|
||||||
binding.iv
|
binding.iv
|
||||||
.getDrawable()
|
.getDrawable()
|
||||||
.getIntrinsicWidth()
|
.intrinsicWidth
|
||||||
.toFloat()
|
.toFloat()
|
||||||
val drawableHeight: Float =
|
val drawableHeight: Float =
|
||||||
binding.iv
|
binding.iv
|
||||||
.getDrawable()
|
.getDrawable()
|
||||||
.getIntrinsicHeight()
|
.intrinsicHeight
|
||||||
.toFloat()
|
.toFloat()
|
||||||
val viewWidth: Float = binding.iv.getMeasuredWidth().toFloat()
|
val viewWidth: Float = binding.iv.measuredWidth.toFloat()
|
||||||
val viewHeight: Float = binding.iv.getMeasuredHeight().toFloat()
|
val viewHeight: Float = binding.iv.measuredHeight.toFloat()
|
||||||
val rotation = imageRotation % 360
|
val rotation = imageRotation % 360
|
||||||
val newRotation = rotation + 90
|
val newRotation = rotation + 90
|
||||||
|
|
||||||
|
|
@ -162,16 +161,23 @@ class EditActivity : AppCompatActivity() {
|
||||||
Timber.d("Rotation $rotation")
|
Timber.d("Rotation $rotation")
|
||||||
Timber.d("new Rotation $newRotation")
|
Timber.d("new Rotation $newRotation")
|
||||||
|
|
||||||
if (rotation == 0 || rotation == 180) {
|
when (rotation) {
|
||||||
|
0, 180 -> {
|
||||||
imageScale = viewWidth / drawableWidth
|
imageScale = viewWidth / drawableWidth
|
||||||
newImageScale = viewWidth / drawableHeight
|
newImageScale = viewWidth / drawableHeight
|
||||||
newViewHeight = (drawableWidth * newImageScale).toInt()
|
newViewHeight = (drawableWidth * newImageScale).toInt()
|
||||||
} else if (rotation == 90 || rotation == 270) {
|
}
|
||||||
|
90, 270 -> {
|
||||||
imageScale = viewWidth / drawableHeight
|
imageScale = viewWidth / drawableHeight
|
||||||
newImageScale = viewWidth / drawableWidth
|
newImageScale = viewWidth / drawableWidth
|
||||||
newViewHeight = (drawableHeight * newImageScale).toInt()
|
newViewHeight = (drawableHeight * newImageScale).toInt()
|
||||||
} else {
|
}
|
||||||
throw UnsupportedOperationException("rotation can 0, 90, 180 or 270. \${rotation} is unsupported")
|
else -> {
|
||||||
|
throw
|
||||||
|
UnsupportedOperationException(
|
||||||
|
"rotation can 0, 90, 180 or 270. \${rotation} is unsupported"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L)
|
val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L)
|
||||||
|
|
@ -204,7 +210,7 @@ class EditActivity : AppCompatActivity() {
|
||||||
(complementaryAnimVal * viewHeight + animVal * newViewHeight).toInt()
|
(complementaryAnimVal * viewHeight + animVal * newViewHeight).toInt()
|
||||||
val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale
|
val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale
|
||||||
val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation
|
val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation
|
||||||
binding.iv.getLayoutParams().height = animatedHeight
|
binding.iv.layoutParams.height = animatedHeight
|
||||||
val matrix: Matrix =
|
val matrix: Matrix =
|
||||||
rotationMatrix(
|
rotationMatrix(
|
||||||
animatedRotation,
|
animatedRotation,
|
||||||
|
|
@ -218,8 +224,8 @@ class EditActivity : AppCompatActivity() {
|
||||||
drawableHeight / 2,
|
drawableHeight / 2,
|
||||||
)
|
)
|
||||||
matrix.postTranslate(
|
matrix.postTranslate(
|
||||||
-(drawableWidth - binding.iv.getMeasuredWidth()) / 2,
|
-(drawableWidth - binding.iv.measuredWidth) / 2,
|
||||||
-(drawableHeight - binding.iv.getMeasuredHeight()) / 2,
|
-(drawableHeight - binding.iv.measuredHeight) / 2,
|
||||||
)
|
)
|
||||||
binding.iv.setImageMatrix(matrix)
|
binding.iv.setImageMatrix(matrix)
|
||||||
binding.iv.requestLayout()
|
binding.iv.requestLayout()
|
||||||
|
|
@ -267,9 +273,9 @@ class EditActivity : AppCompatActivity() {
|
||||||
*/
|
*/
|
||||||
private fun copyExifData(editedImageExif: ExifInterface?) {
|
private fun copyExifData(editedImageExif: ExifInterface?) {
|
||||||
for (attr in sourceExifAttributeList) {
|
for (attr in sourceExifAttributeList) {
|
||||||
Log.d("Tag is ${attr.first}", "Value is ${attr.second}")
|
Timber.d("Value is ${attr.second}")
|
||||||
editedImageExif!!.setAttribute(attr.first, attr.second)
|
editedImageExif!!.setAttribute(attr.first, attr.second)
|
||||||
Log.d("Tag is ${attr.first}", "Value is ${attr.second}")
|
Timber.d("Value is ${attr.second}")
|
||||||
}
|
}
|
||||||
|
|
||||||
editedImageExif?.saveAttributes()
|
editedImageExif?.saveAttributes()
|
||||||
|
|
@ -298,9 +304,10 @@ class EditActivity : AppCompatActivity() {
|
||||||
var scaleFactor = 1
|
var scaleFactor = 1
|
||||||
|
|
||||||
if (originalWidth > maxSize || originalHeight > maxSize) {
|
if (originalWidth > maxSize || originalHeight > maxSize) {
|
||||||
// Calculate the largest power of 2 that is less than or equal to the desired width and height
|
// Calculate the largest power of 2 that is less than or equal to the desired
|
||||||
val widthRatio = Math.ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt()
|
// width and height
|
||||||
val heightRatio = Math.ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt()
|
val widthRatio = ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt()
|
||||||
|
val heightRatio = ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt()
|
||||||
|
|
||||||
scaleFactor = if (widthRatio > heightRatio) widthRatio else heightRatio
|
scaleFactor = if (widthRatio > heightRatio) widthRatio else heightRatio
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.ExtMetadata
|
||||||
import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo
|
import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo
|
||||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage
|
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage
|
||||||
import org.apache.commons.lang3.StringUtils
|
|
||||||
import java.text.ParseException
|
import java.text.ParseException
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -24,7 +23,7 @@ class MediaConverter
|
||||||
entity: Entities.Entity,
|
entity: Entities.Entity,
|
||||||
imageInfo: ImageInfo,
|
imageInfo: ImageInfo,
|
||||||
): Media {
|
): Media {
|
||||||
val metadata = imageInfo.metadata
|
val metadata = imageInfo.getMetadata()
|
||||||
requireNotNull(metadata) { "No metadata" }
|
requireNotNull(metadata) { "No metadata" }
|
||||||
// Stores mapping of title attribute to hidden attribute of each category
|
// Stores mapping of title attribute to hidden attribute of each category
|
||||||
val myMap = mutableMapOf<String, Boolean>()
|
val myMap = mutableMapOf<String, Boolean>()
|
||||||
|
|
@ -32,8 +31,8 @@ class MediaConverter
|
||||||
|
|
||||||
return Media(
|
return Media(
|
||||||
page.pageId().toString(),
|
page.pageId().toString(),
|
||||||
imageInfo.thumbUrl.takeIf { it.isNotBlank() } ?: imageInfo.originalUrl,
|
imageInfo.getThumbUrl().takeIf { it.isNotBlank() } ?: imageInfo.getOriginalUrl(),
|
||||||
imageInfo.originalUrl,
|
imageInfo.getOriginalUrl(),
|
||||||
page.title(),
|
page.title(),
|
||||||
metadata.imageDescription(),
|
metadata.imageDescription(),
|
||||||
safeParseDate(metadata.dateTime()),
|
safeParseDate(metadata.dateTime()),
|
||||||
|
|
@ -41,7 +40,7 @@ class MediaConverter
|
||||||
metadata.prefixedLicenseUrl,
|
metadata.prefixedLicenseUrl,
|
||||||
getAuthor(metadata),
|
getAuthor(metadata),
|
||||||
getAuthor(metadata),
|
getAuthor(metadata),
|
||||||
MediaDataExtractorUtil.extractCategoriesFromList(metadata.categories),
|
MediaDataExtractorUtil.extractCategoriesFromList(metadata.categories()),
|
||||||
metadata.latLng,
|
metadata.latLng,
|
||||||
entity.labels().mapValues { it.value.value() },
|
entity.labels().mapValues { it.value.value() },
|
||||||
entity.descriptions().mapValues { it.value.value() },
|
entity.descriptions().mapValues { it.value.value() },
|
||||||
|
|
@ -104,9 +103,5 @@ private val ExtMetadata.prefixedLicenseUrl: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private val ExtMetadata.latLng: LatLng?
|
private val ExtMetadata.latLng: LatLng?
|
||||||
get() =
|
get() = LatLng.latLongOrNull(gpsLatitude(), gpsLongitude())
|
||||||
if (!StringUtils.isBlank(gpsLatitude) && !StringUtils.isBlank(gpsLongitude)) {
|
|
||||||
LatLng(gpsLatitude.toDouble(), gpsLongitude.toDouble(), 0.0f)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package fr.free.nrw.commons.feedback
|
||||||
|
|
||||||
|
import fr.free.nrw.commons.feedback.model.Feedback
|
||||||
|
|
||||||
|
interface OnFeedbackSubmitCallback {
|
||||||
|
fun onFeedbackSubmit(feedback: Feedback)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
package fr.free.nrw.commons.filepicker;
|
|
||||||
|
|
||||||
public interface Constants {
|
|
||||||
String DEFAULT_FOLDER_NAME = "CommonsContributions";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides the request codes for permission handling
|
|
||||||
*/
|
|
||||||
interface RequestCodes {
|
|
||||||
int LOCATION = 1;
|
|
||||||
int STORAGE = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides locations as string for corresponding operations
|
|
||||||
*/
|
|
||||||
interface BundleKeys {
|
|
||||||
String FOLDER_NAME = "fr.free.nrw.commons.folder_name";
|
|
||||||
String ALLOW_MULTIPLE = "fr.free.nrw.commons.allow_multiple";
|
|
||||||
String COPY_TAKEN_PHOTOS = "fr.free.nrw.commons.copy_taken_photos";
|
|
||||||
String COPY_PICKED_IMAGES = "fr.free.nrw.commons.copy_picked_images";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
29
app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt
Normal file
29
app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package fr.free.nrw.commons.filepicker
|
||||||
|
|
||||||
|
interface Constants {
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_FOLDER_NAME = "CommonsContributions"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the request codes for permission handling
|
||||||
|
*/
|
||||||
|
interface RequestCodes {
|
||||||
|
companion object {
|
||||||
|
const val LOCATION = 1
|
||||||
|
const val STORAGE = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides locations as string for corresponding operations
|
||||||
|
*/
|
||||||
|
interface BundleKeys {
|
||||||
|
companion object {
|
||||||
|
const val FOLDER_NAME = "fr.free.nrw.commons.folder_name"
|
||||||
|
const val ALLOW_MULTIPLE = "fr.free.nrw.commons.allow_multiple"
|
||||||
|
const val COPY_TAKEN_PHOTOS = "fr.free.nrw.commons.copy_taken_photos"
|
||||||
|
const val COPY_PICKED_IMAGES = "fr.free.nrw.commons.copy_picked_images"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
package fr.free.nrw.commons.filepicker;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides abstract methods which are overridden while handling Contribution Results
|
|
||||||
* inside the ContributionsController
|
|
||||||
*/
|
|
||||||
public abstract class DefaultCallback implements FilePicker.Callbacks {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onImagePickerError(Exception e, FilePicker.ImageSource source, int type) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCanceled(FilePicker.ImageSource source, int type) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package fr.free.nrw.commons.filepicker
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides abstract methods which are overridden while handling Contribution Results
|
||||||
|
* inside the ContributionsController
|
||||||
|
*/
|
||||||
|
abstract class DefaultCallback: FilePicker.Callbacks {
|
||||||
|
|
||||||
|
override fun onImagePickerError(e: Exception, source: FilePicker.ImageSource, type: Int) {}
|
||||||
|
|
||||||
|
override fun onCanceled(source: FilePicker.ImageSource, type: Int) {}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package fr.free.nrw.commons.filepicker;
|
|
||||||
|
|
||||||
import androidx.core.content.FileProvider;
|
|
||||||
|
|
||||||
public class ExtendedFileProvider extends FileProvider {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package fr.free.nrw.commons.filepicker
|
||||||
|
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
|
||||||
|
class ExtendedFileProvider: FileProvider() {}
|
||||||
|
|
@ -1,355 +0,0 @@
|
||||||
package fr.free.nrw.commons.filepicker;
|
|
||||||
|
|
||||||
import static fr.free.nrw.commons.filepicker.PickedFiles.singleFileList;
|
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.content.ClipData;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.content.pm.PackageManager;
|
|
||||||
import android.content.pm.ResolveInfo;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.provider.MediaStore;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import androidx.activity.result.ActivityResult;
|
|
||||||
import androidx.activity.result.ActivityResultLauncher;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.preference.PreferenceManager;
|
|
||||||
import fr.free.nrw.commons.customselector.model.Image;
|
|
||||||
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity;
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URISyntaxException;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public class FilePicker implements Constants {
|
|
||||||
|
|
||||||
private static final String KEY_PHOTO_URI = "photo_uri";
|
|
||||||
private static final String KEY_VIDEO_URI = "video_uri";
|
|
||||||
private static final String KEY_LAST_CAMERA_PHOTO = "last_photo";
|
|
||||||
private static final String KEY_LAST_CAMERA_VIDEO = "last_video";
|
|
||||||
private static final String KEY_TYPE = "type";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the uri of the clicked image so that it can be put in MediaStore
|
|
||||||
*/
|
|
||||||
private static Uri createCameraPictureFile(@NonNull Context context) throws IOException {
|
|
||||||
File imagePath = PickedFiles.getCameraPicturesLocation(context);
|
|
||||||
Uri uri = PickedFiles.getUriToFile(context, imagePath);
|
|
||||||
SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(context).edit();
|
|
||||||
editor.putString(KEY_PHOTO_URI, uri.toString());
|
|
||||||
editor.putString(KEY_LAST_CAMERA_PHOTO, imagePath.toString());
|
|
||||||
editor.apply();
|
|
||||||
return uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Intent createGalleryIntent(@NonNull Context context, int type,
|
|
||||||
boolean openDocumentIntentPreferred) {
|
|
||||||
// storing picked image type to shared preferences
|
|
||||||
storeType(context, type);
|
|
||||||
//Supported types are SVG, PNG and JPEG,GIF, TIFF, WebP, XCF
|
|
||||||
final String[] mimeTypes = { "image/jpg","image/png","image/jpeg", "image/gif", "image/tiff", "image/webp", "image/xcf", "image/svg+xml", "image/webp"};
|
|
||||||
return plainGalleryPickerIntent(openDocumentIntentPreferred)
|
|
||||||
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, configuration(context).allowsMultiplePickingInGallery())
|
|
||||||
.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CreateCustomSectorIntent, creates intent for custom selector activity.
|
|
||||||
* @param context
|
|
||||||
* @param type
|
|
||||||
* @return Custom selector intent
|
|
||||||
*/
|
|
||||||
private static Intent createCustomSelectorIntent(@NonNull Context context, int type) {
|
|
||||||
storeType(context, type);
|
|
||||||
return new Intent(context, CustomSelectorActivity.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Intent createCameraForImageIntent(@NonNull Context context, int type) {
|
|
||||||
storeType(context, type);
|
|
||||||
|
|
||||||
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
|
|
||||||
try {
|
|
||||||
Uri capturedImageUri = createCameraPictureFile(context);
|
|
||||||
//We have to explicitly grant the write permission since Intent.setFlag works only on API Level >=20
|
|
||||||
grantWritePermission(context, intent, capturedImageUri);
|
|
||||||
intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri);
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
|
|
||||||
return intent;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void revokeWritePermission(@NonNull Context context, Uri uri) {
|
|
||||||
context.revokeUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void grantWritePermission(@NonNull Context context, Intent intent, Uri uri) {
|
|
||||||
List<ResolveInfo> resInfoList = context.getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
|
|
||||||
for (ResolveInfo resolveInfo : resInfoList) {
|
|
||||||
String packageName = resolveInfo.activityInfo.packageName;
|
|
||||||
context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void storeType(@NonNull Context context, int type) {
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(KEY_TYPE, type).apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int restoreType(@NonNull Context context) {
|
|
||||||
return PreferenceManager.getDefaultSharedPreferences(context).getInt(KEY_TYPE, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens default galery or a available galleries picker if there is no default
|
|
||||||
*
|
|
||||||
* @param type Custom type of your choice, which will be returned with the images
|
|
||||||
*/
|
|
||||||
public static void openGallery(Activity activity, ActivityResultLauncher<Intent> resultLauncher, int type, boolean openDocumentIntentPreferred) {
|
|
||||||
Intent intent = createGalleryIntent(activity, type, openDocumentIntentPreferred);
|
|
||||||
resultLauncher.launch(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens Custom Selector
|
|
||||||
*/
|
|
||||||
public static void openCustomSelector(Activity activity, ActivityResultLauncher<Intent> resultLauncher, int type) {
|
|
||||||
Intent intent = createCustomSelectorIntent(activity, type);
|
|
||||||
resultLauncher.launch(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the camera app to pick image clicked by user
|
|
||||||
*/
|
|
||||||
public static void openCameraForImage(Activity activity, ActivityResultLauncher<Intent> resultLauncher, int type) {
|
|
||||||
Intent intent = createCameraForImageIntent(activity, type);
|
|
||||||
resultLauncher.launch(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
private static UploadableFile takenCameraPicture(Context context) throws URISyntaxException {
|
|
||||||
String lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_LAST_CAMERA_PHOTO, null);
|
|
||||||
if (lastCameraPhoto != null) {
|
|
||||||
return new UploadableFile(new File(lastCameraPhoto));
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
private static UploadableFile takenCameraVideo(Context context) throws URISyntaxException {
|
|
||||||
String lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_LAST_CAMERA_VIDEO, null);
|
|
||||||
if (lastCameraPhoto != null) {
|
|
||||||
return new UploadableFile(new File(lastCameraPhoto));
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<UploadableFile> handleExternalImagesPicked(Intent data, Activity activity) {
|
|
||||||
try {
|
|
||||||
return getFilesFromGalleryPictures(data, activity);
|
|
||||||
} catch (IOException | SecurityException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isPhoto(Intent data) {
|
|
||||||
return data == null || (data.getData() == null && data.getClipData() == null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Intent plainGalleryPickerIntent(boolean openDocumentIntentPreferred) {
|
|
||||||
/*
|
|
||||||
* Asking for ACCESS_MEDIA_LOCATION at runtime solved the location-loss issue
|
|
||||||
* in the custom selector in Contributions fragment.
|
|
||||||
* Detailed discussion: https://github.com/commons-app/apps-android-commons/issues/5015
|
|
||||||
*
|
|
||||||
* This permission check, however, was insufficient to fix location-loss in
|
|
||||||
* the regular selector in Contributions fragment and Nearby fragment,
|
|
||||||
* especially on some devices running Android 13 that use the new Photo Picker by default.
|
|
||||||
*
|
|
||||||
* New Photo Picker: https://developer.android.com/training/data-storage/shared/photopicker
|
|
||||||
*
|
|
||||||
* The new Photo Picker introduced by Android redacts location tags from EXIF metadata.
|
|
||||||
* Reported on the Google Issue Tracker: https://issuetracker.google.com/issues/243294058
|
|
||||||
* Status: Won't fix (Intended behaviour)
|
|
||||||
*
|
|
||||||
* Switched intent from ACTION_GET_CONTENT to ACTION_OPEN_DOCUMENT (by default; can
|
|
||||||
* be changed through the Setting page) as:
|
|
||||||
*
|
|
||||||
* ACTION_GET_CONTENT opens the 'best application' for choosing that kind of data
|
|
||||||
* The best application is the new Photo Picker that redacts the location tags
|
|
||||||
*
|
|
||||||
* ACTION_OPEN_DOCUMENT, however, displays the various DocumentsProvider instances
|
|
||||||
* installed on the device, letting the user interactively navigate through them.
|
|
||||||
*
|
|
||||||
* So, this allows us to use the traditional file picker that does not redact location tags
|
|
||||||
* from EXIF.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
Intent intent;
|
|
||||||
if (openDocumentIntentPreferred) {
|
|
||||||
intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
|
||||||
} else {
|
|
||||||
intent = new Intent(Intent.ACTION_GET_CONTENT);
|
|
||||||
}
|
|
||||||
intent.setType("image/*");
|
|
||||||
return intent;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void onPictureReturnedFromDocuments(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
|
|
||||||
if(result.getResultCode() == Activity.RESULT_OK && !isPhoto(result.getData())){
|
|
||||||
try {
|
|
||||||
Uri photoPath = result.getData().getData();
|
|
||||||
UploadableFile photoFile = PickedFiles.pickedExistingPicture(activity, photoPath);
|
|
||||||
callbacks.onImagesPicked(singleFileList(photoFile), FilePicker.ImageSource.DOCUMENTS, restoreType(activity));
|
|
||||||
|
|
||||||
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
|
|
||||||
PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile));
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
callbacks.onImagePickerError(e, FilePicker.ImageSource.DOCUMENTS, restoreType(activity));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
callbacks.onCanceled(FilePicker.ImageSource.DOCUMENTS, restoreType(activity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* onPictureReturnedFromCustomSelector.
|
|
||||||
* Retrieve and forward the images to upload wizard through callback.
|
|
||||||
*/
|
|
||||||
public static void onPictureReturnedFromCustomSelector(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
|
|
||||||
if(result.getResultCode() == Activity.RESULT_OK){
|
|
||||||
try {
|
|
||||||
List<UploadableFile> files = getFilesFromCustomSelector(result.getData(), activity);
|
|
||||||
callbacks.onImagesPicked(files, ImageSource.CUSTOM_SELECTOR, restoreType(activity));
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
callbacks.onImagePickerError(e, ImageSource.CUSTOM_SELECTOR, restoreType(activity));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
callbacks.onCanceled(ImageSource.CUSTOM_SELECTOR, restoreType(activity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get files from custom selector
|
|
||||||
* Retrieve and process the selected images from the custom selector.
|
|
||||||
*/
|
|
||||||
private static List<UploadableFile> getFilesFromCustomSelector(Intent data, Activity activity) throws IOException, SecurityException {
|
|
||||||
List<UploadableFile> files = new ArrayList<>();
|
|
||||||
ArrayList<Image> images = data.getParcelableArrayListExtra("Images");
|
|
||||||
for(Image image : images) {
|
|
||||||
Uri uri = image.getUri();
|
|
||||||
UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri);
|
|
||||||
files.add(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
|
|
||||||
PickedFiles.copyFilesInSeparateThread(activity, files);
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void onPictureReturnedFromGallery(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
|
|
||||||
if(result.getResultCode() == Activity.RESULT_OK && !isPhoto(result.getData())){
|
|
||||||
try {
|
|
||||||
List<UploadableFile> files = getFilesFromGalleryPictures(result.getData(), activity);
|
|
||||||
callbacks.onImagesPicked(files, FilePicker.ImageSource.GALLERY, restoreType(activity));
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
callbacks.onImagePickerError(e, FilePicker.ImageSource.GALLERY, restoreType(activity));
|
|
||||||
}
|
|
||||||
} else{
|
|
||||||
callbacks.onCanceled(FilePicker.ImageSource.GALLERY, restoreType(activity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<UploadableFile> getFilesFromGalleryPictures(Intent data, Activity activity) throws IOException, SecurityException {
|
|
||||||
List<UploadableFile> files = new ArrayList<>();
|
|
||||||
ClipData clipData = data.getClipData();
|
|
||||||
if (clipData == null) {
|
|
||||||
Uri uri = data.getData();
|
|
||||||
UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri);
|
|
||||||
files.add(file);
|
|
||||||
} else {
|
|
||||||
for (int i = 0; i < clipData.getItemCount(); i++) {
|
|
||||||
Uri uri = clipData.getItemAt(i).getUri();
|
|
||||||
UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri);
|
|
||||||
files.add(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
|
|
||||||
PickedFiles.copyFilesInSeparateThread(activity, files);
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void onPictureReturnedFromCamera(ActivityResult activityResult, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
|
|
||||||
if(activityResult.getResultCode() == Activity.RESULT_OK){
|
|
||||||
try {
|
|
||||||
String lastImageUri = PreferenceManager.getDefaultSharedPreferences(activity).getString(KEY_PHOTO_URI, null);
|
|
||||||
if (!TextUtils.isEmpty(lastImageUri)) {
|
|
||||||
revokeWritePermission(activity, Uri.parse(lastImageUri));
|
|
||||||
}
|
|
||||||
|
|
||||||
UploadableFile photoFile = FilePicker.takenCameraPicture(activity);
|
|
||||||
List<UploadableFile> files = new ArrayList<>();
|
|
||||||
files.add(photoFile);
|
|
||||||
|
|
||||||
if (photoFile == null) {
|
|
||||||
Exception e = new IllegalStateException("Unable to get the picture returned from camera");
|
|
||||||
callbacks.onImagePickerError(e, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity));
|
|
||||||
} else {
|
|
||||||
if (configuration(activity).shouldCopyTakenPhotosToPublicGalleryAppFolder()) {
|
|
||||||
PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile));
|
|
||||||
}
|
|
||||||
|
|
||||||
callbacks.onImagesPicked(files, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity));
|
|
||||||
}
|
|
||||||
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
|
||||||
.edit()
|
|
||||||
.remove(KEY_LAST_CAMERA_PHOTO)
|
|
||||||
.remove(KEY_PHOTO_URI)
|
|
||||||
.apply();
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
callbacks.onImagePickerError(e, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
callbacks.onCanceled(FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static FilePickerConfiguration configuration(@NonNull Context context) {
|
|
||||||
return new FilePickerConfiguration(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public enum ImageSource {
|
|
||||||
GALLERY, DOCUMENTS, CAMERA_IMAGE, CAMERA_VIDEO, CUSTOM_SELECTOR
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface Callbacks {
|
|
||||||
void onImagePickerError(Exception e, FilePicker.ImageSource source, int type);
|
|
||||||
|
|
||||||
void onImagesPicked(@NonNull List<UploadableFile> imageFiles, FilePicker.ImageSource source, int type);
|
|
||||||
|
|
||||||
void onCanceled(FilePicker.ImageSource source, int type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface HandleActivityResult{
|
|
||||||
void onHandleActivityResult(FilePicker.Callbacks callbacks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
441
app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt
Normal file
441
app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
package fr.free.nrw.commons.filepicker
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.activity.result.ActivityResult
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import fr.free.nrw.commons.customselector.model.Image
|
||||||
|
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity
|
||||||
|
import fr.free.nrw.commons.filepicker.PickedFiles.singleFileList
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.URISyntaxException
|
||||||
|
|
||||||
|
|
||||||
|
object FilePicker : Constants {
|
||||||
|
|
||||||
|
private const val KEY_PHOTO_URI = "photo_uri"
|
||||||
|
private const val KEY_VIDEO_URI = "video_uri"
|
||||||
|
private const val KEY_LAST_CAMERA_PHOTO = "last_photo"
|
||||||
|
private const val KEY_LAST_CAMERA_VIDEO = "last_video"
|
||||||
|
private const val KEY_TYPE = "type"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the uri of the clicked image so that it can be put in MediaStore
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
@JvmStatic
|
||||||
|
private fun createCameraPictureFile(context: Context): Uri {
|
||||||
|
val imagePath = PickedFiles.getCameraPicturesLocation(context)
|
||||||
|
val uri = PickedFiles.getUriToFile(context, imagePath)
|
||||||
|
val editor = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||||
|
editor.putString(KEY_PHOTO_URI, uri.toString())
|
||||||
|
editor.putString(KEY_LAST_CAMERA_PHOTO, imagePath.toString())
|
||||||
|
editor.apply()
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun createGalleryIntent(
|
||||||
|
context: Context,
|
||||||
|
type: Int,
|
||||||
|
openDocumentIntentPreferred: Boolean
|
||||||
|
): Intent {
|
||||||
|
// storing picked image type to shared preferences
|
||||||
|
storeType(context, type)
|
||||||
|
// Supported types are SVG, PNG and JPEG, GIF, TIFF, WebP, XCF
|
||||||
|
val mimeTypes = arrayOf(
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/gif",
|
||||||
|
"image/tiff",
|
||||||
|
"image/webp",
|
||||||
|
"image/xcf",
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/webp"
|
||||||
|
)
|
||||||
|
return plainGalleryPickerIntent(openDocumentIntentPreferred)
|
||||||
|
.putExtra(
|
||||||
|
Intent.EXTRA_ALLOW_MULTIPLE,
|
||||||
|
configuration(context).allowsMultiplePickingInGallery()
|
||||||
|
)
|
||||||
|
.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CreateCustomSectorIntent, creates intent for custom selector activity.
|
||||||
|
* @param context
|
||||||
|
* @param type
|
||||||
|
* @return Custom selector intent
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
private fun createCustomSelectorIntent(context: Context, type: Int): Intent {
|
||||||
|
storeType(context, type)
|
||||||
|
return Intent(context, CustomSelectorActivity::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun createCameraForImageIntent(context: Context, type: Int): Intent {
|
||||||
|
storeType(context, type)
|
||||||
|
|
||||||
|
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||||
|
try {
|
||||||
|
val capturedImageUri = createCameraPictureFile(context)
|
||||||
|
// We have to explicitly grant the write permission since Intent.setFlag works only on API Level >=20
|
||||||
|
grantWritePermission(context, intent, capturedImageUri)
|
||||||
|
intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun revokeWritePermission(context: Context, uri: Uri) {
|
||||||
|
context.revokeUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun grantWritePermission(context: Context, intent: Intent, uri: Uri) {
|
||||||
|
val resInfoList =
|
||||||
|
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||||
|
for (resolveInfo in resInfoList) {
|
||||||
|
val packageName = resolveInfo.activityInfo.packageName
|
||||||
|
context.grantUriPermission(
|
||||||
|
packageName,
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun storeType(context: Context, type: Int) {
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(KEY_TYPE, type).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun restoreType(context: Context): Int {
|
||||||
|
return PreferenceManager.getDefaultSharedPreferences(context).getInt(KEY_TYPE, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens default gallery or available galleries picker if there is no default
|
||||||
|
*
|
||||||
|
* @param type Custom type of your choice, which will be returned with the images
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun openGallery(
|
||||||
|
activity: Activity,
|
||||||
|
resultLauncher: ActivityResultLauncher<Intent>,
|
||||||
|
type: Int,
|
||||||
|
openDocumentIntentPreferred: Boolean
|
||||||
|
) {
|
||||||
|
val intent = createGalleryIntent(activity, type, openDocumentIntentPreferred)
|
||||||
|
resultLauncher.launch(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens Custom Selector
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun openCustomSelector(
|
||||||
|
activity: Activity,
|
||||||
|
resultLauncher: ActivityResultLauncher<Intent>,
|
||||||
|
type: Int
|
||||||
|
) {
|
||||||
|
val intent = createCustomSelectorIntent(activity, type)
|
||||||
|
resultLauncher.launch(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the camera app to pick image clicked by user
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun openCameraForImage(
|
||||||
|
activity: Activity,
|
||||||
|
resultLauncher: ActivityResultLauncher<Intent>,
|
||||||
|
type: Int
|
||||||
|
) {
|
||||||
|
val intent = createCameraForImageIntent(activity, type)
|
||||||
|
resultLauncher.launch(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(URISyntaxException::class)
|
||||||
|
@JvmStatic
|
||||||
|
private fun takenCameraPicture(context: Context): UploadableFile? {
|
||||||
|
val lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
.getString(KEY_LAST_CAMERA_PHOTO, null)
|
||||||
|
return if (lastCameraPhoto != null) {
|
||||||
|
UploadableFile(File(lastCameraPhoto))
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(URISyntaxException::class)
|
||||||
|
@JvmStatic
|
||||||
|
private fun takenCameraVideo(context: Context): UploadableFile? {
|
||||||
|
val lastCameraVideo = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
.getString(KEY_LAST_CAMERA_VIDEO, null)
|
||||||
|
return if (lastCameraVideo != null) {
|
||||||
|
UploadableFile(File(lastCameraVideo))
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun handleExternalImagesPicked(data: Intent?, activity: Activity): List<UploadableFile> {
|
||||||
|
return try {
|
||||||
|
getFilesFromGalleryPictures(data, activity)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
emptyList()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun isPhoto(data: Intent?): Boolean {
|
||||||
|
return data == null || (data.data == null && data.clipData == null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun plainGalleryPickerIntent(
|
||||||
|
openDocumentIntentPreferred: Boolean
|
||||||
|
): Intent {
|
||||||
|
/*
|
||||||
|
* Asking for ACCESS_MEDIA_LOCATION at runtime solved the location-loss issue
|
||||||
|
* in the custom selector in Contributions fragment.
|
||||||
|
* Detailed discussion: https://github.com/commons-app/apps-android-commons/issues/5015
|
||||||
|
*
|
||||||
|
* This permission check, however, was insufficient to fix location-loss in
|
||||||
|
* the regular selector in Contributions fragment and Nearby fragment,
|
||||||
|
* especially on some devices running Android 13 that use the new Photo Picker by default.
|
||||||
|
*
|
||||||
|
* New Photo Picker: https://developer.android.com/training/data-storage/shared/photopicker
|
||||||
|
*
|
||||||
|
* The new Photo Picker introduced by Android redacts location tags from EXIF metadata.
|
||||||
|
* Reported on the Google Issue Tracker: https://issuetracker.google.com/issues/243294058
|
||||||
|
* Status: Won't fix (Intended behaviour)
|
||||||
|
*
|
||||||
|
* Switched intent from ACTION_GET_CONTENT to ACTION_OPEN_DOCUMENT (by default; can
|
||||||
|
* be changed through the Setting page) as:
|
||||||
|
*
|
||||||
|
* ACTION_GET_CONTENT opens the 'best application' for choosing that kind of data
|
||||||
|
* The best application is the new Photo Picker that redacts the location tags
|
||||||
|
*
|
||||||
|
* ACTION_OPEN_DOCUMENT, however, displays the various DocumentsProvider instances
|
||||||
|
* installed on the device, letting the user interactively navigate through them.
|
||||||
|
*
|
||||||
|
* So, this allows us to use the traditional file picker that does not redact location tags
|
||||||
|
* from EXIF.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
val intent = if (openDocumentIntentPreferred) {
|
||||||
|
Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||||
|
} else {
|
||||||
|
Intent(Intent.ACTION_GET_CONTENT)
|
||||||
|
}
|
||||||
|
intent.type = "image/*"
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun onPictureReturnedFromDocuments(
|
||||||
|
result: ActivityResult,
|
||||||
|
activity: Activity,
|
||||||
|
callbacks: Callbacks
|
||||||
|
) {
|
||||||
|
if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) {
|
||||||
|
try {
|
||||||
|
val photoPath = result.data?.data
|
||||||
|
val photoFile = PickedFiles.pickedExistingPicture(activity, photoPath!!)
|
||||||
|
callbacks.onImagesPicked(
|
||||||
|
singleFileList(photoFile),
|
||||||
|
ImageSource.DOCUMENTS,
|
||||||
|
restoreType(activity)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
|
||||||
|
PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
callbacks.onImagePickerError(e, ImageSource.DOCUMENTS, restoreType(activity))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callbacks.onCanceled(ImageSource.DOCUMENTS, restoreType(activity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onPictureReturnedFromCustomSelector.
|
||||||
|
* Retrieve and forward the images to upload wizard through callback.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun onPictureReturnedFromCustomSelector(
|
||||||
|
result: ActivityResult,
|
||||||
|
activity: Activity,
|
||||||
|
callbacks: Callbacks
|
||||||
|
) {
|
||||||
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
|
try {
|
||||||
|
val files = getFilesFromCustomSelector(result.data, activity)
|
||||||
|
callbacks.onImagesPicked(files, ImageSource.CUSTOM_SELECTOR, restoreType(activity))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
callbacks.onImagePickerError(e, ImageSource.CUSTOM_SELECTOR, restoreType(activity))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callbacks.onCanceled(ImageSource.CUSTOM_SELECTOR, restoreType(activity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get files from custom selector
|
||||||
|
* Retrieve and process the selected images from the custom selector.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class, SecurityException::class)
|
||||||
|
@JvmStatic
|
||||||
|
private fun getFilesFromCustomSelector(
|
||||||
|
data: Intent?,
|
||||||
|
activity: Activity
|
||||||
|
): List<UploadableFile> {
|
||||||
|
val files = mutableListOf<UploadableFile>()
|
||||||
|
val images = data?.getParcelableArrayListExtra<Image>("Images")
|
||||||
|
images?.forEach { image ->
|
||||||
|
val uri = image.uri
|
||||||
|
val file = PickedFiles.pickedExistingPicture(activity, uri)
|
||||||
|
files.add(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
|
||||||
|
PickedFiles.copyFilesInSeparateThread(activity, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun onPictureReturnedFromGallery(
|
||||||
|
result: ActivityResult,
|
||||||
|
activity: Activity,
|
||||||
|
callbacks: Callbacks
|
||||||
|
) {
|
||||||
|
if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) {
|
||||||
|
try {
|
||||||
|
val files = getFilesFromGalleryPictures(result.data, activity)
|
||||||
|
callbacks.onImagesPicked(files, ImageSource.GALLERY, restoreType(activity))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
callbacks.onImagePickerError(e, ImageSource.GALLERY, restoreType(activity))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callbacks.onCanceled(ImageSource.GALLERY, restoreType(activity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class, SecurityException::class)
|
||||||
|
@JvmStatic
|
||||||
|
private fun getFilesFromGalleryPictures(
|
||||||
|
data: Intent?,
|
||||||
|
activity: Activity
|
||||||
|
): List<UploadableFile> {
|
||||||
|
val files = mutableListOf<UploadableFile>()
|
||||||
|
val clipData = data?.clipData
|
||||||
|
if (clipData == null) {
|
||||||
|
val uri = data?.data
|
||||||
|
val file = PickedFiles.pickedExistingPicture(activity, uri!!)
|
||||||
|
files.add(file)
|
||||||
|
} else {
|
||||||
|
for (i in 0 until clipData.itemCount) {
|
||||||
|
val uri = clipData.getItemAt(i).uri
|
||||||
|
val file = PickedFiles.pickedExistingPicture(activity, uri)
|
||||||
|
files.add(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
|
||||||
|
PickedFiles.copyFilesInSeparateThread(activity, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun onPictureReturnedFromCamera(
|
||||||
|
activityResult: ActivityResult,
|
||||||
|
activity: Activity,
|
||||||
|
callbacks: Callbacks
|
||||||
|
) {
|
||||||
|
if (activityResult.resultCode == Activity.RESULT_OK) {
|
||||||
|
try {
|
||||||
|
val lastImageUri = PreferenceManager.getDefaultSharedPreferences(activity)
|
||||||
|
.getString(KEY_PHOTO_URI, null)
|
||||||
|
if (!lastImageUri.isNullOrEmpty()) {
|
||||||
|
revokeWritePermission(activity, Uri.parse(lastImageUri))
|
||||||
|
}
|
||||||
|
|
||||||
|
val photoFile = takenCameraPicture(activity)
|
||||||
|
val files = mutableListOf<UploadableFile>()
|
||||||
|
photoFile?.let { files.add(it) }
|
||||||
|
|
||||||
|
if (photoFile == null) {
|
||||||
|
val e = IllegalStateException("Unable to get the picture returned from camera")
|
||||||
|
callbacks.onImagePickerError(e, ImageSource.CAMERA_IMAGE, restoreType(activity))
|
||||||
|
} else {
|
||||||
|
if (configuration(activity).shouldCopyTakenPhotosToPublicGalleryAppFolder()) {
|
||||||
|
PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile))
|
||||||
|
}
|
||||||
|
callbacks.onImagesPicked(files, ImageSource.CAMERA_IMAGE, restoreType(activity))
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(activity).edit()
|
||||||
|
.remove(KEY_LAST_CAMERA_PHOTO)
|
||||||
|
.remove(KEY_PHOTO_URI)
|
||||||
|
.apply()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
callbacks.onImagePickerError(e, ImageSource.CAMERA_IMAGE, restoreType(activity))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callbacks.onCanceled(ImageSource.CAMERA_IMAGE, restoreType(activity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun configuration(context: Context): FilePickerConfiguration {
|
||||||
|
return FilePickerConfiguration(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ImageSource {
|
||||||
|
GALLERY, DOCUMENTS, CAMERA_IMAGE, CAMERA_VIDEO, CUSTOM_SELECTOR
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Callbacks {
|
||||||
|
fun onImagePickerError(e: Exception, source: ImageSource, type: Int)
|
||||||
|
|
||||||
|
fun onImagesPicked(imageFiles: List<UploadableFile>, source: ImageSource, type: Int)
|
||||||
|
|
||||||
|
fun onCanceled(source: ImageSource, type: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HandleActivityResult {
|
||||||
|
fun onHandleActivityResult(callbacks: Callbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
package fr.free.nrw.commons.filepicker;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import androidx.preference.PreferenceManager;
|
|
||||||
|
|
||||||
public class FilePickerConfiguration implements Constants {
|
|
||||||
|
|
||||||
private Context context;
|
|
||||||
|
|
||||||
FilePickerConfiguration(Context context) {
|
|
||||||
this.context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public FilePickerConfiguration setAllowMultiplePickInGallery(boolean allowMultiple) {
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
|
||||||
.putBoolean(BundleKeys.ALLOW_MULTIPLE, allowMultiple)
|
|
||||||
.apply();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public FilePickerConfiguration setCopyTakenPhotosToPublicGalleryAppFolder(boolean copy) {
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
|
||||||
.putBoolean(BundleKeys.COPY_TAKEN_PHOTOS, copy)
|
|
||||||
.apply();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getFolderName() {
|
|
||||||
return PreferenceManager.getDefaultSharedPreferences(context).getString(BundleKeys.FOLDER_NAME, DEFAULT_FOLDER_NAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean allowsMultiplePickingInGallery() {
|
|
||||||
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.ALLOW_MULTIPLE, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean shouldCopyTakenPhotosToPublicGalleryAppFolder() {
|
|
||||||
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.COPY_TAKEN_PHOTOS, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean shouldCopyPickedImagesToPublicGalleryAppFolder() {
|
|
||||||
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.COPY_PICKED_IMAGES, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package fr.free.nrw.commons.filepicker
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
|
||||||
|
class FilePickerConfiguration(
|
||||||
|
private val context: Context
|
||||||
|
): Constants {
|
||||||
|
|
||||||
|
fun setAllowMultiplePickInGallery(allowMultiple: Boolean): FilePickerConfiguration {
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||||
|
.putBoolean(Constants.BundleKeys.ALLOW_MULTIPLE, allowMultiple)
|
||||||
|
.apply()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCopyTakenPhotosToPublicGalleryAppFolder(copy: Boolean): FilePickerConfiguration {
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||||
|
.putBoolean(Constants.BundleKeys.COPY_TAKEN_PHOTOS, copy)
|
||||||
|
.apply()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFolderName(): String {
|
||||||
|
return PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
.getString(
|
||||||
|
Constants.BundleKeys.FOLDER_NAME,
|
||||||
|
Constants.DEFAULT_FOLDER_NAME
|
||||||
|
) ?: Constants.DEFAULT_FOLDER_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
fun allowsMultiplePickingInGallery(): Boolean {
|
||||||
|
return PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
.getBoolean(Constants.BundleKeys.ALLOW_MULTIPLE, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldCopyTakenPhotosToPublicGalleryAppFolder(): Boolean {
|
||||||
|
return PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
.getBoolean(Constants.BundleKeys.COPY_TAKEN_PHOTOS, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldCopyPickedImagesToPublicGalleryAppFolder(): Boolean {
|
||||||
|
return PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
.getBoolean(Constants.BundleKeys.COPY_PICKED_IMAGES, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
package fr.free.nrw.commons.filepicker;
|
|
||||||
|
|
||||||
import android.webkit.MimeTypeMap;
|
|
||||||
|
|
||||||
import com.facebook.common.internal.ImmutableMap;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class MimeTypeMapWrapper {
|
|
||||||
|
|
||||||
private static final MimeTypeMap sMimeTypeMap = MimeTypeMap.getSingleton();
|
|
||||||
|
|
||||||
private static final Map<String, String> sMimeTypeToExtensionMap =
|
|
||||||
ImmutableMap.of(
|
|
||||||
"image/heif", "heif",
|
|
||||||
"image/heic", "heic");
|
|
||||||
|
|
||||||
public static String getExtensionFromMimeType(String mimeType) {
|
|
||||||
String result = sMimeTypeToExtensionMap.get(mimeType);
|
|
||||||
if (result != null) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return sMimeTypeMap.getExtensionFromMimeType(mimeType);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package fr.free.nrw.commons.filepicker
|
||||||
|
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
|
||||||
|
class MimeTypeMapWrapper {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val sMimeTypeMap = MimeTypeMap.getSingleton()
|
||||||
|
|
||||||
|
private val sMimeTypeToExtensionMap = mapOf(
|
||||||
|
"image/heif" to "heif",
|
||||||
|
"image/heic" to "heic"
|
||||||
|
)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getExtensionFromMimeType(mimeType: String): String? {
|
||||||
|
val result = sMimeTypeToExtensionMap[mimeType]
|
||||||
|
if (result != null) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return sMimeTypeMap.getExtensionFromMimeType(mimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
package fr.free.nrw.commons.filepicker;
|
|
||||||
|
|
||||||
import android.content.ContentResolver;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.media.MediaScannerConnection;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Environment;
|
|
||||||
import android.webkit.MimeTypeMap;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.core.content.FileProvider;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.io.OutputStream;
|
|
||||||
import java.text.SimpleDateFormat;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Calendar;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import timber.log.Timber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PickedFiles.
|
|
||||||
* Process the upload items.
|
|
||||||
*/
|
|
||||||
public class PickedFiles implements Constants {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Folder Name
|
|
||||||
* @param context
|
|
||||||
* @return default application folder name.
|
|
||||||
*/
|
|
||||||
private static String getFolderName(@NonNull Context context) {
|
|
||||||
return FilePicker.configuration(context).getFolderName();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* tempImageDirectory
|
|
||||||
* @param context
|
|
||||||
* @return temporary image directory to copy and perform exif changes.
|
|
||||||
*/
|
|
||||||
private static File tempImageDirectory(@NonNull Context context) {
|
|
||||||
File privateTempDir = new File(context.getCacheDir(), DEFAULT_FOLDER_NAME);
|
|
||||||
if (!privateTempDir.exists()) privateTempDir.mkdirs();
|
|
||||||
return privateTempDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* writeToFile
|
|
||||||
* writes inputStream data to the destination file.
|
|
||||||
* @param in input stream of source file.
|
|
||||||
* @param file destination file
|
|
||||||
*/
|
|
||||||
private static void writeToFile(InputStream in, File file) throws IOException {
|
|
||||||
try (OutputStream out = new FileOutputStream(file)) {
|
|
||||||
byte[] buf = new byte[1024];
|
|
||||||
int len;
|
|
||||||
while ((len = in.read(buf)) > 0) {
|
|
||||||
out.write(buf, 0, len);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy file function.
|
|
||||||
* Copies source file to destination file.
|
|
||||||
* @param src source file
|
|
||||||
* @param dst destination file
|
|
||||||
* @throws IOException (File input stream exception)
|
|
||||||
*/
|
|
||||||
private static void copyFile(File src, File dst) throws IOException {
|
|
||||||
try (InputStream in = new FileInputStream(src)) {
|
|
||||||
writeToFile(in, dst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy files in separate thread.
|
|
||||||
* Copies all the uploadable files to the temp image folder on background thread.
|
|
||||||
* @param context
|
|
||||||
* @param filesToCopy uploadable file list to be copied.
|
|
||||||
*/
|
|
||||||
static void copyFilesInSeparateThread(final Context context, final List<UploadableFile> filesToCopy) {
|
|
||||||
new Thread(() -> {
|
|
||||||
List<File> copiedFiles = new ArrayList<>();
|
|
||||||
int i = 1;
|
|
||||||
for (UploadableFile uploadableFile : filesToCopy) {
|
|
||||||
File fileToCopy = uploadableFile.getFile();
|
|
||||||
File dstDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), getFolderName(context));
|
|
||||||
if (!dstDir.exists()) {
|
|
||||||
dstDir.mkdirs();
|
|
||||||
}
|
|
||||||
|
|
||||||
String[] filenameSplit = fileToCopy.getName().split("\\.");
|
|
||||||
String extension = "." + filenameSplit[filenameSplit.length - 1];
|
|
||||||
String filename = String.format("IMG_%s_%d.%s", new SimpleDateFormat("yyyyMMdd_HHmmss").format(Calendar.getInstance().getTime()), i, extension);
|
|
||||||
|
|
||||||
File dstFile = new File(dstDir, filename);
|
|
||||||
try {
|
|
||||||
dstFile.createNewFile();
|
|
||||||
copyFile(fileToCopy, dstFile);
|
|
||||||
copiedFiles.add(dstFile);
|
|
||||||
} catch (IOException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
scanCopiedImages(context, copiedFiles);
|
|
||||||
}).run();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* singleFileList.
|
|
||||||
* converts a single uploadableFile to list of uploadableFile.
|
|
||||||
* @param file uploadable file
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
static List<UploadableFile> singleFileList(UploadableFile file) {
|
|
||||||
List<UploadableFile> list = new ArrayList<>();
|
|
||||||
list.add(file);
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ScanCopiedImages
|
|
||||||
* Scan copied images metadata using media scanner.
|
|
||||||
* @param context
|
|
||||||
* @param copiedImages copied images list.
|
|
||||||
*/
|
|
||||||
static void scanCopiedImages(Context context, List<File> copiedImages) {
|
|
||||||
String[] paths = new String[copiedImages.size()];
|
|
||||||
for (int i = 0; i < copiedImages.size(); i++) {
|
|
||||||
paths[i] = copiedImages.get(i).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaScannerConnection.scanFile(context,
|
|
||||||
paths, null,
|
|
||||||
(path, uri) -> {
|
|
||||||
Timber.d("Scanned " + path + ":");
|
|
||||||
Timber.d("-> uri=%s", uri);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* pickedExistingPicture
|
|
||||||
* convert the image into uploadable file.
|
|
||||||
* @param photoUri Uri of the image.
|
|
||||||
* @return Uploadable file ready for tag redaction.
|
|
||||||
*/
|
|
||||||
public static UploadableFile pickedExistingPicture(@NonNull Context context, Uri photoUri) throws IOException, SecurityException {// SecurityException for those file providers who share URI but forget to grant necessary permissions
|
|
||||||
File directory = tempImageDirectory(context);
|
|
||||||
File photoFile = new File(directory, UUID.randomUUID().toString() + "." + getMimeType(context, photoUri));
|
|
||||||
if (photoFile.createNewFile()) {
|
|
||||||
try (InputStream pictureInputStream = context.getContentResolver().openInputStream(photoUri)) {
|
|
||||||
writeToFile(pictureInputStream, photoFile);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new IOException("could not create photoFile to write upon");
|
|
||||||
}
|
|
||||||
return new UploadableFile(photoUri, photoFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* getCameraPictureLocation
|
|
||||||
*/
|
|
||||||
static File getCameraPicturesLocation(@NonNull Context context) throws IOException {
|
|
||||||
File dir = tempImageDirectory(context);
|
|
||||||
return File.createTempFile(UUID.randomUUID().toString(), ".jpg", dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To find out the extension of required object in given uri
|
|
||||||
* Solution by http://stackoverflow.com/a/36514823/1171484
|
|
||||||
*/
|
|
||||||
private static String getMimeType(@NonNull Context context, @NonNull Uri uri) {
|
|
||||||
String extension;
|
|
||||||
|
|
||||||
//Check uri format to avoid null
|
|
||||||
if (uri.getScheme().equals(ContentResolver.SCHEME_CONTENT)) {
|
|
||||||
//If scheme is a content
|
|
||||||
extension = MimeTypeMapWrapper.getExtensionFromMimeType(context.getContentResolver().getType(uri));
|
|
||||||
} else {
|
|
||||||
//If scheme is a File
|
|
||||||
//This will replace white spaces with %20 and also other special characters. This will avoid returning null values on file name with spaces and special characters.
|
|
||||||
extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(uri.getPath())).toString());
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return extension;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GetUriToFile
|
|
||||||
* @param file get uri of file
|
|
||||||
* @return uri of requested file.
|
|
||||||
*/
|
|
||||||
static Uri getUriToFile(@NonNull Context context, @NonNull File file) {
|
|
||||||
String packageName = context.getApplicationContext().getPackageName();
|
|
||||||
String authority = packageName + ".provider";
|
|
||||||
return FileProvider.getUriForFile(context, authority, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
195
app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt
Normal file
195
app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
package fr.free.nrw.commons.filepicker
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.MediaScannerConnection
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Environment
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import fr.free.nrw.commons.filepicker.Constants.Companion.DEFAULT_FOLDER_NAME
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PickedFiles.
|
||||||
|
* Process the upload items.
|
||||||
|
*/
|
||||||
|
object PickedFiles : Constants {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Folder Name
|
||||||
|
* @return default application folder name.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
private fun getFolderName(context: Context): String {
|
||||||
|
return FilePicker.configuration(context).getFolderName()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tempImageDirectory
|
||||||
|
* @return temporary image directory to copy and perform exif changes.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
private fun tempImageDirectory(context: Context): File {
|
||||||
|
val privateTempDir = File(context.cacheDir, DEFAULT_FOLDER_NAME)
|
||||||
|
if (!privateTempDir.exists()) privateTempDir.mkdirs()
|
||||||
|
return privateTempDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* writeToFile
|
||||||
|
* Writes inputStream data to the destination file.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun writeToFile(inputStream: InputStream, file: File) {
|
||||||
|
inputStream.use { input ->
|
||||||
|
FileOutputStream(file).use { output ->
|
||||||
|
val buffer = ByteArray(1024)
|
||||||
|
var length: Int
|
||||||
|
while (input.read(buffer).also { length = it } > 0) {
|
||||||
|
output.write(buffer, 0, length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy file function.
|
||||||
|
* Copies source file to destination file.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
@JvmStatic
|
||||||
|
private fun copyFile(src: File, dst: File) {
|
||||||
|
FileInputStream(src).use { inputStream ->
|
||||||
|
writeToFile(inputStream, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy files in separate thread.
|
||||||
|
* Copies all the uploadable files to the temp image folder on background thread.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun copyFilesInSeparateThread(context: Context, filesToCopy: List<UploadableFile>) {
|
||||||
|
Thread {
|
||||||
|
val copiedFiles = mutableListOf<File>()
|
||||||
|
var index = 1
|
||||||
|
filesToCopy.forEach { uploadableFile ->
|
||||||
|
val fileToCopy = uploadableFile.file
|
||||||
|
val dstDir = File(
|
||||||
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
|
||||||
|
getFolderName(context)
|
||||||
|
)
|
||||||
|
if (!dstDir.exists()) dstDir.mkdirs()
|
||||||
|
|
||||||
|
val filenameSplit = fileToCopy.name.split(".")
|
||||||
|
val extension = ".${filenameSplit.last()}"
|
||||||
|
val filename = "IMG_${SimpleDateFormat(
|
||||||
|
"yyyyMMdd_HHmmss",
|
||||||
|
Locale.getDefault()).format(Date())}_$index$extension"
|
||||||
|
val dstFile = File(dstDir, filename)
|
||||||
|
|
||||||
|
try {
|
||||||
|
dstFile.createNewFile()
|
||||||
|
copyFile(fileToCopy, dstFile)
|
||||||
|
copiedFiles.add(dstFile)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
scanCopiedImages(context, copiedFiles)
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* singleFileList
|
||||||
|
* Converts a single uploadableFile to list of uploadableFile.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun singleFileList(file: UploadableFile): List<UploadableFile> {
|
||||||
|
return listOf(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScanCopiedImages
|
||||||
|
* Scans copied images metadata using media scanner.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun scanCopiedImages(context: Context, copiedImages: List<File>) {
|
||||||
|
val paths = copiedImages.map { it.toString() }.toTypedArray()
|
||||||
|
MediaScannerConnection.scanFile(context, paths, null) { path, uri ->
|
||||||
|
Timber.d("Scanned $path:")
|
||||||
|
Timber.d("-> uri=$uri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pickedExistingPicture
|
||||||
|
* Convert the image into uploadable file.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class, SecurityException::class)
|
||||||
|
@JvmStatic
|
||||||
|
fun pickedExistingPicture(context: Context, photoUri: Uri): UploadableFile {
|
||||||
|
val directory = tempImageDirectory(context)
|
||||||
|
val mimeType = getMimeType(context, photoUri)
|
||||||
|
val photoFile = File(directory, "${UUID.randomUUID()}.$mimeType")
|
||||||
|
|
||||||
|
if (photoFile.createNewFile()) {
|
||||||
|
context.contentResolver.openInputStream(photoUri)?.use { inputStream ->
|
||||||
|
writeToFile(inputStream, photoFile)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw IOException("Could not create photoFile to write upon")
|
||||||
|
}
|
||||||
|
return UploadableFile(photoUri, photoFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getCameraPictureLocation
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
@JvmStatic
|
||||||
|
fun getCameraPicturesLocation(context: Context): File {
|
||||||
|
val dir = tempImageDirectory(context)
|
||||||
|
return File.createTempFile(UUID.randomUUID().toString(), ".jpg", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To find out the extension of the required object in a given uri
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
private fun getMimeType(context: Context, uri: Uri): String {
|
||||||
|
return if (uri.scheme == ContentResolver.SCHEME_CONTENT) {
|
||||||
|
context.contentResolver.getType(uri)
|
||||||
|
?.let { MimeTypeMapWrapper.getExtensionFromMimeType(it) }
|
||||||
|
} else {
|
||||||
|
MimeTypeMap.getFileExtensionFromUrl(
|
||||||
|
Uri.fromFile(uri.path?.let { File(it) }).toString()
|
||||||
|
)
|
||||||
|
} ?: "jpg" // Default to jpg if unable to determine type
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetUriToFile
|
||||||
|
* @param file get uri of file
|
||||||
|
* @return uri of requested file.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun getUriToFile(context: Context, file: File): Uri {
|
||||||
|
val packageName = context.applicationContext.packageName
|
||||||
|
val authority = "$packageName.provider"
|
||||||
|
return FileProvider.getUriForFile(context, authority, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
package fr.free.nrw.commons.filepicker;
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Parcel;
|
|
||||||
import android.os.Parcelable;
|
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.exifinterface.media.ExifInterface;
|
|
||||||
|
|
||||||
import fr.free.nrw.commons.upload.FileUtils;
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.Date;
|
|
||||||
import timber.log.Timber;
|
|
||||||
|
|
||||||
public class UploadableFile implements Parcelable {
|
|
||||||
public static final Creator<UploadableFile> CREATOR = new Creator<UploadableFile>() {
|
|
||||||
@Override
|
|
||||||
public UploadableFile createFromParcel(Parcel in) {
|
|
||||||
return new UploadableFile(in);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public UploadableFile[] newArray(int size) {
|
|
||||||
return new UploadableFile[size];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private final Uri contentUri;
|
|
||||||
private final File file;
|
|
||||||
|
|
||||||
public UploadableFile(Uri contentUri, File file) {
|
|
||||||
this.contentUri = contentUri;
|
|
||||||
this.file = file;
|
|
||||||
}
|
|
||||||
|
|
||||||
public UploadableFile(File file) {
|
|
||||||
this.file = file;
|
|
||||||
this.contentUri = Uri.fromFile(new File(file.getPath()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public UploadableFile(Parcel in) {
|
|
||||||
this.contentUri = in.readParcelable(Uri.class.getClassLoader());
|
|
||||||
file = (File) in.readSerializable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Uri getContentUri() {
|
|
||||||
return contentUri;
|
|
||||||
}
|
|
||||||
|
|
||||||
public File getFile() {
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getFilePath() {
|
|
||||||
return file.getPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Uri getMediaUri() {
|
|
||||||
return Uri.parse(getFilePath());
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getMimeType(Context context) {
|
|
||||||
return FileUtils.getMimeType(context, getMediaUri());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int describeContents() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First try to get the file creation date from EXIF else fall back to CP
|
|
||||||
* @param context
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
@Nullable
|
|
||||||
public DateTimeWithSource getFileCreatedDate(Context context) {
|
|
||||||
DateTimeWithSource dateTimeFromExif = getDateTimeFromExif();
|
|
||||||
if (dateTimeFromExif == null) {
|
|
||||||
return getFileCreatedDateFromCP(context);
|
|
||||||
} else {
|
|
||||||
return dateTimeFromExif;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get filePath creation date from uri from all possible content providers
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
private DateTimeWithSource getFileCreatedDateFromCP(Context context) {
|
|
||||||
try {
|
|
||||||
Cursor cursor = context.getContentResolver().query(contentUri, null, null, null, null);
|
|
||||||
if (cursor == null) {
|
|
||||||
return null;//Could not fetch last_modified
|
|
||||||
}
|
|
||||||
//Content provider contracts for opening gallery from the app and that by sharing from gallery from outside are different and we need to handle both the cases
|
|
||||||
int lastModifiedColumnIndex = cursor.getColumnIndex("last_modified");//If gallery is opened from in app
|
|
||||||
if (lastModifiedColumnIndex == -1) {
|
|
||||||
lastModifiedColumnIndex = cursor.getColumnIndex("datetaken");
|
|
||||||
}
|
|
||||||
//If both the content providers do not give the data, lets leave it to Jesus
|
|
||||||
if (lastModifiedColumnIndex == -1) {
|
|
||||||
cursor.close();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
cursor.moveToFirst();
|
|
||||||
return new DateTimeWithSource(cursor.getLong(lastModifiedColumnIndex), DateTimeWithSource.CP_SOURCE);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;////Could not fetch last_modified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicate whether the EXIF contains the location (both latitude and longitude).
|
|
||||||
*
|
|
||||||
* @return whether the location exists for the file's EXIF
|
|
||||||
*/
|
|
||||||
public boolean hasLocation() {
|
|
||||||
try {
|
|
||||||
ExifInterface exif = new ExifInterface(file.getAbsolutePath());
|
|
||||||
final String latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE);
|
|
||||||
final String longitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE);
|
|
||||||
return latitude != null && longitude != null;
|
|
||||||
} catch (IOException | NumberFormatException | IndexOutOfBoundsException e) {
|
|
||||||
Timber.tag("UploadableFile");
|
|
||||||
Timber.d(e);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get filePath creation date from uri from EXIF
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
private DateTimeWithSource getDateTimeFromExif() {
|
|
||||||
try {
|
|
||||||
ExifInterface exif = new ExifInterface(file.getAbsolutePath());
|
|
||||||
// TAG_DATETIME returns the last edited date, we need TAG_DATETIME_ORIGINAL for creation date
|
|
||||||
// See issue https://github.com/commons-app/apps-android-commons/issues/1971
|
|
||||||
String dateTimeSubString = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL);
|
|
||||||
if (dateTimeSubString!=null) { //getAttribute may return null
|
|
||||||
String year = dateTimeSubString.substring(0,4);
|
|
||||||
String month = dateTimeSubString.substring(5,7);
|
|
||||||
String day = dateTimeSubString.substring(8,10);
|
|
||||||
// This date is stored as a string (not as a date), the rason is we don't want to include timezones
|
|
||||||
String dateCreatedString = String.format("%04d-%02d-%02d", Integer.parseInt(year), Integer.parseInt(month), Integer.parseInt(day));
|
|
||||||
if (dateCreatedString.length() == 10) { //yyyy-MM-dd format of date is expected
|
|
||||||
@SuppressLint("RestrictedApi") Long dateTime = exif.getDateTimeOriginal();
|
|
||||||
if(dateTime != null){
|
|
||||||
Date date = new Date(dateTime);
|
|
||||||
return new DateTimeWithSource(date, dateCreatedString, DateTimeWithSource.EXIF_SOURCE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException | NumberFormatException | IndexOutOfBoundsException e) {
|
|
||||||
Timber.tag("UploadableFile");
|
|
||||||
Timber.d(e);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void writeToParcel(Parcel parcel, int i) {
|
|
||||||
parcel.writeParcelable(contentUri, 0);
|
|
||||||
parcel.writeSerializable(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class contains the epochDate along with the source from which it was extracted
|
|
||||||
*/
|
|
||||||
public class DateTimeWithSource {
|
|
||||||
public static final String CP_SOURCE = "contentProvider";
|
|
||||||
public static final String EXIF_SOURCE = "exif";
|
|
||||||
|
|
||||||
private final long epochDate;
|
|
||||||
private String dateString; // this does not includes timezone information
|
|
||||||
private final String source;
|
|
||||||
|
|
||||||
public DateTimeWithSource(long epochDate, String source) {
|
|
||||||
this.epochDate = epochDate;
|
|
||||||
this.source = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
public DateTimeWithSource(Date date, String source) {
|
|
||||||
this.epochDate = date.getTime();
|
|
||||||
this.source = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
public DateTimeWithSource(Date date, String dateString, String source) {
|
|
||||||
this.epochDate = date.getTime();
|
|
||||||
this.dateString = dateString;
|
|
||||||
this.source = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getEpochDate() {
|
|
||||||
return epochDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getDateString() {
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getSource() {
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
package fr.free.nrw.commons.filepicker
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.os.Parcelable
|
||||||
|
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
|
||||||
|
import fr.free.nrw.commons.upload.FileUtils
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.Date
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class UploadableFile : Parcelable {
|
||||||
|
|
||||||
|
val contentUri: Uri
|
||||||
|
val file: File
|
||||||
|
|
||||||
|
constructor(contentUri: Uri, file: File) {
|
||||||
|
this.contentUri = contentUri
|
||||||
|
this.file = file
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(file: File) {
|
||||||
|
this.file = file
|
||||||
|
this.contentUri = Uri.fromFile(File(file.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(parcel: Parcel) {
|
||||||
|
contentUri = parcel.readParcelable(Uri::class.java.classLoader)!!
|
||||||
|
file = parcel.readSerializable() as File
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFilePath(): String {
|
||||||
|
return file.path
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMediaUri(): Uri {
|
||||||
|
return Uri.parse(getFilePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMimeType(context: Context): String? {
|
||||||
|
return FileUtils.getMimeType(context, getMediaUri())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun describeContents(): Int = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First try to get the file creation date from EXIF, else fall back to Content Provider (CP)
|
||||||
|
*/
|
||||||
|
fun getFileCreatedDate(context: Context): DateTimeWithSource? {
|
||||||
|
return getDateTimeFromExif() ?: getFileCreatedDateFromCP(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filePath creation date from URI using all possible content providers
|
||||||
|
*/
|
||||||
|
private fun getFileCreatedDateFromCP(context: Context): DateTimeWithSource? {
|
||||||
|
return try {
|
||||||
|
val cursor: Cursor? = context.contentResolver.query(contentUri, null, null, null, null)
|
||||||
|
cursor?.use {
|
||||||
|
val lastModifiedColumnIndex = cursor
|
||||||
|
.getColumnIndex(
|
||||||
|
"last_modified"
|
||||||
|
).takeIf { it != -1 }
|
||||||
|
?: cursor.getColumnIndex("datetaken")
|
||||||
|
if (lastModifiedColumnIndex == -1) return null // No valid column found
|
||||||
|
cursor.moveToFirst()
|
||||||
|
DateTimeWithSource(
|
||||||
|
cursor.getLong(
|
||||||
|
lastModifiedColumnIndex
|
||||||
|
), DateTimeWithSource.CP_SOURCE)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("UploadableFile").d(e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether the EXIF contains the location (both latitude and longitude).
|
||||||
|
*/
|
||||||
|
fun hasLocation(): Boolean {
|
||||||
|
return try {
|
||||||
|
val exif = ExifInterface(file.absolutePath)
|
||||||
|
val latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE)
|
||||||
|
val longitude = exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE)
|
||||||
|
latitude != null && longitude != null
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Timber.tag("UploadableFile").d(e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filePath creation date from URI using EXIF data
|
||||||
|
*/
|
||||||
|
private fun getDateTimeFromExif(): DateTimeWithSource? {
|
||||||
|
return try {
|
||||||
|
val exif = ExifInterface(file.absolutePath)
|
||||||
|
val dateTimeSubString = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
|
||||||
|
if (dateTimeSubString != null) {
|
||||||
|
val year = dateTimeSubString.substring(0, 4).toInt()
|
||||||
|
val month = dateTimeSubString.substring(5, 7).toInt()
|
||||||
|
val day = dateTimeSubString.substring(8, 10).toInt()
|
||||||
|
val dateCreatedString = "%04d-%02d-%02d".format(year, month, day)
|
||||||
|
if (dateCreatedString.length == 10) {
|
||||||
|
@SuppressLint("RestrictedApi")
|
||||||
|
val dateTime = exif.dateTimeOriginal
|
||||||
|
if (dateTime != null) {
|
||||||
|
val date = Date(dateTime)
|
||||||
|
return DateTimeWithSource(date, dateCreatedString, DateTimeWithSource.EXIF_SOURCE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("UploadableFile").d(e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||||
|
parcel.writeParcelable(contentUri, flags)
|
||||||
|
parcel.writeSerializable(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DateTimeWithSource {
|
||||||
|
companion object {
|
||||||
|
const val CP_SOURCE = "contentProvider"
|
||||||
|
const val EXIF_SOURCE = "exif"
|
||||||
|
}
|
||||||
|
|
||||||
|
val epochDate: Long
|
||||||
|
var dateString: String? = null
|
||||||
|
val source: String
|
||||||
|
|
||||||
|
constructor(epochDate: Long, source: String) {
|
||||||
|
this.epochDate = epochDate
|
||||||
|
this.source = source
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(date: Date, source: String) {
|
||||||
|
epochDate = date.time
|
||||||
|
this.source = source
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(date: Date, dateString: String, source: String) {
|
||||||
|
epochDate = date.time
|
||||||
|
this.dateString = dateString
|
||||||
|
this.source = source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object CREATOR : Parcelable.Creator<UploadableFile> {
|
||||||
|
override fun createFromParcel(parcel: Parcel): UploadableFile {
|
||||||
|
return UploadableFile(parcel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newArray(size: Int): Array<UploadableFile?> {
|
||||||
|
return arrayOfNulls(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -41,6 +41,13 @@ data class LatLng(
|
||||||
* Accepts a non-null [Location] and converts it to a [LatLng].
|
* Accepts a non-null [Location] and converts it to a [LatLng].
|
||||||
*/
|
*/
|
||||||
companion object {
|
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
|
* gets the latitude and longitude of a given non-null location
|
||||||
* @param location the non-null location of the user
|
* @param location the non-null location of the user
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
2233
app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt
Normal file
2233
app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
package fr.free.nrw.commons.mwapi;
|
|
||||||
|
|
||||||
import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_PREFIX;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import fr.free.nrw.commons.BuildConfig;
|
|
||||||
import fr.free.nrw.commons.category.CategoryItem;
|
|
||||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage;
|
|
||||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse;
|
|
||||||
import io.reactivex.Single;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Set;
|
|
||||||
import javax.inject.Inject;
|
|
||||||
import okhttp3.HttpUrl;
|
|
||||||
import okhttp3.OkHttpClient;
|
|
||||||
import okhttp3.Request;
|
|
||||||
import okhttp3.Response;
|
|
||||||
import okhttp3.ResponseBody;
|
|
||||||
import timber.log.Timber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uses the OkHttp library to implement calls to the Commons MediaWiki API to match GPS coordinates
|
|
||||||
* with nearby Commons categories. Parses the results using GSON to obtain a list of relevant
|
|
||||||
* categories. Note: that caller is responsible for executing the request() method on a background
|
|
||||||
* thread.
|
|
||||||
*/
|
|
||||||
public class CategoryApi {
|
|
||||||
|
|
||||||
private final OkHttpClient okHttpClient;
|
|
||||||
private final Gson gson;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
public CategoryApi(final OkHttpClient okHttpClient, final Gson gson) {
|
|
||||||
this.okHttpClient = okHttpClient;
|
|
||||||
this.gson = gson;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Single<List<CategoryItem>> request(String coords) {
|
|
||||||
return Single.fromCallable(() -> {
|
|
||||||
HttpUrl apiUrl = buildUrl(coords);
|
|
||||||
Timber.d("URL: %s", apiUrl.toString());
|
|
||||||
|
|
||||||
Request request = new Request.Builder().get().url(apiUrl).build();
|
|
||||||
Response response = okHttpClient.newCall(request).execute();
|
|
||||||
ResponseBody body = response.body();
|
|
||||||
if (body == null) {
|
|
||||||
return Collections.emptyList();
|
|
||||||
}
|
|
||||||
|
|
||||||
MwQueryResponse apiResponse = gson.fromJson(body.charStream(), MwQueryResponse.class);
|
|
||||||
Set<CategoryItem> categories = new LinkedHashSet<>();
|
|
||||||
if (apiResponse != null && apiResponse.query() != null && apiResponse.query().pages() != null) {
|
|
||||||
for (MwQueryPage page : apiResponse.query().pages()) {
|
|
||||||
if (page.categories() != null) {
|
|
||||||
for (MwQueryPage.Category category : page.categories()) {
|
|
||||||
categories.add(new CategoryItem(category.title().replace(CATEGORY_PREFIX, ""), "", "", false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new ArrayList<>(categories);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds URL with image coords for MediaWiki API calls
|
|
||||||
* Example URL: https://commons.wikimedia.org/w/api.php?action=query&prop=categories|coordinates|pageprops&format=json&clshow=!hidden&coprop=type|name|dim|country|region|globe&codistancefrompoint=38.11386944444445|13.356263888888888&generator=geosearch&redirects=&ggscoord=38.11386944444445|1.356263888888888&ggsradius=100&ggslimit=10&ggsnamespace=6&ggsprop=type|name|dim|country|region|globe&ggsprimary=all&formatversion=2
|
|
||||||
*
|
|
||||||
* @param coords Coordinates to build query with
|
|
||||||
* @return URL for API query
|
|
||||||
*/
|
|
||||||
private HttpUrl buildUrl(final String coords) {
|
|
||||||
return HttpUrl
|
|
||||||
.parse(BuildConfig.WIKIMEDIA_API_HOST)
|
|
||||||
.newBuilder()
|
|
||||||
.addQueryParameter("action", "query")
|
|
||||||
.addQueryParameter("prop", "categories|coordinates|pageprops")
|
|
||||||
.addQueryParameter("format", "json")
|
|
||||||
.addQueryParameter("clshow", "!hidden")
|
|
||||||
.addQueryParameter("coprop", "type|name|dim|country|region|globe")
|
|
||||||
.addQueryParameter("codistancefrompoint", coords)
|
|
||||||
.addQueryParameter("generator", "geosearch")
|
|
||||||
.addQueryParameter("ggscoord", coords)
|
|
||||||
.addQueryParameter("ggsradius", "10000")
|
|
||||||
.addQueryParameter("ggslimit", "10")
|
|
||||||
.addQueryParameter("ggsnamespace", "6")
|
|
||||||
.addQueryParameter("ggsprop", "type|name|dim|country|region|globe")
|
|
||||||
.addQueryParameter("ggsprimary", "all")
|
|
||||||
.addQueryParameter("formatversion", "2")
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
83
app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt
Normal file
83
app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package fr.free.nrw.commons.mwapi
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import fr.free.nrw.commons.BuildConfig
|
||||||
|
import fr.free.nrw.commons.category.CATEGORY_PREFIX
|
||||||
|
import fr.free.nrw.commons.category.CategoryItem
|
||||||
|
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
|
||||||
|
import io.reactivex.Single
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the OkHttp library to implement calls to the Commons MediaWiki API to match GPS coordinates
|
||||||
|
* with nearby Commons categories. Parses the results using GSON to obtain a list of relevant
|
||||||
|
* categories. Note: that caller is responsible for executing the request() method on a background
|
||||||
|
* thread.
|
||||||
|
*/
|
||||||
|
class CategoryApi @Inject constructor(
|
||||||
|
private val okHttpClient: OkHttpClient,
|
||||||
|
private val gson: Gson
|
||||||
|
) {
|
||||||
|
private val apiUrl : HttpUrl by lazy { BuildConfig.WIKIMEDIA_API_HOST.toHttpUrlOrNull()!! }
|
||||||
|
|
||||||
|
fun request(coords: String): Single<List<CategoryItem>> = Single.fromCallable {
|
||||||
|
val apiUrl = buildUrl(coords)
|
||||||
|
Timber.d("URL: %s", apiUrl.toString())
|
||||||
|
|
||||||
|
val request: Request = Request.Builder().get().url(apiUrl).build()
|
||||||
|
val response = okHttpClient.newCall(request).execute()
|
||||||
|
val body = response.body ?: return@fromCallable emptyList<CategoryItem>()
|
||||||
|
|
||||||
|
val apiResponse = gson.fromJson(body.charStream(), MwQueryResponse::class.java)
|
||||||
|
val categories: MutableSet<CategoryItem> = mutableSetOf()
|
||||||
|
if (apiResponse?.query() != null && apiResponse.query()!!.pages() != null) {
|
||||||
|
for (page in apiResponse.query()!!.pages()!!) {
|
||||||
|
if (page.categories() != null) {
|
||||||
|
for (category in page.categories()!!) {
|
||||||
|
categories.add(
|
||||||
|
CategoryItem(
|
||||||
|
name = category.title().replace(CATEGORY_PREFIX, ""),
|
||||||
|
description = "",
|
||||||
|
thumbnail = "",
|
||||||
|
isSelected = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ArrayList<CategoryItem>(categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds URL with image coords for MediaWiki API calls
|
||||||
|
* Example URL: https://commons.wikimedia.org/w/api.php?action=query&prop=categories|coordinates|pageprops&format=json&clshow=!hidden&coprop=type|name|dim|country|region|globe&codistancefrompoint=38.11386944444445|13.356263888888888&generator=geosearch&redirects=&ggscoord=38.11386944444445|1.356263888888888&ggsradius=100&ggslimit=10&ggsnamespace=6&ggsprop=type|name|dim|country|region|globe&ggsprimary=all&formatversion=2
|
||||||
|
*
|
||||||
|
* @param coords Coordinates to build query with
|
||||||
|
* @return URL for API query
|
||||||
|
*/
|
||||||
|
private fun buildUrl(coords: String): HttpUrl = apiUrl.newBuilder()
|
||||||
|
.addQueryParameter("action", "query")
|
||||||
|
.addQueryParameter("prop", "categories|coordinates|pageprops")
|
||||||
|
.addQueryParameter("format", "json")
|
||||||
|
.addQueryParameter("clshow", "!hidden")
|
||||||
|
.addQueryParameter("coprop", "type|name|dim|country|region|globe")
|
||||||
|
.addQueryParameter("codistancefrompoint", coords)
|
||||||
|
.addQueryParameter("generator", "geosearch")
|
||||||
|
.addQueryParameter("ggscoord", coords)
|
||||||
|
.addQueryParameter("ggsradius", "10000")
|
||||||
|
.addQueryParameter("ggslimit", "10")
|
||||||
|
.addQueryParameter("ggsnamespace", "6")
|
||||||
|
.addQueryParameter("ggsprop", "type|name|dim|country|region|globe")
|
||||||
|
.addQueryParameter("ggsprimary", "all")
|
||||||
|
.addQueryParameter("formatversion", "2")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,677 +0,0 @@
|
||||||
package fr.free.nrw.commons.mwapi;
|
|
||||||
|
|
||||||
import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LEADERBOARD_END_POINT;
|
|
||||||
import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.UPDATE_AVATAR_END_POINT;
|
|
||||||
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import fr.free.nrw.commons.campaigns.CampaignResponseDTO;
|
|
||||||
import fr.free.nrw.commons.explore.depictions.DepictsClient;
|
|
||||||
import fr.free.nrw.commons.location.LatLng;
|
|
||||||
import fr.free.nrw.commons.nearby.Place;
|
|
||||||
import fr.free.nrw.commons.nearby.model.ItemsClass;
|
|
||||||
import fr.free.nrw.commons.nearby.model.NearbyResponse;
|
|
||||||
import fr.free.nrw.commons.nearby.model.NearbyResultItem;
|
|
||||||
import fr.free.nrw.commons.nearby.model.PlaceBindings;
|
|
||||||
import fr.free.nrw.commons.profile.achievements.FeaturedImages;
|
|
||||||
import fr.free.nrw.commons.profile.achievements.FeedbackResponse;
|
|
||||||
import fr.free.nrw.commons.profile.leaderboard.LeaderboardResponse;
|
|
||||||
import fr.free.nrw.commons.profile.leaderboard.UpdateAvatarResponse;
|
|
||||||
import fr.free.nrw.commons.upload.FileUtils;
|
|
||||||
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
|
|
||||||
import fr.free.nrw.commons.utils.ConfigUtils;
|
|
||||||
import fr.free.nrw.commons.wikidata.model.GetWikidataEditCountResponse;
|
|
||||||
import io.reactivex.Observable;
|
|
||||||
import io.reactivex.Single;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import javax.inject.Inject;
|
|
||||||
import javax.inject.Singleton;
|
|
||||||
import okhttp3.HttpUrl;
|
|
||||||
import okhttp3.OkHttpClient;
|
|
||||||
import okhttp3.Request;
|
|
||||||
import okhttp3.Response;
|
|
||||||
import okhttp3.ResponseBody;
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
|
||||||
import timber.log.Timber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test methods in ok http api client
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
public class OkHttpJsonApiClient {
|
|
||||||
|
|
||||||
private final OkHttpClient okHttpClient;
|
|
||||||
private final DepictsClient depictsClient;
|
|
||||||
private final HttpUrl wikiMediaToolforgeUrl;
|
|
||||||
private final String sparqlQueryUrl;
|
|
||||||
private final String campaignsUrl;
|
|
||||||
private final Gson gson;
|
|
||||||
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
public OkHttpJsonApiClient(OkHttpClient okHttpClient,
|
|
||||||
DepictsClient depictsClient,
|
|
||||||
HttpUrl wikiMediaToolforgeUrl,
|
|
||||||
String sparqlQueryUrl,
|
|
||||||
String campaignsUrl,
|
|
||||||
Gson gson) {
|
|
||||||
this.okHttpClient = okHttpClient;
|
|
||||||
this.depictsClient = depictsClient;
|
|
||||||
this.wikiMediaToolforgeUrl = wikiMediaToolforgeUrl;
|
|
||||||
this.sparqlQueryUrl = sparqlQueryUrl;
|
|
||||||
this.campaignsUrl = campaignsUrl;
|
|
||||||
this.gson = gson;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The method will gradually calls the leaderboard API and fetches the leaderboard
|
|
||||||
*
|
|
||||||
* @param userName username of leaderboard user
|
|
||||||
* @param duration duration for leaderboard
|
|
||||||
* @param category category for leaderboard
|
|
||||||
* @param limit page size limit for list
|
|
||||||
* @param offset offset for the list
|
|
||||||
* @return LeaderboardResponse object
|
|
||||||
*/
|
|
||||||
@NonNull
|
|
||||||
public Observable<LeaderboardResponse> getLeaderboard(String userName, String duration,
|
|
||||||
String category, String limit, String offset) {
|
|
||||||
final String fetchLeaderboardUrlTemplate = wikiMediaToolforgeUrl
|
|
||||||
+ LEADERBOARD_END_POINT;
|
|
||||||
String url = String.format(Locale.ENGLISH,
|
|
||||||
fetchLeaderboardUrlTemplate,
|
|
||||||
userName,
|
|
||||||
duration,
|
|
||||||
category,
|
|
||||||
limit,
|
|
||||||
offset);
|
|
||||||
HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder();
|
|
||||||
urlBuilder.addQueryParameter("user", userName);
|
|
||||||
urlBuilder.addQueryParameter("duration", duration);
|
|
||||||
urlBuilder.addQueryParameter("category", category);
|
|
||||||
urlBuilder.addQueryParameter("limit", limit);
|
|
||||||
urlBuilder.addQueryParameter("offset", offset);
|
|
||||||
Timber.i("Url %s", urlBuilder.toString());
|
|
||||||
Request request = new Request.Builder()
|
|
||||||
.url(urlBuilder.toString())
|
|
||||||
.build();
|
|
||||||
return Observable.fromCallable(() -> {
|
|
||||||
Response response = okHttpClient.newCall(request).execute();
|
|
||||||
if (response != null && response.body() != null && response.isSuccessful()) {
|
|
||||||
String json = response.body().string();
|
|
||||||
if (json == null) {
|
|
||||||
return new LeaderboardResponse();
|
|
||||||
}
|
|
||||||
Timber.d("Response for leaderboard is %s", json);
|
|
||||||
try {
|
|
||||||
return gson.fromJson(json, LeaderboardResponse.class);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return new LeaderboardResponse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new LeaderboardResponse();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This method will update the leaderboard user avatar
|
|
||||||
*
|
|
||||||
* @param username username to update
|
|
||||||
* @param avatar url of the new avatar
|
|
||||||
* @return UpdateAvatarResponse object
|
|
||||||
*/
|
|
||||||
@NonNull
|
|
||||||
public Single<UpdateAvatarResponse> setAvatar(String username, String avatar) {
|
|
||||||
final String urlTemplate = wikiMediaToolforgeUrl
|
|
||||||
+ UPDATE_AVATAR_END_POINT;
|
|
||||||
return Single.fromCallable(() -> {
|
|
||||||
String url = String.format(Locale.ENGLISH,
|
|
||||||
urlTemplate,
|
|
||||||
username,
|
|
||||||
avatar);
|
|
||||||
HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder();
|
|
||||||
urlBuilder.addQueryParameter("user", username);
|
|
||||||
urlBuilder.addQueryParameter("avatar", avatar);
|
|
||||||
Timber.i("Url %s", urlBuilder.toString());
|
|
||||||
Request request = new Request.Builder()
|
|
||||||
.url(urlBuilder.toString())
|
|
||||||
.build();
|
|
||||||
Response response = okHttpClient.newCall(request).execute();
|
|
||||||
if (response != null && response.body() != null && response.isSuccessful()) {
|
|
||||||
String json = response.body().string();
|
|
||||||
if (json == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return gson.fromJson(json, UpdateAvatarResponse.class);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return new UpdateAvatarResponse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
public Single<Integer> getUploadCount(String userName) {
|
|
||||||
HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder();
|
|
||||||
urlBuilder
|
|
||||||
.addPathSegments("uploadsbyuser.py")
|
|
||||||
.addQueryParameter("user", userName);
|
|
||||||
|
|
||||||
if (ConfigUtils.isBetaFlavour()) {
|
|
||||||
urlBuilder.addQueryParameter("labs", "commonswiki");
|
|
||||||
}
|
|
||||||
|
|
||||||
Request request = new Request.Builder()
|
|
||||||
.url(urlBuilder.build())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return Single.fromCallable(() -> {
|
|
||||||
Response response = okHttpClient.newCall(request).execute();
|
|
||||||
if (response != null && response.isSuccessful()) {
|
|
||||||
ResponseBody responseBody = response.body();
|
|
||||||
if (null != responseBody) {
|
|
||||||
String responseBodyString = responseBody.string().trim();
|
|
||||||
if (!TextUtils.isEmpty(responseBodyString)) {
|
|
||||||
try {
|
|
||||||
return Integer.parseInt(responseBodyString);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
Timber.e(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
public Single<Integer> getWikidataEdits(String userName) {
|
|
||||||
HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder();
|
|
||||||
urlBuilder
|
|
||||||
.addPathSegments("wikidataedits.py")
|
|
||||||
.addQueryParameter("user", userName);
|
|
||||||
|
|
||||||
if (ConfigUtils.isBetaFlavour()) {
|
|
||||||
urlBuilder.addQueryParameter("labs", "commonswiki");
|
|
||||||
}
|
|
||||||
|
|
||||||
Request request = new Request.Builder()
|
|
||||||
.url(urlBuilder.build())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return Single.fromCallable(() -> {
|
|
||||||
Response response = okHttpClient.newCall(request).execute();
|
|
||||||
if (response != null &&
|
|
||||||
response.isSuccessful() && response.body() != null) {
|
|
||||||
String json = response.body().string();
|
|
||||||
if (json == null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
// Extract JSON from response
|
|
||||||
json = json.substring(json.indexOf('{'));
|
|
||||||
GetWikidataEditCountResponse countResponse = gson
|
|
||||||
.fromJson(json, GetWikidataEditCountResponse.class);
|
|
||||||
if (null != countResponse) {
|
|
||||||
return countResponse.getWikidataEditCount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This takes userName as input, which is then used to fetch the feedback/achievements
|
|
||||||
* statistics using OkHttp and JavaRx. This function return JSONObject
|
|
||||||
*
|
|
||||||
* @param userName MediaWiki user name
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
public Single<FeedbackResponse> getAchievements(String userName) {
|
|
||||||
final String fetchAchievementUrlTemplate =
|
|
||||||
wikiMediaToolforgeUrl + (ConfigUtils.isBetaFlavour() ? "/feedback.py?labs=commonswiki"
|
|
||||||
: "/feedback.py");
|
|
||||||
return Single.fromCallable(() -> {
|
|
||||||
String url = String.format(
|
|
||||||
Locale.ENGLISH,
|
|
||||||
fetchAchievementUrlTemplate,
|
|
||||||
userName);
|
|
||||||
HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder();
|
|
||||||
urlBuilder.addQueryParameter("user", userName);
|
|
||||||
Request request = new Request.Builder()
|
|
||||||
.url(urlBuilder.toString())
|
|
||||||
.build();
|
|
||||||
Response response = okHttpClient.newCall(request).execute();
|
|
||||||
if (response != null && response.body() != null && response.isSuccessful()) {
|
|
||||||
String json = response.body().string();
|
|
||||||
if (json == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Extract JSON from response
|
|
||||||
json = json.substring(json.indexOf('{'));
|
|
||||||
Timber.d("Response for achievements is %s", json);
|
|
||||||
try {
|
|
||||||
return gson.fromJson(json, FeedbackResponse.class);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return new FeedbackResponse(0, 0, 0, new FeaturedImages(0, 0), 0, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make API Call to get Nearby Places
|
|
||||||
*
|
|
||||||
* @param cur Search lat long
|
|
||||||
* @param language Language
|
|
||||||
* @param radius Search Radius
|
|
||||||
* @return
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
@Nullable
|
|
||||||
public List<Place> getNearbyPlaces(final LatLng cur, final String language, final double radius,
|
|
||||||
final String customQuery)
|
|
||||||
throws Exception {
|
|
||||||
|
|
||||||
Timber.d("Fetching nearby items at radius %s", radius);
|
|
||||||
Timber.d("CUSTOM_SPARQL: %s", String.valueOf(customQuery != null));
|
|
||||||
final String wikidataQuery;
|
|
||||||
if (customQuery != null) {
|
|
||||||
wikidataQuery = customQuery;
|
|
||||||
} else {
|
|
||||||
wikidataQuery = FileUtils.readFromResource(
|
|
||||||
"/queries/radius_query_for_upload_wizard.rq");
|
|
||||||
}
|
|
||||||
final String query = wikidataQuery
|
|
||||||
.replace("${RAD}", String.format(Locale.ROOT, "%.2f", radius))
|
|
||||||
.replace("${LAT}", String.format(Locale.ROOT, "%.4f", cur.getLatitude()))
|
|
||||||
.replace("${LONG}", String.format(Locale.ROOT, "%.4f", cur.getLongitude()))
|
|
||||||
.replace("${LANG}", language);
|
|
||||||
|
|
||||||
final HttpUrl.Builder urlBuilder = HttpUrl
|
|
||||||
.parse(sparqlQueryUrl)
|
|
||||||
.newBuilder()
|
|
||||||
.addQueryParameter("query", query)
|
|
||||||
.addQueryParameter("format", "json");
|
|
||||||
|
|
||||||
final Request request = new Request.Builder()
|
|
||||||
.url(urlBuilder.build())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
final Response response = okHttpClient.newCall(request).execute();
|
|
||||||
if (response.body() != null && response.isSuccessful()) {
|
|
||||||
final String json = response.body().string();
|
|
||||||
final NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class);
|
|
||||||
final List<NearbyResultItem> bindings = nearbyResponse.getResults().getBindings();
|
|
||||||
final List<Place> places = new ArrayList<>();
|
|
||||||
for (final NearbyResultItem item : bindings) {
|
|
||||||
final Place placeFromNearbyItem = Place.from(item);
|
|
||||||
placeFromNearbyItem.setMonument(false);
|
|
||||||
places.add(placeFromNearbyItem);
|
|
||||||
}
|
|
||||||
return places;
|
|
||||||
}
|
|
||||||
throw new Exception(response.message());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves nearby places based on screen coordinates and optional query parameters.
|
|
||||||
*
|
|
||||||
* @param screenTopRight The top right corner of the screen (latitude, longitude).
|
|
||||||
* @param screenBottomLeft The bottom left corner of the screen (latitude, longitude).
|
|
||||||
* @param language The language for the query.
|
|
||||||
* @param shouldQueryForMonuments Flag indicating whether to include monuments in the query.
|
|
||||||
* @param customQuery Optional custom SPARQL query to use instead of default
|
|
||||||
* queries.
|
|
||||||
* @return A list of nearby places.
|
|
||||||
* @throws Exception If an error occurs during the retrieval process.
|
|
||||||
*/
|
|
||||||
@Nullable
|
|
||||||
public List<Place> getNearbyPlaces(
|
|
||||||
final fr.free.nrw.commons.location.LatLng screenTopRight,
|
|
||||||
final fr.free.nrw.commons.location.LatLng screenBottomLeft, final String language,
|
|
||||||
final boolean shouldQueryForMonuments, final String customQuery)
|
|
||||||
throws Exception {
|
|
||||||
|
|
||||||
Timber.d("CUSTOM_SPARQL: %s", String.valueOf(customQuery != null));
|
|
||||||
|
|
||||||
final String wikidataQuery;
|
|
||||||
if (customQuery != null) {
|
|
||||||
wikidataQuery = customQuery;
|
|
||||||
} else if (!shouldQueryForMonuments) {
|
|
||||||
wikidataQuery = FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq");
|
|
||||||
} else {
|
|
||||||
wikidataQuery = FileUtils.readFromResource(
|
|
||||||
"/queries/rectangle_query_for_nearby_monuments.rq");
|
|
||||||
}
|
|
||||||
|
|
||||||
final double westCornerLat = screenTopRight.getLatitude();
|
|
||||||
final double westCornerLong = screenTopRight.getLongitude();
|
|
||||||
final double eastCornerLat = screenBottomLeft.getLatitude();
|
|
||||||
final double eastCornerLong = screenBottomLeft.getLongitude();
|
|
||||||
|
|
||||||
final String query = wikidataQuery
|
|
||||||
.replace("${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat))
|
|
||||||
.replace("${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong))
|
|
||||||
.replace("${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat))
|
|
||||||
.replace("${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong))
|
|
||||||
.replace("${LANG}", language);
|
|
||||||
final HttpUrl.Builder urlBuilder = HttpUrl
|
|
||||||
.parse(sparqlQueryUrl)
|
|
||||||
.newBuilder()
|
|
||||||
.addQueryParameter("query", query)
|
|
||||||
.addQueryParameter("format", "json");
|
|
||||||
|
|
||||||
final Request request = new Request.Builder()
|
|
||||||
.url(urlBuilder.build())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
final Response response = okHttpClient.newCall(request).execute();
|
|
||||||
if (response.body() != null && response.isSuccessful()) {
|
|
||||||
final String json = response.body().string();
|
|
||||||
final NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class);
|
|
||||||
final List<NearbyResultItem> bindings = nearbyResponse.getResults().getBindings();
|
|
||||||
final List<Place> places = new ArrayList<>();
|
|
||||||
for (final NearbyResultItem item : bindings) {
|
|
||||||
final Place placeFromNearbyItem = Place.from(item);
|
|
||||||
if (shouldQueryForMonuments && item.getMonument() != null) {
|
|
||||||
placeFromNearbyItem.setMonument(true);
|
|
||||||
} else {
|
|
||||||
placeFromNearbyItem.setMonument(false);
|
|
||||||
}
|
|
||||||
places.add(placeFromNearbyItem);
|
|
||||||
}
|
|
||||||
return places;
|
|
||||||
}
|
|
||||||
throw new Exception(response.message());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a list of places based on the provided list of places and language.
|
|
||||||
*
|
|
||||||
* @param placeList A list of Place objects for which to fetch information.
|
|
||||||
* @param language The language code to use for the query.
|
|
||||||
* @return A list of Place objects with additional information retrieved from Wikidata, or null
|
|
||||||
* if an error occurs.
|
|
||||||
* @throws IOException If there is an issue with reading the resource file or executing the HTTP
|
|
||||||
* request.
|
|
||||||
*/
|
|
||||||
@Nullable
|
|
||||||
public List<Place> getPlaces(
|
|
||||||
final List<Place> placeList, final String language) throws IOException {
|
|
||||||
final String wikidataQuery = FileUtils.readFromResource("/queries/query_for_item.rq");
|
|
||||||
String qids = "";
|
|
||||||
for (final Place place : placeList) {
|
|
||||||
qids += "\n" + ("wd:" + place.getWikiDataEntityId());
|
|
||||||
}
|
|
||||||
final String query = wikidataQuery
|
|
||||||
.replace("${ENTITY}", qids)
|
|
||||||
.replace("${LANG}", language);
|
|
||||||
final HttpUrl.Builder urlBuilder = HttpUrl
|
|
||||||
.parse(sparqlQueryUrl)
|
|
||||||
.newBuilder()
|
|
||||||
.addQueryParameter("query", query)
|
|
||||||
.addQueryParameter("format", "json");
|
|
||||||
|
|
||||||
final Request request = new Request.Builder()
|
|
||||||
.url(urlBuilder.build())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
try (Response response = okHttpClient.newCall(request).execute()) {
|
|
||||||
if (response.isSuccessful()) {
|
|
||||||
final String json = response.body().string();
|
|
||||||
final NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class);
|
|
||||||
final List<NearbyResultItem> bindings = nearbyResponse.getResults().getBindings();
|
|
||||||
final List<Place> places = new ArrayList<>();
|
|
||||||
for (final NearbyResultItem item : bindings) {
|
|
||||||
final Place placeFromNearbyItem = Place.from(item);
|
|
||||||
places.add(placeFromNearbyItem);
|
|
||||||
}
|
|
||||||
return places;
|
|
||||||
} else {
|
|
||||||
throw new IOException("Unexpected response code: " + response.code());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make API Call to get Places
|
|
||||||
*
|
|
||||||
* @param leftLatLng Left lat long
|
|
||||||
* @param rightLatLng Right lat long
|
|
||||||
* @return
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
@Nullable
|
|
||||||
public String getPlacesAsKML(final LatLng leftLatLng, final LatLng rightLatLng)
|
|
||||||
throws Exception {
|
|
||||||
String kmlString = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n" +
|
|
||||||
"<!--Created by Wikimedia Commons Android app -->\n" +
|
|
||||||
"<kml xmlns=\"http://www.opengis.net/kml/2.2\">\n" +
|
|
||||||
" <Document>";
|
|
||||||
List<PlaceBindings> placeBindings = runQuery(leftLatLng,
|
|
||||||
rightLatLng);
|
|
||||||
if (placeBindings != null) {
|
|
||||||
for (PlaceBindings item : placeBindings) {
|
|
||||||
if (item.getItem() != null && item.getLabel() != null && item.getClas() != null) {
|
|
||||||
String input = item.getLocation().getValue();
|
|
||||||
Pattern pattern = Pattern.compile(
|
|
||||||
"Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)");
|
|
||||||
Matcher matcher = pattern.matcher(input);
|
|
||||||
|
|
||||||
if (matcher.find()) {
|
|
||||||
String longStr = matcher.group(1);
|
|
||||||
String latStr = matcher.group(2);
|
|
||||||
String itemUrl = item.getItem().getValue();
|
|
||||||
String itemName = item.getLabel().getValue().replace("&", "&");
|
|
||||||
String itemLatitude = latStr;
|
|
||||||
String itemLongitude = longStr;
|
|
||||||
String itemClass = item.getClas().getValue();
|
|
||||||
|
|
||||||
String formattedItemName =
|
|
||||||
!itemClass.isEmpty() ? itemName + " (" + itemClass + ")"
|
|
||||||
: itemName;
|
|
||||||
|
|
||||||
String kmlEntry = "\n <Placemark>\n" +
|
|
||||||
" <name>" + formattedItemName + "</name>\n" +
|
|
||||||
" <description>" + itemUrl + "</description>\n" +
|
|
||||||
" <Point>\n" +
|
|
||||||
" <coordinates>" + itemLongitude + ","
|
|
||||||
+ itemLatitude
|
|
||||||
+ "</coordinates>\n" +
|
|
||||||
" </Point>\n" +
|
|
||||||
" </Placemark>";
|
|
||||||
kmlString = kmlString + kmlEntry;
|
|
||||||
} else {
|
|
||||||
Timber.e("No match found");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kmlString = kmlString + "\n </Document>\n" +
|
|
||||||
"</kml>\n";
|
|
||||||
return kmlString;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make API Call to get Places
|
|
||||||
*
|
|
||||||
* @param leftLatLng Left lat long
|
|
||||||
* @param rightLatLng Right lat long
|
|
||||||
* @return
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
@Nullable
|
|
||||||
public String getPlacesAsGPX(final LatLng leftLatLng, final LatLng rightLatLng)
|
|
||||||
throws Exception {
|
|
||||||
String gpxString = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n" +
|
|
||||||
"<gpx\n" +
|
|
||||||
" version=\"1.0\"\n" +
|
|
||||||
" creator=\"Wikimedia Commons Android app\"\n" +
|
|
||||||
" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n" +
|
|
||||||
" xmlns=\"http://www.topografix.com/GPX/1/0\"\n" +
|
|
||||||
" xsi:schemaLocation=\"http://www.topografix.com/GPX/1/0 http://www.topografix.com/GPX/1/0/gpx.xsd\">"
|
|
||||||
+ "\n<bounds minlat=\"$MIN_LATITUDE\" minlon=\"$MIN_LONGITUDE\" maxlat=\"$MAX_LATITUDE\" maxlon=\"$MAX_LONGITUDE\"/>";
|
|
||||||
|
|
||||||
List<PlaceBindings> placeBindings = runQuery(leftLatLng, rightLatLng);
|
|
||||||
if (placeBindings != null) {
|
|
||||||
for (PlaceBindings item : placeBindings) {
|
|
||||||
if (item.getItem() != null && item.getLabel() != null && item.getClas() != null) {
|
|
||||||
String input = item.getLocation().getValue();
|
|
||||||
Pattern pattern = Pattern.compile(
|
|
||||||
"Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)");
|
|
||||||
Matcher matcher = pattern.matcher(input);
|
|
||||||
|
|
||||||
if (matcher.find()) {
|
|
||||||
String longStr = matcher.group(1);
|
|
||||||
String latStr = matcher.group(2);
|
|
||||||
String itemUrl = item.getItem().getValue();
|
|
||||||
String itemName = item.getLabel().getValue().replace("&", "&");
|
|
||||||
String itemLatitude = latStr;
|
|
||||||
String itemLongitude = longStr;
|
|
||||||
String itemClass = item.getClas().getValue();
|
|
||||||
|
|
||||||
String formattedItemName =
|
|
||||||
!itemClass.isEmpty() ? itemName + " (" + itemClass + ")"
|
|
||||||
: itemName;
|
|
||||||
|
|
||||||
String gpxEntry =
|
|
||||||
"\n <wpt lat=\"" + itemLatitude + "\" lon=\"" + itemLongitude
|
|
||||||
+ "\">\n" +
|
|
||||||
" <name>" + itemName + "</name>\n" +
|
|
||||||
" <url>" + itemUrl + "</url>\n" +
|
|
||||||
" </wpt>";
|
|
||||||
gpxString = gpxString + gpxEntry;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
Timber.e("No match found");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
gpxString = gpxString + "\n</gpx>";
|
|
||||||
return gpxString;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<PlaceBindings> runQuery(final LatLng currentLatLng, final LatLng nextLatLng)
|
|
||||||
throws IOException {
|
|
||||||
|
|
||||||
final String wikidataQuery = FileUtils.readFromResource("/queries/places_query.rq");
|
|
||||||
final String query = wikidataQuery
|
|
||||||
.replace("${LONGITUDE}",
|
|
||||||
String.format(Locale.ROOT, "%.2f", currentLatLng.getLongitude()))
|
|
||||||
.replace("${LATITUDE}", String.format(Locale.ROOT, "%.4f", currentLatLng.getLatitude()))
|
|
||||||
.replace("${NEXT_LONGITUDE}",
|
|
||||||
String.format(Locale.ROOT, "%.4f", nextLatLng.getLongitude()))
|
|
||||||
.replace("${NEXT_LATITUDE}",
|
|
||||||
String.format(Locale.ROOT, "%.4f", nextLatLng.getLatitude()));
|
|
||||||
|
|
||||||
final HttpUrl.Builder urlBuilder = HttpUrl
|
|
||||||
.parse(sparqlQueryUrl)
|
|
||||||
.newBuilder()
|
|
||||||
.addQueryParameter("query", query)
|
|
||||||
.addQueryParameter("format", "json");
|
|
||||||
|
|
||||||
final Request request = new Request.Builder()
|
|
||||||
.url(urlBuilder.build())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
final Response response = okHttpClient.newCall(request).execute();
|
|
||||||
if (response.body() != null && response.isSuccessful()) {
|
|
||||||
final String json = response.body().string();
|
|
||||||
final ItemsClass item = gson.fromJson(json, ItemsClass.class);
|
|
||||||
return item.getResults().getBindings();
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make API Call to get Nearby Places Implementation does not expects a custom query
|
|
||||||
*
|
|
||||||
* @param cur Search lat long
|
|
||||||
* @param language Language
|
|
||||||
* @param radius Search Radius
|
|
||||||
* @return
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
@Nullable
|
|
||||||
public List<Place> getNearbyPlaces(final LatLng cur, final String language, final double radius)
|
|
||||||
throws Exception {
|
|
||||||
return getNearbyPlaces(cur, language, radius, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the QIDs of all Wikidata items that are subclasses of the given Wikidata item. Example:
|
|
||||||
* bridge -> suspended bridge, aqueduct, etc
|
|
||||||
*/
|
|
||||||
public Single<List<DepictedItem>> getChildDepictions(String qid, int startPosition,
|
|
||||||
int limit) throws IOException {
|
|
||||||
return depictedItemsFrom(
|
|
||||||
sparqlQuery(qid, startPosition, limit, "/queries/subclasses_query.rq"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the QIDs of all Wikidata items that are subclasses of the given Wikidata item. Example:
|
|
||||||
* bridge -> suspended bridge, aqueduct, etc
|
|
||||||
*/
|
|
||||||
public Single<List<DepictedItem>> getParentDepictions(String qid, int startPosition,
|
|
||||||
int limit) throws IOException {
|
|
||||||
return depictedItemsFrom(sparqlQuery(qid, startPosition, limit,
|
|
||||||
"/queries/parentclasses_query.rq"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Single<List<DepictedItem>> depictedItemsFrom(Request request) {
|
|
||||||
return depictsClient.toDepictions(Single.fromCallable(() -> {
|
|
||||||
try (ResponseBody body = okHttpClient.newCall(request).execute().body()) {
|
|
||||||
return gson.fromJson(body.string(), SparqlResponse.class);
|
|
||||||
}
|
|
||||||
}).doOnError(Timber::e));
|
|
||||||
}
|
|
||||||
|
|
||||||
@NotNull
|
|
||||||
private Request sparqlQuery(String qid, int startPosition, int limit, String fileName)
|
|
||||||
throws IOException {
|
|
||||||
String query = FileUtils.readFromResource(fileName)
|
|
||||||
.replace("${QID}", qid)
|
|
||||||
.replace("${LANG}", "\"" + Locale.getDefault().getLanguage() + "\"")
|
|
||||||
.replace("${LIMIT}", "" + limit)
|
|
||||||
.replace("${OFFSET}", "" + startPosition);
|
|
||||||
HttpUrl.Builder urlBuilder = HttpUrl
|
|
||||||
.parse(sparqlQueryUrl)
|
|
||||||
.newBuilder()
|
|
||||||
.addQueryParameter("query", query)
|
|
||||||
.addQueryParameter("format", "json");
|
|
||||||
return new Request.Builder()
|
|
||||||
.url(urlBuilder.build())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Single<CampaignResponseDTO> getCampaigns() {
|
|
||||||
return Single.fromCallable(() -> {
|
|
||||||
Request request = new Request.Builder().url(campaignsUrl)
|
|
||||||
.build();
|
|
||||||
Response response = okHttpClient.newCall(request).execute();
|
|
||||||
if (response != null && response.body() != null && response.isSuccessful()) {
|
|
||||||
String json = response.body().string();
|
|
||||||
if (json == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return gson.fromJson(json, CampaignResponseDTO.class);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,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("&", "&")
|
||||||
|
val itemLatitude = latStr
|
||||||
|
val itemLongitude = longStr
|
||||||
|
val itemClass = clas.value
|
||||||
|
|
||||||
|
val formattedItemName =
|
||||||
|
if (!itemClass.isEmpty())
|
||||||
|
"$itemName ($itemClass)"
|
||||||
|
else
|
||||||
|
itemName
|
||||||
|
|
||||||
|
val kmlEntry = ("""
|
||||||
|
<Placemark>
|
||||||
|
<name>$formattedItemName</name>
|
||||||
|
<description>$itemUrl</description>
|
||||||
|
<Point>
|
||||||
|
<coordinates>$itemLongitude,$itemLatitude</coordinates>
|
||||||
|
</Point>
|
||||||
|
</Placemark>""")
|
||||||
|
kmlString = kmlString + kmlEntry
|
||||||
|
} else {
|
||||||
|
Timber.e("No match found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kmlString = """$kmlString
|
||||||
|
</Document>
|
||||||
|
</kml>
|
||||||
|
"""
|
||||||
|
return kmlString
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun getPlacesAsGPX(leftLatLng: LatLng, rightLatLng: LatLng): String? {
|
||||||
|
var gpxString = ("""<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<gpx
|
||||||
|
version="1.0"
|
||||||
|
creator="Wikimedia Commons Android app"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns="http://www.topografix.com/GPX/1/0"
|
||||||
|
xsi:schemaLocation="http://www.topografix.com/GPX/1/0 http://www.topografix.com/GPX/1/0/gpx.xsd">
|
||||||
|
<bounds minlat="${"$"}MIN_LATITUDE" minlon="${"$"}MIN_LONGITUDE" maxlat="${"$"}MAX_LATITUDE" maxlon="${"$"}MAX_LONGITUDE"/>""")
|
||||||
|
|
||||||
|
val placeBindings = runQuery(leftLatLng, rightLatLng)
|
||||||
|
if (placeBindings != null) {
|
||||||
|
for ((item1, label, location, clas) in placeBindings) {
|
||||||
|
if (item1 != null && label != null && clas != null) {
|
||||||
|
val input = location.value
|
||||||
|
val pattern = Pattern.compile(
|
||||||
|
"Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)"
|
||||||
|
)
|
||||||
|
val matcher = pattern.matcher(input)
|
||||||
|
|
||||||
|
if (matcher.find()) {
|
||||||
|
val longStr = matcher.group(1)
|
||||||
|
val latStr = matcher.group(2)
|
||||||
|
val itemUrl = item1.value
|
||||||
|
val itemName = label.value.replace("&", "&")
|
||||||
|
val itemLatitude = latStr
|
||||||
|
val itemLongitude = longStr
|
||||||
|
val itemClass = clas.value
|
||||||
|
|
||||||
|
val formattedItemName = if (!itemClass.isEmpty())
|
||||||
|
"$itemName ($itemClass)"
|
||||||
|
else
|
||||||
|
itemName
|
||||||
|
|
||||||
|
val gpxEntry =
|
||||||
|
("""
|
||||||
|
<wpt lat="$itemLatitude" lon="$itemLongitude">
|
||||||
|
<name>$itemName</name>
|
||||||
|
<url>$itemUrl</url>
|
||||||
|
</wpt>""")
|
||||||
|
gpxString = gpxString + gpxEntry
|
||||||
|
} else {
|
||||||
|
Timber.e("No match found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gpxString = "$gpxString\n</gpx>"
|
||||||
|
return gpxString
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun getChildDepictions(
|
||||||
|
qid: String, startPosition: Int,
|
||||||
|
limit: Int
|
||||||
|
): Single<List<DepictedItem>> =
|
||||||
|
depictedItemsFrom(sparqlQuery(qid, startPosition, limit, "/queries/subclasses_query.rq"))
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun getParentDepictions(
|
||||||
|
qid: String, startPosition: Int,
|
||||||
|
limit: Int
|
||||||
|
): Single<List<DepictedItem>> = depictedItemsFrom(
|
||||||
|
sparqlQuery(
|
||||||
|
qid,
|
||||||
|
startPosition,
|
||||||
|
limit,
|
||||||
|
"/queries/parentclasses_query.rq"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getCampaigns(): Single<CampaignResponseDTO> {
|
||||||
|
return Single.fromCallable<CampaignResponseDTO?>({
|
||||||
|
val request: Request = Request.Builder().url(campaignsUrl).build()
|
||||||
|
val response: Response = okHttpClient.newCall(request).execute()
|
||||||
|
if (response.body != null && response.isSuccessful) {
|
||||||
|
val json: String = response.body!!.string()
|
||||||
|
return@fromCallable gson.fromJson<CampaignResponseDTO>(
|
||||||
|
json,
|
||||||
|
CampaignResponseDTO::class.java
|
||||||
|
)
|
||||||
|
}
|
||||||
|
null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun depictedItemsFrom(request: Request): Single<List<DepictedItem>> {
|
||||||
|
return depictsClient.toDepictions(Single.fromCallable({
|
||||||
|
okHttpClient.newCall(request).execute().body.use { body ->
|
||||||
|
return@fromCallable gson.fromJson<SparqlResponse>(
|
||||||
|
body!!.string(),
|
||||||
|
SparqlResponse::class.java
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}).doOnError({ t: Throwable? -> Timber.e(t) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun sparqlQuery(
|
||||||
|
qid: String,
|
||||||
|
startPosition: Int,
|
||||||
|
limit: Int,
|
||||||
|
fileName: String
|
||||||
|
): Request {
|
||||||
|
val query = FileUtils.readFromResource(fileName)
|
||||||
|
.replace("\${QID}", qid)
|
||||||
|
.replace("\${LANG}", "\"" + Locale.getDefault().language + "\"")
|
||||||
|
.replace("\${LIMIT}", "" + limit)
|
||||||
|
.replace("\${OFFSET}", "" + startPosition)
|
||||||
|
val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
|
||||||
|
.newBuilder()
|
||||||
|
.addQueryParameter("query", query)
|
||||||
|
.addQueryParameter("format", "json")
|
||||||
|
return Request.Builder().url(urlBuilder.build()).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun runQuery(currentLatLng: LatLng, nextLatLng: LatLng): List<PlaceBindings>? {
|
||||||
|
val wikidataQuery = FileUtils.readFromResource("/queries/places_query.rq")
|
||||||
|
val query = wikidataQuery
|
||||||
|
.replace("\${LONGITUDE}", String.format(Locale.ROOT, "%.2f", currentLatLng.longitude))
|
||||||
|
.replace("\${LATITUDE}", String.format(Locale.ROOT, "%.4f", currentLatLng.latitude))
|
||||||
|
.replace("\${NEXT_LONGITUDE}", String.format(Locale.ROOT, "%.4f", nextLatLng.longitude))
|
||||||
|
.replace("\${NEXT_LATITUDE}", String.format(Locale.ROOT, "%.4f", nextLatLng.latitude))
|
||||||
|
|
||||||
|
val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
|
||||||
|
.newBuilder()
|
||||||
|
.addQueryParameter("query", query)
|
||||||
|
.addQueryParameter("format", "json")
|
||||||
|
|
||||||
|
val request: Request = Request.Builder().url(urlBuilder.build()).build()
|
||||||
|
|
||||||
|
val response = okHttpClient.newCall(request).execute()
|
||||||
|
if (response.body != null && response.isSuccessful) {
|
||||||
|
val json = response.body!!.string()
|
||||||
|
val item = gson.fromJson(json, ItemsClass::class.java)
|
||||||
|
return item.results.bindings
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,6 @@ import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
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.di.ApplicationlessInjection
|
||||||
import fr.free.nrw.commons.feedback.FeedbackContentCreator
|
import fr.free.nrw.commons.feedback.FeedbackContentCreator
|
||||||
import fr.free.nrw.commons.feedback.FeedbackDialog
|
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.feedback.model.Feedback
|
||||||
import fr.free.nrw.commons.kvstore.BasicKvStore
|
import fr.free.nrw.commons.kvstore.BasicKvStore
|
||||||
import fr.free.nrw.commons.kvstore.JsonKvStore
|
import fr.free.nrw.commons.kvstore.JsonKvStore
|
||||||
|
|
@ -111,10 +111,18 @@ class MoreBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
private fun setUserName() {
|
private fun setUserName() {
|
||||||
val store = BasicKvStore(requireContext(), getUserName())
|
val store = BasicKvStore(requireContext(), getUserName())
|
||||||
val level = store.getString("userAchievementsLevel", "0")
|
val level = store.getString("userAchievementsLevel", "0")
|
||||||
binding?.moreProfile?.text = if (level == "0") {
|
if (level == "0"){
|
||||||
"${getUserName()} (${getString(R.string.see_your_achievements)})"
|
binding?.moreProfile?.text = getString(
|
||||||
|
R.string.profileLevel,
|
||||||
|
getUserName(),
|
||||||
|
getString(R.string.see_your_achievements) // Second argument
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
"${getUserName()} (${getString(R.string.level)} $level)"
|
binding?.moreProfile?.text = getString(
|
||||||
|
R.string.profileLevel,
|
||||||
|
getUserName(),
|
||||||
|
level
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,7 +156,11 @@ class MoreBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
* Creates and shows a dialog asking feedback from users
|
* Creates and shows a dialog asking feedback from users
|
||||||
*/
|
*/
|
||||||
private fun showFeedbackDialog() {
|
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(
|
val single = pageEditClient.createNewSection(
|
||||||
"Commons:Mobile_app/Feedback",
|
"Commons:Mobile_app/Feedback",
|
||||||
feedbackContentCreator.sectionTitle,
|
feedbackContentCreator.getSectionTitle(),
|
||||||
feedbackContentCreator.sectionText,
|
feedbackContentCreator.getSectionText(),
|
||||||
"New feedback on version ${feedback.version} of the app"
|
"New feedback on version ${feedback.version} of the app"
|
||||||
)
|
)
|
||||||
.flatMapSingle { Single.just(it) }
|
.flatMapSingle { Single.just(it) }
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,8 @@ class NotificationClient
|
||||||
return Notification(
|
return Notification(
|
||||||
notificationType = notificationType,
|
notificationType = notificationType,
|
||||||
notificationText = notificationText,
|
notificationText = notificationText,
|
||||||
date = DateUtil.getMonthOnlyDateString(timestamp),
|
date = DateUtil.getMonthOnlyDateString(getTimestamp()),
|
||||||
link = contents?.links?.primary?.url ?: "",
|
link = contents?.links?.getPrimary()?.url ?: "",
|
||||||
iconUrl = "",
|
iconUrl = "",
|
||||||
notificationId = id().toString(),
|
notificationId = id().toString(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import androidx.annotation.NonNull;
|
||||||
import androidx.core.content.FileProvider;
|
import androidx.core.content.FileProvider;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
import androidx.fragment.app.FragmentManager;
|
import androidx.fragment.app.FragmentManager;
|
||||||
import com.google.android.material.tabs.TabLayout;
|
|
||||||
import fr.free.nrw.commons.R;
|
import fr.free.nrw.commons.R;
|
||||||
import fr.free.nrw.commons.Utils;
|
import fr.free.nrw.commons.Utils;
|
||||||
import fr.free.nrw.commons.ViewPagerAdapter;
|
import fr.free.nrw.commons.ViewPagerAdapter;
|
||||||
|
|
|
||||||
|
|
@ -1,492 +0,0 @@
|
||||||
package fr.free.nrw.commons.profile.achievements;
|
|
||||||
|
|
||||||
import android.accounts.Account;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.util.DisplayMetrics;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.Toast;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.appcompat.view.ContextThemeWrapper;
|
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
|
||||||
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
|
|
||||||
import fr.free.nrw.commons.R;
|
|
||||||
import fr.free.nrw.commons.Utils;
|
|
||||||
import fr.free.nrw.commons.auth.SessionManager;
|
|
||||||
import fr.free.nrw.commons.databinding.FragmentAchievementsBinding;
|
|
||||||
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
|
|
||||||
import fr.free.nrw.commons.kvstore.BasicKvStore;
|
|
||||||
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
|
|
||||||
import fr.free.nrw.commons.utils.ConfigUtils;
|
|
||||||
import fr.free.nrw.commons.utils.DialogUtil;
|
|
||||||
import fr.free.nrw.commons.utils.ViewUtil;
|
|
||||||
import fr.free.nrw.commons.profile.ProfileActivity;
|
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
|
||||||
import io.reactivex.disposables.CompositeDisposable;
|
|
||||||
import io.reactivex.schedulers.Schedulers;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Objects;
|
|
||||||
import javax.inject.Inject;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import timber.log.Timber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* fragment for sharing feedback on uploaded activity
|
|
||||||
*/
|
|
||||||
public class AchievementsFragment extends CommonsDaggerSupportFragment {
|
|
||||||
|
|
||||||
private static final double BADGE_IMAGE_WIDTH_RATIO = 0.4;
|
|
||||||
private static final double BADGE_IMAGE_HEIGHT_RATIO = 0.3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Help link URLs
|
|
||||||
*/
|
|
||||||
private static final String IMAGES_UPLOADED_URL = "https://commons.wikimedia.org/wiki/Commons:Project_scope";
|
|
||||||
private static final String IMAGES_REVERT_URL = "https://commons.wikimedia.org/wiki/Commons:Deletion_policy#Reasons_for_deletion";
|
|
||||||
private static final String IMAGES_USED_URL = "https://en.wikipedia.org/wiki/Wikipedia:Manual_of_Style/Images";
|
|
||||||
private static final String IMAGES_NEARBY_PLACES_URL = "https://www.wikidata.org/wiki/Property:P18";
|
|
||||||
private static final String IMAGES_FEATURED_URL = "https://commons.wikimedia.org/wiki/Commons:Featured_pictures";
|
|
||||||
private static final String QUALITY_IMAGE_URL = "https://commons.wikimedia.org/wiki/Commons:Quality_images";
|
|
||||||
private static final String THANKS_URL = "https://www.mediawiki.org/wiki/Extension:Thanks";
|
|
||||||
|
|
||||||
private LevelController.LevelInfo levelInfo;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
SessionManager sessionManager;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
OkHttpJsonApiClient okHttpJsonApiClient;
|
|
||||||
|
|
||||||
private FragmentAchievementsBinding binding;
|
|
||||||
|
|
||||||
private CompositeDisposable compositeDisposable = new CompositeDisposable();
|
|
||||||
|
|
||||||
// To keep track of the number of wiki edits made by a user
|
|
||||||
private int numberOfEdits = 0;
|
|
||||||
|
|
||||||
private String userName;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate(@Nullable final Bundle savedInstanceState) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
if (getArguments() != null) {
|
|
||||||
userName = getArguments().getString(ProfileActivity.KEY_USERNAME);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This method helps in the creation Achievement screen and
|
|
||||||
* dynamically set the size of imageView
|
|
||||||
*
|
|
||||||
* @param savedInstanceState Data bundle
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
|
||||||
binding = FragmentAchievementsBinding.inflate(inflater, container, false);
|
|
||||||
View rootView = binding.getRoot();
|
|
||||||
|
|
||||||
binding.achievementInfo.setOnClickListener(view -> showInfoDialog());
|
|
||||||
binding.imagesUploadInfo.setOnClickListener(view -> showUploadInfo());
|
|
||||||
binding.imagesRevertedInfo.setOnClickListener(view -> showRevertedInfo());
|
|
||||||
binding.imagesUsedByWikiInfo.setOnClickListener(view -> showUsedByWikiInfo());
|
|
||||||
binding.imagesNearbyInfo.setOnClickListener(view -> showImagesViaNearbyInfo());
|
|
||||||
binding.imagesFeaturedInfo.setOnClickListener(view -> showFeaturedImagesInfo());
|
|
||||||
binding.thanksReceivedInfo.setOnClickListener(view -> showThanksReceivedInfo());
|
|
||||||
binding.qualityImagesInfo.setOnClickListener(view -> showQualityImagesInfo());
|
|
||||||
|
|
||||||
// DisplayMetrics used to fetch the size of the screen
|
|
||||||
DisplayMetrics displayMetrics = new DisplayMetrics();
|
|
||||||
getActivity().getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
|
|
||||||
int height = displayMetrics.heightPixels;
|
|
||||||
int width = displayMetrics.widthPixels;
|
|
||||||
|
|
||||||
// Used for the setting the size of imageView at runtime
|
|
||||||
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams)
|
|
||||||
binding.achievementBadgeImage.getLayoutParams();
|
|
||||||
params.height = (int) (height * BADGE_IMAGE_HEIGHT_RATIO);
|
|
||||||
params.width = (int) (width * BADGE_IMAGE_WIDTH_RATIO);
|
|
||||||
binding.achievementBadgeImage.requestLayout();
|
|
||||||
binding.progressBar.setVisibility(View.VISIBLE);
|
|
||||||
|
|
||||||
setHasOptionsMenu(true);
|
|
||||||
|
|
||||||
// Set the initial value of WikiData edits to 0
|
|
||||||
binding.wikidataEdits.setText("0");
|
|
||||||
if(sessionManager.getUserName() == null || sessionManager.getUserName().equals(userName)){
|
|
||||||
binding.tvAchievementsOfUser.setVisibility(View.GONE);
|
|
||||||
}else{
|
|
||||||
binding.tvAchievementsOfUser.setVisibility(View.VISIBLE);
|
|
||||||
binding.tvAchievementsOfUser.setText(getString(R.string.achievements_of_user,userName));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Achievements currently unimplemented in Beta flavor. Skip all API calls.
|
|
||||||
if(ConfigUtils.isBetaFlavour()) {
|
|
||||||
binding.progressBar.setVisibility(View.GONE);
|
|
||||||
binding.imagesUsedByWikiText.setText(R.string.no_image);
|
|
||||||
binding.imagesRevertedText.setText(R.string.no_image_reverted);
|
|
||||||
binding.imagesUploadTextParam.setText(R.string.no_image_uploaded);
|
|
||||||
binding.wikidataEdits.setText("0");
|
|
||||||
binding.imageFeatured.setText("0");
|
|
||||||
binding.qualityImages.setText("0");
|
|
||||||
binding.achievementLevel.setText("0");
|
|
||||||
setMenuVisibility(true);
|
|
||||||
return rootView;
|
|
||||||
}
|
|
||||||
setWikidataEditCount();
|
|
||||||
setAchievements();
|
|
||||||
return rootView;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroyView() {
|
|
||||||
binding = null;
|
|
||||||
super.onDestroyView();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setMenuVisibility(boolean visible) {
|
|
||||||
super.setMenuVisibility(visible);
|
|
||||||
|
|
||||||
// Whenever this fragment is revealed in a menu,
|
|
||||||
// notify Beta users the page data is unavailable
|
|
||||||
if(ConfigUtils.isBetaFlavour() && visible) {
|
|
||||||
Context ctx = null;
|
|
||||||
if(getContext() != null) {
|
|
||||||
ctx = getContext();
|
|
||||||
} else if(getView() != null && getView().getContext() != null) {
|
|
||||||
ctx = getView().getContext();
|
|
||||||
}
|
|
||||||
if(ctx != null) {
|
|
||||||
Toast.makeText(ctx,
|
|
||||||
R.string.achievements_unavailable_beta,
|
|
||||||
Toast.LENGTH_LONG).show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To invoke the AlertDialog on clicking info button
|
|
||||||
*/
|
|
||||||
protected void showInfoDialog(){
|
|
||||||
launchAlert(
|
|
||||||
getResources().getString(R.string.Achievements),
|
|
||||||
getResources().getString(R.string.achievements_info_message));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To call the API to get results in form Single<JSONObject>
|
|
||||||
* which then calls parseJson when results are fetched
|
|
||||||
*/
|
|
||||||
private void setAchievements() {
|
|
||||||
binding.progressBar.setVisibility(View.VISIBLE);
|
|
||||||
if (checkAccount()) {
|
|
||||||
try{
|
|
||||||
|
|
||||||
compositeDisposable.add(okHttpJsonApiClient
|
|
||||||
.getAchievements(Objects.requireNonNull(userName))
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(
|
|
||||||
response -> {
|
|
||||||
if (response != null) {
|
|
||||||
setUploadCount(Achievements.from(response));
|
|
||||||
} else {
|
|
||||||
Timber.d("success");
|
|
||||||
binding.layoutImageReverts.setVisibility(View.INVISIBLE);
|
|
||||||
binding.achievementBadgeImage.setVisibility(View.INVISIBLE);
|
|
||||||
// If the number of edits made by the user are more than 150,000
|
|
||||||
// in some cases such high number of wiki edit counts cause the
|
|
||||||
// achievements calculator to fail in some cases, for more details
|
|
||||||
// refer Issue: #3295
|
|
||||||
if (numberOfEdits <= 150000) {
|
|
||||||
showSnackBarWithRetry(false);
|
|
||||||
} else {
|
|
||||||
showSnackBarWithRetry(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
t -> {
|
|
||||||
Timber.e(t, "Fetching achievements statistics failed");
|
|
||||||
if (numberOfEdits <= 150000) {
|
|
||||||
showSnackBarWithRetry(false);
|
|
||||||
} else {
|
|
||||||
showSnackBarWithRetry(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
catch (Exception e){
|
|
||||||
Timber.d(e+"success");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To call the API to fetch the count of wiki data edits
|
|
||||||
* in the form of JavaRx Single object<JSONobject>
|
|
||||||
*/
|
|
||||||
private void setWikidataEditCount() {
|
|
||||||
if (StringUtils.isBlank(userName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
compositeDisposable.add(okHttpJsonApiClient
|
|
||||||
.getWikidataEdits(userName)
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(edits -> {
|
|
||||||
numberOfEdits = edits;
|
|
||||||
binding.wikidataEdits.setText(String.valueOf(edits));
|
|
||||||
}, e -> {
|
|
||||||
Timber.e("Error:" + e);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows a snack bar which has an action button which on click dismisses the snackbar and invokes the
|
|
||||||
* listener passed
|
|
||||||
* @param tooManyAchievements if this value is true it means that the number of achievements of the
|
|
||||||
* user are so high that it wrecks havoc with the Achievements calculator due to which request may time
|
|
||||||
* out. Well this is the Ultimate Achievement
|
|
||||||
*/
|
|
||||||
private void showSnackBarWithRetry(boolean tooManyAchievements) {
|
|
||||||
if (tooManyAchievements) {
|
|
||||||
binding.progressBar.setVisibility(View.GONE);
|
|
||||||
ViewUtil.showDismissibleSnackBar(getActivity().findViewById(android.R.id.content),
|
|
||||||
R.string.achievements_fetch_failed_ultimate_achievement, R.string.retry, view -> setAchievements());
|
|
||||||
} else {
|
|
||||||
binding.progressBar.setVisibility(View.GONE);
|
|
||||||
ViewUtil.showDismissibleSnackBar(getActivity().findViewById(android.R.id.content),
|
|
||||||
R.string.achievements_fetch_failed, R.string.retry, view -> setAchievements());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows a generic error toast when error occurs while loading achievements or uploads
|
|
||||||
*/
|
|
||||||
private void onError() {
|
|
||||||
ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.error_occurred));
|
|
||||||
binding.progressBar.setVisibility(View.GONE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* used to the count of images uploaded by user
|
|
||||||
*/
|
|
||||||
private void setUploadCount(Achievements achievements) {
|
|
||||||
if (checkAccount()) {
|
|
||||||
compositeDisposable.add(okHttpJsonApiClient
|
|
||||||
.getUploadCount(Objects.requireNonNull(userName))
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(
|
|
||||||
uploadCount -> setAchievementsUploadCount(achievements, uploadCount),
|
|
||||||
t -> {
|
|
||||||
Timber.e(t, "Fetching upload count failed");
|
|
||||||
onError();
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* used to set achievements upload count and call hideProgressbar
|
|
||||||
* @param uploadCount
|
|
||||||
*/
|
|
||||||
private void setAchievementsUploadCount(Achievements achievements, int uploadCount) {
|
|
||||||
// Create a new instance of Achievements with updated imagesUploaded
|
|
||||||
Achievements updatedAchievements = new Achievements(
|
|
||||||
achievements.getUniqueUsedImages(),
|
|
||||||
achievements.getArticlesUsingImages(),
|
|
||||||
achievements.getThanksReceived(),
|
|
||||||
achievements.getFeaturedImages(),
|
|
||||||
achievements.getQualityImages(),
|
|
||||||
uploadCount, // Update imagesUploaded with new value
|
|
||||||
achievements.getRevertCount()
|
|
||||||
);
|
|
||||||
|
|
||||||
hideProgressBar(updatedAchievements);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* used to the uploaded images progressbar
|
|
||||||
* @param uploadCount
|
|
||||||
*/
|
|
||||||
private void setUploadProgress(int uploadCount){
|
|
||||||
if (uploadCount==0){
|
|
||||||
setZeroAchievements();
|
|
||||||
}else {
|
|
||||||
binding.imagesUploadedProgressbar.setVisibility(View.VISIBLE);
|
|
||||||
binding.imagesUploadedProgressbar.setProgress
|
|
||||||
(100*uploadCount/levelInfo.getMaxUploadCount());
|
|
||||||
binding.tvUploadedImages.setText
|
|
||||||
(uploadCount + "/" + levelInfo.getMaxUploadCount());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setZeroAchievements() {
|
|
||||||
String message = !Objects.equals(sessionManager.getUserName(), userName) ?
|
|
||||||
getString(R.string.no_achievements_yet, userName) :
|
|
||||||
getString(R.string.you_have_no_achievements_yet);
|
|
||||||
DialogUtil.showAlertDialog(getActivity(),
|
|
||||||
null,
|
|
||||||
message,
|
|
||||||
getString(R.string.ok),
|
|
||||||
() -> {},
|
|
||||||
true);
|
|
||||||
// binding.imagesUploadedProgressbar.setVisibility(View.INVISIBLE);
|
|
||||||
// binding.imageRevertsProgressbar.setVisibility(View.INVISIBLE);
|
|
||||||
// binding.imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE);
|
|
||||||
binding.achievementBadgeImage.setVisibility(View.INVISIBLE);
|
|
||||||
binding.imagesUsedByWikiText.setText(R.string.no_image);
|
|
||||||
binding.imagesRevertedText.setText(R.string.no_image_reverted);
|
|
||||||
binding.imagesUploadTextParam.setText(R.string.no_image_uploaded);
|
|
||||||
binding.achievementBadgeImage.setVisibility(View.INVISIBLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* used to set the non revert image percentage
|
|
||||||
* @param notRevertPercentage
|
|
||||||
*/
|
|
||||||
private void setImageRevertPercentage(int notRevertPercentage){
|
|
||||||
binding.imageRevertsProgressbar.setVisibility(View.VISIBLE);
|
|
||||||
binding.imageRevertsProgressbar.setProgress(notRevertPercentage);
|
|
||||||
final String revertPercentage = Integer.toString(notRevertPercentage);
|
|
||||||
binding.tvRevertedImages.setText(revertPercentage + "%");
|
|
||||||
binding.imagesRevertLimitText.setText(getResources().getString(R.string.achievements_revert_limit_message)+ levelInfo.getMinNonRevertPercentage() + "%");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used the inflate the fetched statistics of the images uploaded by user
|
|
||||||
* and assign badge and level. Also stores the achievements level of the user in BasicKvStore to display in menu
|
|
||||||
* @param achievements
|
|
||||||
*/
|
|
||||||
private void inflateAchievements(Achievements achievements) {
|
|
||||||
// binding.imagesUsedByWikiProgressBar.setVisibility(View.VISIBLE);
|
|
||||||
binding.thanksReceived.setText(String.valueOf(achievements.getThanksReceived()));
|
|
||||||
binding.imagesUsedByWikiProgressBar.setProgress
|
|
||||||
(100 * achievements.getUniqueUsedImages() / levelInfo.getMaxUniqueImages());
|
|
||||||
binding.tvWikiPb.setText(achievements.getUniqueUsedImages() + "/"
|
|
||||||
+ levelInfo.getMaxUniqueImages());
|
|
||||||
binding.imageFeatured.setText(String.valueOf(achievements.getFeaturedImages()));
|
|
||||||
binding.qualityImages.setText(String.valueOf(achievements.getQualityImages()));
|
|
||||||
String levelUpInfoString = getString(R.string.level).toUpperCase(Locale.ROOT);
|
|
||||||
levelUpInfoString += " " + levelInfo.getLevelNumber();
|
|
||||||
binding.achievementLevel.setText(levelUpInfoString);
|
|
||||||
binding.achievementBadgeImage.setImageDrawable(VectorDrawableCompat.create(getResources(), R.drawable.badge,
|
|
||||||
new ContextThemeWrapper(getActivity(), levelInfo.getLevelStyle()).getTheme()));
|
|
||||||
binding.achievementBadgeText.setText(Integer.toString(levelInfo.getLevelNumber()));
|
|
||||||
BasicKvStore store = new BasicKvStore(this.getContext(), userName);
|
|
||||||
store.putString("userAchievementsLevel", Integer.toString(levelInfo.getLevelNumber()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* to hide progressbar
|
|
||||||
*/
|
|
||||||
private void hideProgressBar(Achievements achievements) {
|
|
||||||
if (binding.progressBar != null) {
|
|
||||||
levelInfo = LevelController.LevelInfo.from(achievements.getImagesUploaded(),
|
|
||||||
achievements.getUniqueUsedImages(),
|
|
||||||
achievements.getNotRevertPercentage());
|
|
||||||
inflateAchievements(achievements);
|
|
||||||
setUploadProgress(achievements.getImagesUploaded());
|
|
||||||
setImageRevertPercentage(achievements.getNotRevertPercentage());
|
|
||||||
binding.progressBar.setVisibility(View.GONE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void showUploadInfo(){
|
|
||||||
launchAlertWithHelpLink(
|
|
||||||
getResources().getString(R.string.images_uploaded),
|
|
||||||
getResources().getString(R.string.images_uploaded_explanation),
|
|
||||||
IMAGES_UPLOADED_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void showRevertedInfo(){
|
|
||||||
launchAlertWithHelpLink(
|
|
||||||
getResources().getString(R.string.image_reverts),
|
|
||||||
getResources().getString(R.string.images_reverted_explanation),
|
|
||||||
IMAGES_REVERT_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void showUsedByWikiInfo(){
|
|
||||||
launchAlertWithHelpLink(
|
|
||||||
getResources().getString(R.string.images_used_by_wiki),
|
|
||||||
getResources().getString(R.string.images_used_explanation),
|
|
||||||
IMAGES_USED_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void showImagesViaNearbyInfo(){
|
|
||||||
launchAlertWithHelpLink(
|
|
||||||
getResources().getString(R.string.statistics_wikidata_edits),
|
|
||||||
getResources().getString(R.string.images_via_nearby_explanation),
|
|
||||||
IMAGES_NEARBY_PLACES_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void showFeaturedImagesInfo(){
|
|
||||||
launchAlertWithHelpLink(
|
|
||||||
getResources().getString(R.string.statistics_featured),
|
|
||||||
getResources().getString(R.string.images_featured_explanation),
|
|
||||||
IMAGES_FEATURED_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void showThanksReceivedInfo(){
|
|
||||||
launchAlertWithHelpLink(
|
|
||||||
getResources().getString(R.string.statistics_thanks),
|
|
||||||
getResources().getString(R.string.thanks_received_explanation),
|
|
||||||
THANKS_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void showQualityImagesInfo() {
|
|
||||||
launchAlertWithHelpLink(
|
|
||||||
getResources().getString(R.string.statistics_quality),
|
|
||||||
getResources().getString(R.string.quality_images_info),
|
|
||||||
QUALITY_IMAGE_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* takes title and message as input to display alerts
|
|
||||||
* @param title
|
|
||||||
* @param message
|
|
||||||
*/
|
|
||||||
private void launchAlert(String title, String message){
|
|
||||||
DialogUtil.showAlertDialog(getActivity(),
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
getString(R.string.ok),
|
|
||||||
() -> {},
|
|
||||||
true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch Alert with a READ MORE button and clicking it open a custom webpage
|
|
||||||
*/
|
|
||||||
private void launchAlertWithHelpLink(String title, String message, String helpLinkUrl) {
|
|
||||||
DialogUtil.showAlertDialog(getActivity(),
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
getString(R.string.ok),
|
|
||||||
getString(R.string.read_help_link),
|
|
||||||
() -> {},
|
|
||||||
() -> Utils.handleWebUrl(requireContext(), Uri.parse(helpLinkUrl)),
|
|
||||||
null,
|
|
||||||
true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* check to ensure that user is logged in
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
private boolean checkAccount(){
|
|
||||||
Account currentAccount = sessionManager.getCurrentAccount();
|
|
||||||
if (currentAccount == null) {
|
|
||||||
Timber.d("Current account is null");
|
|
||||||
ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.user_not_logged_in));
|
|
||||||
sessionManager.forceLogin(getActivity());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,566 @@
|
||||||
|
package fr.free.nrw.commons.profile.achievements
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.DisplayMetrics
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.view.ContextThemeWrapper
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
|
||||||
|
import com.google.android.material.badge.BadgeDrawable
|
||||||
|
import com.google.android.material.badge.BadgeUtils
|
||||||
|
import com.google.android.material.badge.ExperimentalBadgeUtils
|
||||||
|
import fr.free.nrw.commons.R
|
||||||
|
import fr.free.nrw.commons.Utils
|
||||||
|
import fr.free.nrw.commons.auth.SessionManager
|
||||||
|
import fr.free.nrw.commons.databinding.FragmentAchievementsBinding
|
||||||
|
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
|
||||||
|
import fr.free.nrw.commons.kvstore.BasicKvStore
|
||||||
|
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient
|
||||||
|
import fr.free.nrw.commons.profile.ProfileActivity
|
||||||
|
import fr.free.nrw.commons.profile.achievements.LevelController.LevelInfo.Companion.from
|
||||||
|
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
|
||||||
|
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
|
||||||
|
import fr.free.nrw.commons.utils.ViewUtil.showDismissibleSnackBar
|
||||||
|
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.apache.commons.lang3.StringUtils
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.Objects
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class AchievementsFragment : CommonsDaggerSupportFragment(){
|
||||||
|
private lateinit var levelInfo: LevelController.LevelInfo
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var sessionManager: SessionManager
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var okHttpJsonApiClient: OkHttpJsonApiClient
|
||||||
|
|
||||||
|
private var _binding: FragmentAchievementsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
// To keep track of the number of wiki edits made by a user
|
||||||
|
private var numberOfEdits: Int = 0
|
||||||
|
|
||||||
|
private var userName: String? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
arguments?.let {
|
||||||
|
userName = it.getString(ProfileActivity.KEY_USERNAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentAchievementsBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
binding.achievementInfo.setOnClickListener { showInfoDialog() }
|
||||||
|
binding.imagesUploadInfoIcon.setOnClickListener { showUploadInfo() }
|
||||||
|
binding.imagesRevertedInfoIcon.setOnClickListener { showRevertedInfo() }
|
||||||
|
binding.imagesUsedByWikiInfoIcon.setOnClickListener { showUsedByWikiInfo() }
|
||||||
|
binding.wikidataEditsIcon.setOnClickListener { showImagesViaNearbyInfo() }
|
||||||
|
binding.featuredImageIcon.setOnClickListener { showFeaturedImagesInfo() }
|
||||||
|
binding.thanksImageIcon.setOnClickListener { showThanksReceivedInfo() }
|
||||||
|
binding.qualityImageIcon.setOnClickListener { showQualityImagesInfo() }
|
||||||
|
|
||||||
|
// DisplayMetrics used to fetch the size of the screen
|
||||||
|
val displayMetrics = DisplayMetrics()
|
||||||
|
requireActivity().windowManager.defaultDisplay.getMetrics(displayMetrics)
|
||||||
|
val height = displayMetrics.heightPixels
|
||||||
|
val width = displayMetrics.widthPixels
|
||||||
|
|
||||||
|
// Used for the setting the size of imageView at runtime
|
||||||
|
// TODO REMOVE
|
||||||
|
val params = binding.achievementBadgeImage.layoutParams as ConstraintLayout.LayoutParams
|
||||||
|
params.height = (height * BADGE_IMAGE_HEIGHT_RATIO).toInt()
|
||||||
|
params.width = (width * BADGE_IMAGE_WIDTH_RATIO).toInt()
|
||||||
|
binding.achievementBadgeImage.requestLayout()
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
setHasOptionsMenu(true)
|
||||||
|
if (sessionManager.userName == null || sessionManager.userName == userName) {
|
||||||
|
binding.tvAchievementsOfUser.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
binding.tvAchievementsOfUser.visibility = View.VISIBLE
|
||||||
|
binding.tvAchievementsOfUser.text = getString(R.string.achievements_of_user, userName)
|
||||||
|
}
|
||||||
|
if (isBetaFlavour) {
|
||||||
|
binding.layout.visibility = View.GONE
|
||||||
|
setMenuVisibility(true)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
setWikidataEditCount()
|
||||||
|
setAchievements()
|
||||||
|
return binding.root
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun setMenuVisibility(visible: Boolean) {
|
||||||
|
super.setMenuVisibility(visible)
|
||||||
|
|
||||||
|
// Whenever this fragment is revealed in a menu,
|
||||||
|
// notify Beta users the page data is unavailable
|
||||||
|
if (isBetaFlavour && visible) {
|
||||||
|
val ctx = context ?: view?.context
|
||||||
|
ctx?.let {
|
||||||
|
Toast.makeText(it, R.string.achievements_unavailable_beta, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To invoke the AlertDialog on clicking info button
|
||||||
|
*/
|
||||||
|
fun showInfoDialog() {
|
||||||
|
launchAlert(
|
||||||
|
resources.getString(R.string.Achievements),
|
||||||
|
resources.getString(R.string.achievements_info_message)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To call the API to get results in form Single<JSONObject>
|
||||||
|
* which then calls parseJson when results are fetched
|
||||||
|
*/
|
||||||
|
|
||||||
|
private fun setAchievements() {
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
if (checkAccount()) {
|
||||||
|
try {
|
||||||
|
compositeDisposable.add(
|
||||||
|
okHttpJsonApiClient
|
||||||
|
.getAchievements(userName ?: return)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ response ->
|
||||||
|
if (response != null) {
|
||||||
|
setUploadCount(Achievements.from(response))
|
||||||
|
} else {
|
||||||
|
Timber.d("Success")
|
||||||
|
// TODO Create a Method to Hide all the Statistics
|
||||||
|
// binding.layoutImageReverts.visibility = View.INVISIBLE
|
||||||
|
// binding.achievementBadgeImage.visibility = View.INVISIBLE
|
||||||
|
// If the number of edits made by the user are more than 150,000
|
||||||
|
// in some cases such high number of wiki edit counts cause the
|
||||||
|
// achievements calculator to fail in some cases, for more details
|
||||||
|
// refer Issue: #3295
|
||||||
|
if (numberOfEdits <= 150_000) {
|
||||||
|
showSnackBarWithRetry(false)
|
||||||
|
} else {
|
||||||
|
showSnackBarWithRetry(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ throwable ->
|
||||||
|
Timber.e(throwable, "Fetching achievements statistics failed")
|
||||||
|
if (numberOfEdits <= 150_000) {
|
||||||
|
showSnackBarWithRetry(false)
|
||||||
|
} else {
|
||||||
|
showSnackBarWithRetry(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.d("Exception: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To call the API to fetch the count of wiki data edits
|
||||||
|
* in the form of JavaRx Single object<JSONobject>
|
||||||
|
</JSONobject> */
|
||||||
|
|
||||||
|
private fun setWikidataEditCount() {
|
||||||
|
if (StringUtils.isBlank(userName)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
compositeDisposable.add(
|
||||||
|
okHttpJsonApiClient
|
||||||
|
.getWikidataEdits(userName)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe({ edits: Int ->
|
||||||
|
numberOfEdits = edits
|
||||||
|
showBadgesWithCount(view = binding.wikidataEditsIcon, count = edits)
|
||||||
|
}, { e: Throwable ->
|
||||||
|
Timber.e("Error:$e")
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a snack bar which has an action button which on click dismisses the snackbar and invokes the
|
||||||
|
* listener passed
|
||||||
|
* @param tooManyAchievements if this value is true it means that the number of achievements of the
|
||||||
|
* user are so high that it wrecks havoc with the Achievements calculator due to which request may time
|
||||||
|
* out. Well this is the Ultimate Achievement
|
||||||
|
*/
|
||||||
|
private fun showSnackBarWithRetry(tooManyAchievements: Boolean) {
|
||||||
|
if (tooManyAchievements) {
|
||||||
|
if (view == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
showDismissibleSnackBar(
|
||||||
|
requireView().findViewById(android.R.id.content),
|
||||||
|
R.string.achievements_fetch_failed_ultimate_achievement, R.string.retry
|
||||||
|
) { setAchievements() }
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
if (view == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
showDismissibleSnackBar(
|
||||||
|
requireView().findViewById(android.R.id.content),
|
||||||
|
R.string.achievements_fetch_failed, R.string.retry
|
||||||
|
) { setAchievements() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a generic error toast when error occurs while loading achievements or uploads
|
||||||
|
*/
|
||||||
|
private fun onError() {
|
||||||
|
showLongToast(requireActivity(), resources.getString(R.string.error_occurred))
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* used to the count of images uploaded by user
|
||||||
|
*/
|
||||||
|
|
||||||
|
private fun setUploadCount(achievements: Achievements) {
|
||||||
|
if (checkAccount()) {
|
||||||
|
compositeDisposable.add(okHttpJsonApiClient
|
||||||
|
.getUploadCount(Objects.requireNonNull<String>(userName))
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ uploadCount: Int? ->
|
||||||
|
setAchievementsUploadCount(
|
||||||
|
achievements,
|
||||||
|
uploadCount ?:0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ t: Throwable? ->
|
||||||
|
Timber.e(t, "Fetching upload count failed")
|
||||||
|
onError()
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* used to set achievements upload count and call hideProgressbar
|
||||||
|
* @param uploadCount
|
||||||
|
*/
|
||||||
|
private fun setAchievementsUploadCount(achievements: Achievements, uploadCount: Int) {
|
||||||
|
// Create a new instance of Achievements with updated imagesUploaded
|
||||||
|
val updatedAchievements = Achievements(
|
||||||
|
achievements.uniqueUsedImages,
|
||||||
|
achievements.articlesUsingImages,
|
||||||
|
achievements.thanksReceived,
|
||||||
|
achievements.featuredImages,
|
||||||
|
achievements.qualityImages,
|
||||||
|
uploadCount, // Update imagesUploaded with new value
|
||||||
|
achievements.revertCount
|
||||||
|
)
|
||||||
|
|
||||||
|
hideProgressBar(updatedAchievements)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* used to the uploaded images progressbar
|
||||||
|
* @param uploadCount
|
||||||
|
*/
|
||||||
|
private fun setUploadProgress(uploadCount: Int) {
|
||||||
|
if (uploadCount == 0) {
|
||||||
|
setZeroAchievements()
|
||||||
|
} else {
|
||||||
|
binding.imagesUploadedProgressbar.visibility = View.VISIBLE
|
||||||
|
binding.imagesUploadedProgressbar.progress =
|
||||||
|
100 * uploadCount / levelInfo.maxUploadCount
|
||||||
|
binding.imageUploadedTVCount.text = uploadCount.toString() + "/" + levelInfo.maxUploadCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setZeroAchievements() {
|
||||||
|
val message = if (sessionManager.userName != userName) {
|
||||||
|
getString(R.string.no_achievements_yet, userName )
|
||||||
|
} else {
|
||||||
|
getString(R.string.you_have_no_achievements_yet)
|
||||||
|
}
|
||||||
|
showAlertDialog(
|
||||||
|
requireActivity(),
|
||||||
|
null,
|
||||||
|
message,
|
||||||
|
getString(R.string.ok),
|
||||||
|
{},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
// binding.imagesUploadedProgressbar.setVisibility(View.INVISIBLE);
|
||||||
|
// binding.imageRevertsProgressbar.setVisibility(View.INVISIBLE);
|
||||||
|
// binding.imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE);
|
||||||
|
//binding.achievementBadgeImage.visibility = View.INVISIBLE // TODO
|
||||||
|
binding.imagesUsedCount.setText(R.string.no_image)
|
||||||
|
binding.imagesRevertedText.setText(R.string.no_image_reverted)
|
||||||
|
binding.imagesUploadTextParam.setText(R.string.no_image_uploaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* used to set the non revert image percentage
|
||||||
|
* @param notRevertPercentage
|
||||||
|
*/
|
||||||
|
private fun setImageRevertPercentage(notRevertPercentage: Int) {
|
||||||
|
binding.imageRevertsProgressbar.visibility = View.VISIBLE
|
||||||
|
binding.imageRevertsProgressbar.progress = notRevertPercentage
|
||||||
|
val revertPercentage = notRevertPercentage.toString()
|
||||||
|
binding.imageRevertTVCount.text = "$revertPercentage%"
|
||||||
|
binding.imagesRevertLimitText.text =
|
||||||
|
resources.getString(R.string.achievements_revert_limit_message) + levelInfo.minNonRevertPercentage + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used the inflate the fetched statistics of the images uploaded by user
|
||||||
|
* and assign badge and level. Also stores the achievements level of the user in BasicKvStore to display in menu
|
||||||
|
* @param achievements
|
||||||
|
*/
|
||||||
|
private fun inflateAchievements(achievements: Achievements) {
|
||||||
|
|
||||||
|
// Thanks Received Badge
|
||||||
|
showBadgesWithCount(view = binding.thanksImageIcon, count = achievements.thanksReceived)
|
||||||
|
|
||||||
|
// Featured Images Badge
|
||||||
|
showBadgesWithCount(view = binding.featuredImageIcon, count = achievements.featuredImages)
|
||||||
|
|
||||||
|
// Quality Images Badge
|
||||||
|
showBadgesWithCount(view = binding.qualityImageIcon, count = achievements.qualityImages)
|
||||||
|
|
||||||
|
binding.imagesUsedByWikiProgressBar.progress =
|
||||||
|
100 * achievements.uniqueUsedImages / levelInfo.maxUniqueImages
|
||||||
|
binding.imagesUsedCount.text = (achievements.uniqueUsedImages.toString() + "/"
|
||||||
|
+ levelInfo.maxUniqueImages)
|
||||||
|
|
||||||
|
binding.achievementLevel.text = getString(R.string.level,levelInfo.levelNumber)
|
||||||
|
binding.achievementBadgeImage.setImageDrawable(
|
||||||
|
VectorDrawableCompat.create(
|
||||||
|
resources, R.drawable.badge,
|
||||||
|
ContextThemeWrapper(activity, levelInfo.levelStyle).theme
|
||||||
|
)
|
||||||
|
)
|
||||||
|
binding.achievementBadgeText.text = levelInfo.levelNumber.toString()
|
||||||
|
val store = BasicKvStore(requireContext(), userName)
|
||||||
|
store.putString("userAchievementsLevel", levelInfo.levelNumber.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is used to show badge on any view (button, imageView, etc)
|
||||||
|
* @param view The View on which the badge will be displayed eg (button, imageView, etc)
|
||||||
|
* @param count The number to be displayed inside the badge.
|
||||||
|
* @param backgroundColor The badge background color. Default is R.attr.colorPrimary
|
||||||
|
* @param badgeTextColor The badge text color. Default is R.attr.colorPrimary
|
||||||
|
* @param badgeGravity The position of the badge [TOP_END,TOP_START,BOTTOM_END,BOTTOM_START]. Default is TOP_END
|
||||||
|
* @return if the number is 0, then it will not create badge for it and hide the view
|
||||||
|
* @see https://developer.android.com/reference/com/google/android/material/badge/BadgeDrawable
|
||||||
|
*/
|
||||||
|
|
||||||
|
private fun showBadgesWithCount(
|
||||||
|
view: View,
|
||||||
|
count: Int,
|
||||||
|
backgroundColor: Int = R.attr.colorPrimary,
|
||||||
|
badgeTextColor: Int = R.attr.textEnabled,
|
||||||
|
badgeGravity: Int = BadgeDrawable.TOP_END
|
||||||
|
) {
|
||||||
|
//https://stackoverflow.com/a/67742035
|
||||||
|
if (count == 0) {
|
||||||
|
view.visibility = View.GONE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view.viewTreeObserver.addOnGlobalLayoutListener(object :
|
||||||
|
ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
/**
|
||||||
|
* Callback method to be invoked when the global layout state or the visibility of views
|
||||||
|
* within the view tree changes
|
||||||
|
*/
|
||||||
|
@ExperimentalBadgeUtils
|
||||||
|
override fun onGlobalLayout() {
|
||||||
|
view.visibility = View.VISIBLE
|
||||||
|
val badgeDrawable = BadgeDrawable.create(requireActivity())
|
||||||
|
badgeDrawable.number = count
|
||||||
|
badgeDrawable.badgeGravity = badgeGravity
|
||||||
|
badgeDrawable.badgeTextColor = badgeTextColor
|
||||||
|
badgeDrawable.backgroundColor = backgroundColor
|
||||||
|
BadgeUtils.attachBadgeDrawable(badgeDrawable, view)
|
||||||
|
view.getViewTreeObserver().removeOnGlobalLayoutListener(this)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* to hide progressbar
|
||||||
|
*/
|
||||||
|
private fun hideProgressBar(achievements: Achievements) {
|
||||||
|
if (binding.progressBar != null) {
|
||||||
|
levelInfo = from(
|
||||||
|
achievements.imagesUploaded,
|
||||||
|
achievements.uniqueUsedImages,
|
||||||
|
achievements.notRevertPercentage
|
||||||
|
)
|
||||||
|
inflateAchievements(achievements)
|
||||||
|
setUploadProgress(achievements.imagesUploaded)
|
||||||
|
setImageRevertPercentage(achievements.notRevertPercentage)
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showUploadInfo() {
|
||||||
|
launchAlertWithHelpLink(
|
||||||
|
resources.getString(R.string.images_uploaded),
|
||||||
|
resources.getString(R.string.images_uploaded_explanation),
|
||||||
|
IMAGES_UPLOADED_URL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showRevertedInfo() {
|
||||||
|
launchAlertWithHelpLink(
|
||||||
|
resources.getString(R.string.image_reverts),
|
||||||
|
resources.getString(R.string.images_reverted_explanation),
|
||||||
|
IMAGES_REVERT_URL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showUsedByWikiInfo() {
|
||||||
|
launchAlertWithHelpLink(
|
||||||
|
resources.getString(R.string.images_used_by_wiki),
|
||||||
|
resources.getString(R.string.images_used_explanation),
|
||||||
|
IMAGES_USED_URL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showImagesViaNearbyInfo() {
|
||||||
|
launchAlertWithHelpLink(
|
||||||
|
resources.getString(R.string.statistics_wikidata_edits),
|
||||||
|
resources.getString(R.string.images_via_nearby_explanation),
|
||||||
|
IMAGES_NEARBY_PLACES_URL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showFeaturedImagesInfo() {
|
||||||
|
launchAlertWithHelpLink(
|
||||||
|
resources.getString(R.string.statistics_featured),
|
||||||
|
resources.getString(R.string.images_featured_explanation),
|
||||||
|
IMAGES_FEATURED_URL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showThanksReceivedInfo() {
|
||||||
|
launchAlertWithHelpLink(
|
||||||
|
resources.getString(R.string.statistics_thanks),
|
||||||
|
resources.getString(R.string.thanks_received_explanation),
|
||||||
|
THANKS_URL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showQualityImagesInfo() {
|
||||||
|
launchAlertWithHelpLink(
|
||||||
|
resources.getString(R.string.statistics_quality),
|
||||||
|
resources.getString(R.string.quality_images_info),
|
||||||
|
QUALITY_IMAGE_URL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* takes title and message as input to display alerts
|
||||||
|
* @param title
|
||||||
|
* @param message
|
||||||
|
*/
|
||||||
|
private fun launchAlert(title: String, message: String) {
|
||||||
|
showAlertDialog(
|
||||||
|
requireActivity(),
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
getString(R.string.ok),
|
||||||
|
{},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch Alert with a READ MORE button and clicking it open a custom webpage
|
||||||
|
*/
|
||||||
|
private fun launchAlertWithHelpLink(title: String, message: String, helpLinkUrl: String) {
|
||||||
|
showAlertDialog(
|
||||||
|
requireActivity(),
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
getString(R.string.ok),
|
||||||
|
getString(R.string.read_help_link),
|
||||||
|
{},
|
||||||
|
{ Utils.handleWebUrl(requireContext(), Uri.parse(helpLinkUrl)) },
|
||||||
|
null,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* check to ensure that user is logged in
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
private fun checkAccount(): Boolean {
|
||||||
|
val currentAccount = sessionManager.currentAccount
|
||||||
|
if (currentAccount == null) {
|
||||||
|
Timber.d("Current account is null")
|
||||||
|
showLongToast(requireActivity(), resources.getString(R.string.user_not_logged_in))
|
||||||
|
sessionManager.forceLogin(activity)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
companion object{
|
||||||
|
private const val BADGE_IMAGE_WIDTH_RATIO = 0.4
|
||||||
|
private const val BADGE_IMAGE_HEIGHT_RATIO = 0.3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Help link URLs
|
||||||
|
*/
|
||||||
|
private const val IMAGES_UPLOADED_URL = "https://commons.wikimedia.org/wiki/Commons:Project_scope"
|
||||||
|
private const val IMAGES_REVERT_URL = "https://commons.wikimedia.org/wiki/Commons:Deletion_policy#Reasons_for_deletion"
|
||||||
|
private const val IMAGES_USED_URL = "https://en.wikipedia.org/wiki/Wikipedia:Manual_of_Style/Images"
|
||||||
|
private const val IMAGES_NEARBY_PLACES_URL = "https://www.wikidata.org/wiki/Property:P18"
|
||||||
|
private const val IMAGES_FEATURED_URL = "https://commons.wikimedia.org/wiki/Commons:Featured_pictures"
|
||||||
|
private const val QUALITY_IMAGE_URL = "https://commons.wikimedia.org/wiki/Commons:Quality_images"
|
||||||
|
private const val THANKS_URL = "https://www.mediawiki.org/wiki/Extension:Thanks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -201,7 +201,7 @@ class ReviewActivity : BaseActivity() {
|
||||||
val caption = getString(
|
val caption = getString(
|
||||||
R.string.review_is_uploaded_by,
|
R.string.review_is_uploaded_by,
|
||||||
fileName,
|
fileName,
|
||||||
revision.user
|
revision.user()
|
||||||
)
|
)
|
||||||
binding.tvImageCaption.text = caption
|
binding.tvImageCaption.text = caption
|
||||||
binding.pbReviewImage.visibility = View.GONE
|
binding.pbReviewImage.visibility = View.GONE
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
|
||||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage
|
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage
|
||||||
|
|
||||||
import java.util.ArrayList
|
import java.util.ArrayList
|
||||||
import java.util.concurrent.Callable
|
|
||||||
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Named
|
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.di.ApplicationlessInjection
|
||||||
import fr.free.nrw.commons.utils.ViewUtil
|
import fr.free.nrw.commons.utils.ViewUtil
|
||||||
import io.reactivex.Observable
|
import io.reactivex.Observable
|
||||||
import io.reactivex.ObservableSource
|
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.schedulers.Schedulers
|
import io.reactivex.schedulers.Schedulers
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -175,7 +173,7 @@ class ReviewController @Inject constructor(
|
||||||
if (firstRevision == null) return
|
if (firstRevision == null) return
|
||||||
|
|
||||||
Observable.defer {
|
Observable.defer {
|
||||||
thanksClient.thank(firstRevision!!.revisionId)
|
thanksClient.thank(firstRevision!!.revisionId())
|
||||||
}
|
}
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class ReviewHelper
|
||||||
reviewInterface
|
reviewInterface
|
||||||
.getRecentChanges()
|
.getRecentChanges()
|
||||||
.map { it.query()?.pages() }
|
.map { it.query()?.pages() }
|
||||||
.map(MutableList<MwQueryPage>::shuffled)
|
.map { it.shuffled() }
|
||||||
.flatMapIterable { changes: List<MwQueryPage>? -> changes }
|
.flatMapIterable { changes: List<MwQueryPage>? -> changes }
|
||||||
.filter { isChangeReviewable(it) }
|
.filter { isChangeReviewable(it) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,15 @@ package fr.free.nrw.commons.review
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Html
|
import android.text.Html
|
||||||
import android.text.TextUtils
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import fr.free.nrw.commons.CommonsApplication
|
import fr.free.nrw.commons.CommonsApplication
|
||||||
import fr.free.nrw.commons.Media
|
|
||||||
import fr.free.nrw.commons.R
|
import fr.free.nrw.commons.R
|
||||||
import fr.free.nrw.commons.auth.SessionManager
|
import fr.free.nrw.commons.auth.SessionManager
|
||||||
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
|
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
|
||||||
import fr.free.nrw.commons.databinding.FragmentReviewImageBinding
|
import fr.free.nrw.commons.databinding.FragmentReviewImageBinding
|
||||||
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
|
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
|
||||||
import java.util.ArrayList
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -126,7 +123,7 @@ class ReviewImageFragment : CommonsDaggerSupportFragment() {
|
||||||
enableButtons()
|
enableButtons()
|
||||||
question = getString(R.string.review_thanks)
|
question = getString(R.string.review_thanks)
|
||||||
|
|
||||||
user = reviewActivity.reviewController.firstRevision?.user
|
user = reviewActivity.reviewController.firstRevision?.user()
|
||||||
?: savedInstanceState?.getString(SAVED_USER)
|
?: savedInstanceState?.getString(SAVED_USER)
|
||||||
|
|
||||||
//if the user is null because of whatsoever reason, review will not be sent anyways
|
//if the user is null because of whatsoever reason, review will not be sent anyways
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import fr.free.nrw.commons.campaigns.CampaignView
|
||||||
import fr.free.nrw.commons.contributions.ContributionController
|
import fr.free.nrw.commons.contributions.ContributionController
|
||||||
import fr.free.nrw.commons.contributions.MainActivity
|
import fr.free.nrw.commons.contributions.MainActivity
|
||||||
import fr.free.nrw.commons.di.ApplicationlessInjection
|
import fr.free.nrw.commons.di.ApplicationlessInjection
|
||||||
|
import fr.free.nrw.commons.filepicker.FilePicker
|
||||||
import fr.free.nrw.commons.kvstore.JsonKvStore
|
import fr.free.nrw.commons.kvstore.JsonKvStore
|
||||||
import fr.free.nrw.commons.location.LocationServiceManager
|
import fr.free.nrw.commons.location.LocationServiceManager
|
||||||
import fr.free.nrw.commons.logging.CommonsLogSender
|
import fr.free.nrw.commons.logging.CommonsLogSender
|
||||||
|
|
@ -83,9 +84,17 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> =
|
private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> =
|
||||||
registerForActivityResult(StartActivityForResult()) { result ->
|
registerForActivityResult(StartActivityForResult()) { result ->
|
||||||
contributionController.handleActivityResultWithCallback(requireActivity()) { callbacks ->
|
contributionController.handleActivityResultWithCallback(
|
||||||
contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks)
|
requireActivity(),
|
||||||
|
object: FilePicker.HandleActivityResult {
|
||||||
|
override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) {
|
||||||
|
contributionController.onPictureReturnedFromCamera(
|
||||||
|
result,
|
||||||
|
requireActivity(),
|
||||||
|
callbacks
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ class FileProcessor
|
||||||
requireNotNull(imageCoordinates.decimalCoords)
|
requireNotNull(imageCoordinates.decimalCoords)
|
||||||
compositeDisposable.add(
|
compositeDisposable.add(
|
||||||
apiCall
|
apiCall
|
||||||
.request(imageCoordinates.decimalCoords)
|
.request(imageCoordinates.decimalCoords!!)
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(Schedulers.io())
|
.observeOn(Schedulers.io())
|
||||||
.subscribe(
|
.subscribe(
|
||||||
|
|
@ -220,7 +220,7 @@ class FileProcessor
|
||||||
.concatMap {
|
.concatMap {
|
||||||
Observable.fromCallable {
|
Observable.fromCallable {
|
||||||
okHttpJsonApiClient.getNearbyPlaces(
|
okHttpJsonApiClient.getNearbyPlaces(
|
||||||
imageCoordinates.latLng,
|
imageCoordinates.latLng!!,
|
||||||
Locale.getDefault().language,
|
Locale.getDefault().language,
|
||||||
it,
|
it,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -270,7 +270,7 @@ class UploadClient
|
||||||
if (uploadResult.upload == null) {
|
if (uploadResult.upload == null) {
|
||||||
val exception = gson.fromJson(uploadResponse, MwException::class.java)
|
val exception = gson.fromJson(uploadResponse, MwException::class.java)
|
||||||
Timber.e(exception, "Error in uploading file from stash")
|
Timber.e(exception, "Error in uploading file from stash")
|
||||||
throw Exception(exception.getErrorCode())
|
throw Exception(exception.errorCode)
|
||||||
}
|
}
|
||||||
uploadResult.upload
|
uploadResult.upload
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -496,14 +496,14 @@ class UploadWorker(
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
wikidataEditService.handleImageClaimResult(
|
wikidataEditService.handleImageClaimResult(
|
||||||
contribution.wikidataPlace,
|
contribution.wikidataPlace!!,
|
||||||
revisionID,
|
revisionID,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
wikidataEditService.handleImageClaimResult(
|
wikidataEditService.handleImageClaimResult(
|
||||||
contribution.wikidataPlace,
|
contribution.wikidataPlace!!,
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class CustomSelectorUtils {
|
||||||
fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact())
|
fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact())
|
||||||
val sha1 =
|
val sha1 =
|
||||||
fileUtilsWrapper.getSHA1(
|
fileUtilsWrapper.getSHA1(
|
||||||
fileUtilsWrapper.getFileInputStream(uploadableFile.filePath),
|
fileUtilsWrapper.getFileInputStream(uploadableFile.getFilePath()),
|
||||||
)
|
)
|
||||||
uploadableFile.file.delete()
|
uploadableFile.file.delete()
|
||||||
sha1
|
sha1
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,10 @@ class CommonsServiceFactory(
|
||||||
) {
|
) {
|
||||||
val builder: Retrofit.Builder by lazy {
|
val builder: Retrofit.Builder by lazy {
|
||||||
// All instances of retrofit share this configuration, but create it lazily
|
// All instances of retrofit share this configuration, but create it lazily
|
||||||
Retrofit
|
Retrofit.Builder()
|
||||||
.Builder()
|
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
|
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
|
||||||
.addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson()))
|
.addConverterFactory(GsonConverterFactory.create(GsonUtil.defaultGson))
|
||||||
}
|
}
|
||||||
|
|
||||||
val retrofitCache: MutableMap<String, Retrofit> = mutableMapOf()
|
val retrofitCache: MutableMap<String, Retrofit> = mutableMapOf()
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata;
|
|
||||||
|
|
||||||
import android.net.Uri;
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.GsonBuilder;
|
|
||||||
import fr.free.nrw.commons.wikidata.json.RequiredFieldsCheckOnReadTypeAdapterFactory;
|
|
||||||
import fr.free.nrw.commons.wikidata.model.DataValue;
|
|
||||||
import fr.free.nrw.commons.wikidata.model.WikiSite;
|
|
||||||
import fr.free.nrw.commons.wikidata.json.NamespaceTypeAdapter;
|
|
||||||
import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter;
|
|
||||||
import fr.free.nrw.commons.wikidata.json.UriTypeAdapter;
|
|
||||||
import fr.free.nrw.commons.wikidata.json.WikiSiteTypeAdapter;
|
|
||||||
import fr.free.nrw.commons.wikidata.model.page.Namespace;
|
|
||||||
|
|
||||||
public final class GsonUtil {
|
|
||||||
private static final String DATE_FORMAT = "MMM dd, yyyy HH:mm:ss";
|
|
||||||
|
|
||||||
private static final GsonBuilder DEFAULT_GSON_BUILDER = new GsonBuilder()
|
|
||||||
.setDateFormat(DATE_FORMAT)
|
|
||||||
.registerTypeAdapterFactory(DataValue.getPolymorphicTypeAdapter())
|
|
||||||
.registerTypeHierarchyAdapter(Uri.class, new UriTypeAdapter().nullSafe())
|
|
||||||
.registerTypeHierarchyAdapter(Namespace.class, new NamespaceTypeAdapter().nullSafe())
|
|
||||||
.registerTypeAdapter(WikiSite.class, new WikiSiteTypeAdapter().nullSafe())
|
|
||||||
.registerTypeAdapterFactory(new RequiredFieldsCheckOnReadTypeAdapterFactory())
|
|
||||||
.registerTypeAdapterFactory(new PostProcessingTypeAdapter());
|
|
||||||
|
|
||||||
private static final Gson DEFAULT_GSON = DEFAULT_GSON_BUILDER.create();
|
|
||||||
|
|
||||||
public static Gson getDefaultGson() {
|
|
||||||
return DEFAULT_GSON;
|
|
||||||
}
|
|
||||||
|
|
||||||
private GsonUtil() { }
|
|
||||||
}
|
|
||||||
29
app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt
Normal file
29
app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package fr.free.nrw.commons.wikidata
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import fr.free.nrw.commons.wikidata.json.NamespaceTypeAdapter
|
||||||
|
import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter
|
||||||
|
import fr.free.nrw.commons.wikidata.json.RequiredFieldsCheckOnReadTypeAdapterFactory
|
||||||
|
import fr.free.nrw.commons.wikidata.json.UriTypeAdapter
|
||||||
|
import fr.free.nrw.commons.wikidata.json.WikiSiteTypeAdapter
|
||||||
|
import fr.free.nrw.commons.wikidata.model.DataValue.Companion.polymorphicTypeAdapter
|
||||||
|
import fr.free.nrw.commons.wikidata.model.WikiSite
|
||||||
|
import fr.free.nrw.commons.wikidata.model.page.Namespace
|
||||||
|
|
||||||
|
object GsonUtil {
|
||||||
|
private const val DATE_FORMAT = "MMM dd, yyyy HH:mm:ss"
|
||||||
|
|
||||||
|
private val DEFAULT_GSON_BUILDER: GsonBuilder by lazy {
|
||||||
|
GsonBuilder().setDateFormat(DATE_FORMAT)
|
||||||
|
.registerTypeAdapterFactory(polymorphicTypeAdapter)
|
||||||
|
.registerTypeHierarchyAdapter(Uri::class.java, UriTypeAdapter().nullSafe())
|
||||||
|
.registerTypeHierarchyAdapter(Namespace::class.java, NamespaceTypeAdapter().nullSafe())
|
||||||
|
.registerTypeAdapter(WikiSite::class.java, WikiSiteTypeAdapter().nullSafe())
|
||||||
|
.registerTypeAdapterFactory(RequiredFieldsCheckOnReadTypeAdapterFactory())
|
||||||
|
.registerTypeAdapterFactory(PostProcessingTypeAdapter())
|
||||||
|
}
|
||||||
|
|
||||||
|
val defaultGson: Gson by lazy { DEFAULT_GSON_BUILDER.create() }
|
||||||
|
}
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata;
|
|
||||||
|
|
||||||
public class WikidataConstants {
|
|
||||||
public static final String PLACE_OBJECT = "place";
|
|
||||||
public static final String BOOKMARKS_ITEMS = "bookmarks.items";
|
|
||||||
public static final String SELECTED_NEARBY_PLACE = "selected.nearby.place";
|
|
||||||
public static final String SELECTED_NEARBY_PLACE_CATEGORY = "selected.nearby.place.category";
|
|
||||||
|
|
||||||
public static final String MW_API_PREFIX = "w/api.php?format=json&formatversion=2&errorformat=plaintext&";
|
|
||||||
public static final String WIKIPEDIA_URL = "https://wikipedia.org/";
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package fr.free.nrw.commons.wikidata
|
||||||
|
|
||||||
|
object WikidataConstants {
|
||||||
|
const val PLACE_OBJECT: String = "place"
|
||||||
|
const val BOOKMARKS_ITEMS: String = "bookmarks.items"
|
||||||
|
const val SELECTED_NEARBY_PLACE: String = "selected.nearby.place"
|
||||||
|
const val SELECTED_NEARBY_PLACE_CATEGORY: String = "selected.nearby.place.category"
|
||||||
|
|
||||||
|
const val MW_API_PREFIX: String = "w/api.php?format=json&formatversion=2&errorformat=plaintext&"
|
||||||
|
const val WIKIPEDIA_URL: String = "https://wikipedia.org/"
|
||||||
|
}
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata;
|
|
||||||
|
|
||||||
public abstract class WikidataEditListener {
|
|
||||||
|
|
||||||
protected WikidataP18EditListener wikidataP18EditListener;
|
|
||||||
|
|
||||||
public abstract void onSuccessfulWikidataEdit();
|
|
||||||
|
|
||||||
public void setAuthenticationStateListener(WikidataP18EditListener wikidataP18EditListener) {
|
|
||||||
this.wikidataP18EditListener = wikidataP18EditListener;
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface WikidataP18EditListener {
|
|
||||||
void onWikidataEditSuccessful();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package fr.free.nrw.commons.wikidata
|
||||||
|
|
||||||
|
abstract class WikidataEditListener {
|
||||||
|
var authenticationStateListener: WikidataP18EditListener? = null
|
||||||
|
|
||||||
|
abstract fun onSuccessfulWikidataEdit()
|
||||||
|
|
||||||
|
interface WikidataP18EditListener {
|
||||||
|
fun onWikidataEditSuccessful()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listener for wikidata edits
|
|
||||||
*/
|
|
||||||
public class WikidataEditListenerImpl extends WikidataEditListener {
|
|
||||||
|
|
||||||
public WikidataEditListenerImpl() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fired when wikidata P18 edit is successful. If there's an active listener, then it is fired
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void onSuccessfulWikidataEdit() {
|
|
||||||
if (wikidataP18EditListener != null) {
|
|
||||||
wikidataP18EditListener.onWikidataEditSuccessful();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package fr.free.nrw.commons.wikidata
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener for wikidata edits
|
||||||
|
*/
|
||||||
|
class WikidataEditListenerImpl : WikidataEditListener() {
|
||||||
|
/**
|
||||||
|
* Fired when wikidata P18 edit is successful. If there's an active listener, then it is fired
|
||||||
|
*/
|
||||||
|
override fun onSuccessfulWikidataEdit() {
|
||||||
|
authenticationStateListener?.onWikidataEditSuccessful()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,271 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata;
|
|
||||||
|
|
||||||
|
|
||||||
import static fr.free.nrw.commons.media.MediaClientKt.PAGE_ID_PREFIX;
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
|
||||||
import android.content.Context;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import fr.free.nrw.commons.R;
|
|
||||||
import fr.free.nrw.commons.contributions.Contribution;
|
|
||||||
import fr.free.nrw.commons.kvstore.JsonKvStore;
|
|
||||||
import fr.free.nrw.commons.upload.UploadResult;
|
|
||||||
import fr.free.nrw.commons.upload.WikidataItem;
|
|
||||||
import fr.free.nrw.commons.upload.WikidataPlace;
|
|
||||||
import fr.free.nrw.commons.utils.ConfigUtils;
|
|
||||||
import fr.free.nrw.commons.utils.ViewUtil;
|
|
||||||
import fr.free.nrw.commons.wikidata.model.DataValue;
|
|
||||||
import fr.free.nrw.commons.wikidata.model.DataValue.ValueString;
|
|
||||||
import fr.free.nrw.commons.wikidata.model.EditClaim;
|
|
||||||
import fr.free.nrw.commons.wikidata.model.RemoveClaim;
|
|
||||||
import fr.free.nrw.commons.wikidata.model.SnakPartial;
|
|
||||||
import fr.free.nrw.commons.wikidata.model.StatementPartial;
|
|
||||||
import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue;
|
|
||||||
import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse;
|
|
||||||
import io.reactivex.Observable;
|
|
||||||
import io.reactivex.schedulers.Schedulers;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
import javax.inject.Inject;
|
|
||||||
import javax.inject.Named;
|
|
||||||
import javax.inject.Singleton;
|
|
||||||
import timber.log.Timber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki
|
|
||||||
* Apis to make the necessary calls, log the edits and fire listeners on successful edits
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
public class WikidataEditService {
|
|
||||||
|
|
||||||
public static final String COMMONS_APP_TAG = "wikimedia-commons-app";
|
|
||||||
|
|
||||||
private final Context context;
|
|
||||||
private final WikidataEditListener wikidataEditListener;
|
|
||||||
private final JsonKvStore directKvStore;
|
|
||||||
private final WikiBaseClient wikiBaseClient;
|
|
||||||
private final WikidataClient wikidataClient;
|
|
||||||
private final Gson gson;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
public WikidataEditService(final Context context,
|
|
||||||
final WikidataEditListener wikidataEditListener,
|
|
||||||
@Named("default_preferences") final JsonKvStore directKvStore,
|
|
||||||
final WikiBaseClient wikiBaseClient,
|
|
||||||
final WikidataClient wikidataClient, final Gson gson) {
|
|
||||||
this.context = context;
|
|
||||||
this.wikidataEditListener = wikidataEditListener;
|
|
||||||
this.directKvStore = directKvStore;
|
|
||||||
this.wikiBaseClient = wikiBaseClient;
|
|
||||||
this.wikidataClient = wikidataClient;
|
|
||||||
this.gson = gson;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edits the wikibase entity by adding DEPICTS property. Adding DEPICTS property requires call
|
|
||||||
* to the wikibase API to set tag against the entity.
|
|
||||||
*/
|
|
||||||
@SuppressLint("CheckResult")
|
|
||||||
private Observable<Boolean> addDepictsProperty(
|
|
||||||
final String fileEntityId,
|
|
||||||
final List<String> depictedItems
|
|
||||||
) {
|
|
||||||
final EditClaim data = editClaim(
|
|
||||||
ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10")
|
|
||||||
// Wikipedia:Sandbox (Q10)
|
|
||||||
: depictedItems
|
|
||||||
);
|
|
||||||
|
|
||||||
return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data))
|
|
||||||
.doOnNext(success -> {
|
|
||||||
if (success) {
|
|
||||||
Timber.d("DEPICTS property was set successfully for %s", fileEntityId);
|
|
||||||
} else {
|
|
||||||
Timber.d("Unable to set DEPICTS property for %s", fileEntityId);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.doOnError(throwable -> {
|
|
||||||
Timber.e(throwable, "Error occurred while setting DEPICTS property");
|
|
||||||
ViewUtil.showLongToast(context, throwable.toString());
|
|
||||||
})
|
|
||||||
.subscribeOn(Schedulers.io());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Takes depicts ID as a parameter and create a uploadable data with the Id
|
|
||||||
* and send the data for POST operation
|
|
||||||
*
|
|
||||||
* @param fileEntityId ID of the file
|
|
||||||
* @param depictedItems IDs of the selected depict item
|
|
||||||
* @return Observable<Boolean>
|
|
||||||
*/
|
|
||||||
@SuppressLint("CheckResult")
|
|
||||||
public Observable<Boolean> updateDepictsProperty(
|
|
||||||
final String fileEntityId,
|
|
||||||
final List<String> depictedItems
|
|
||||||
) {
|
|
||||||
final String entityId = PAGE_ID_PREFIX + fileEntityId;
|
|
||||||
final List<String> claimIds = getDepictionsClaimIds(entityId);
|
|
||||||
|
|
||||||
final RemoveClaim data = removeClaim( /* Please consider removeClaim scenario for BetaDebug */
|
|
||||||
ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10")
|
|
||||||
// Wikipedia:Sandbox (Q10)
|
|
||||||
: claimIds
|
|
||||||
);
|
|
||||||
|
|
||||||
return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data))
|
|
||||||
.doOnError(throwable -> {
|
|
||||||
Timber.e(throwable, "Error occurred while removing existing claims for DEPICTS property");
|
|
||||||
ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure));
|
|
||||||
}).switchMap(success-> {
|
|
||||||
if(success) {
|
|
||||||
Timber.d("DEPICTS property was deleted successfully");
|
|
||||||
return addDepictsProperty(fileEntityId, depictedItems);
|
|
||||||
} else {
|
|
||||||
Timber.d("Unable to delete DEPICTS property");
|
|
||||||
return Observable.empty();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("CheckResult")
|
|
||||||
private List<String> getDepictionsClaimIds(final String entityId) {
|
|
||||||
return wikiBaseClient.getClaimIdsByProperty(entityId, WikidataProperties.DEPICTS.getPropertyName())
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.blockingFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
private EditClaim editClaim(final List<String> entityIds) {
|
|
||||||
return EditClaim.from(entityIds, WikidataProperties.DEPICTS.getPropertyName());
|
|
||||||
}
|
|
||||||
|
|
||||||
private RemoveClaim removeClaim(final List<String> claimIds) {
|
|
||||||
return RemoveClaim.from(claimIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a success toast when the edit is made successfully
|
|
||||||
*/
|
|
||||||
private void showSuccessToast(final String wikiItemName) {
|
|
||||||
final String successStringTemplate = context.getString(R.string.successful_wikidata_edit);
|
|
||||||
final String successMessage = String
|
|
||||||
.format(Locale.getDefault(), successStringTemplate, wikiItemName);
|
|
||||||
ViewUtil.showLongToast(context, successMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds label to Wikidata using the fileEntityId and the edit token, obtained from
|
|
||||||
* csrfTokenClient
|
|
||||||
*
|
|
||||||
* @param fileEntityId
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
@SuppressLint("CheckResult")
|
|
||||||
private Observable<Boolean> addCaption(final long fileEntityId, final String languageCode,
|
|
||||||
final String captionValue) {
|
|
||||||
return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue)
|
|
||||||
.doOnNext(mwPostResponse -> onAddCaptionResponse(fileEntityId, mwPostResponse))
|
|
||||||
.doOnError(throwable -> {
|
|
||||||
Timber.e(throwable, "Error occurred while setting Captions");
|
|
||||||
ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure));
|
|
||||||
})
|
|
||||||
.map(mwPostResponse -> mwPostResponse != null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onAddCaptionResponse(Long fileEntityId, MwPostResponse response) {
|
|
||||||
if (response != null) {
|
|
||||||
Timber.d("Caption successfully set, revision id = %s", response);
|
|
||||||
} else {
|
|
||||||
Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long createClaim(@Nullable final WikidataPlace wikidataPlace, final String fileName,
|
|
||||||
final Map<String, String> captions) {
|
|
||||||
if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) {
|
|
||||||
Timber
|
|
||||||
.d("Image location and nearby place location mismatched, so Wikidata item won't be edited");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return addImageAndMediaLegends(wikidataPlace, fileName, captions);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long addImageAndMediaLegends(final WikidataItem wikidataItem, final String fileName,
|
|
||||||
final Map<String, String> captions) {
|
|
||||||
final SnakPartial p18 = new SnakPartial("value",
|
|
||||||
WikidataProperties.IMAGE.getPropertyName(),
|
|
||||||
new ValueString(fileName.replace("File:", "")));
|
|
||||||
|
|
||||||
final List<SnakPartial> snaks = new ArrayList<>();
|
|
||||||
for (final Map.Entry<String, String> entry : captions.entrySet()) {
|
|
||||||
snaks.add(new SnakPartial("value",
|
|
||||||
WikidataProperties.MEDIA_LEGENDS.getPropertyName(), new DataValue.MonoLingualText(
|
|
||||||
new WikiBaseMonolingualTextValue(entry.getValue(), entry.getKey()))));
|
|
||||||
}
|
|
||||||
|
|
||||||
final String id = wikidataItem.getId() + "$" + UUID.randomUUID().toString();
|
|
||||||
final StatementPartial claim = new StatementPartial(p18, "statement", "normal", id,
|
|
||||||
Collections.singletonMap(WikidataProperties.MEDIA_LEGENDS.getPropertyName(), snaks),
|
|
||||||
Arrays.asList(WikidataProperties.MEDIA_LEGENDS.getPropertyName()));
|
|
||||||
|
|
||||||
return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void handleImageClaimResult(final WikidataItem wikidataItem, final Long revisionId) {
|
|
||||||
if (revisionId != null) {
|
|
||||||
if (wikidataEditListener != null) {
|
|
||||||
wikidataEditListener.onSuccessfulWikidataEdit();
|
|
||||||
}
|
|
||||||
showSuccessToast(wikidataItem.getName());
|
|
||||||
} else {
|
|
||||||
Timber.d("Unable to make wiki data edit for entity %s", wikidataItem);
|
|
||||||
ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Observable<Boolean> addDepictionsAndCaptions(
|
|
||||||
final UploadResult uploadResult,
|
|
||||||
final Contribution contribution
|
|
||||||
) {
|
|
||||||
return wikiBaseClient.getFileEntityId(uploadResult)
|
|
||||||
.doOnError(throwable -> {
|
|
||||||
Timber
|
|
||||||
.e(throwable, "Error occurred while getting EntityID to set DEPICTS property");
|
|
||||||
ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure));
|
|
||||||
})
|
|
||||||
.switchMap(fileEntityId -> {
|
|
||||||
if (fileEntityId != null) {
|
|
||||||
Timber.d("EntityId for image was received successfully: %s", fileEntityId);
|
|
||||||
return Observable.concat(
|
|
||||||
depictionEdits(contribution, fileEntityId),
|
|
||||||
captionEdits(contribution, fileEntityId)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
Timber.d("Error acquiring EntityId for image: %s", uploadResult);
|
|
||||||
return Observable.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Observable<Boolean> captionEdits(Contribution contribution, Long fileEntityId) {
|
|
||||||
return Observable.fromIterable(contribution.getMedia().getCaptions().entrySet())
|
|
||||||
.concatMap(entry -> addCaption(fileEntityId, entry.getKey(), entry.getValue()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Observable<Boolean> depictionEdits(Contribution contribution, Long fileEntityId) {
|
|
||||||
final List<String> depictIDs = new ArrayList<>();
|
|
||||||
for (final WikidataItem wikidataItem :
|
|
||||||
contribution.getDepictedItems()) {
|
|
||||||
depictIDs.add(wikidataItem.getId());
|
|
||||||
}
|
|
||||||
return addDepictsProperty(fileEntityId.toString(), depictIDs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
package fr.free.nrw.commons.wikidata
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import fr.free.nrw.commons.R
|
||||||
|
import fr.free.nrw.commons.contributions.Contribution
|
||||||
|
import fr.free.nrw.commons.kvstore.JsonKvStore
|
||||||
|
import fr.free.nrw.commons.media.PAGE_ID_PREFIX
|
||||||
|
import fr.free.nrw.commons.upload.UploadResult
|
||||||
|
import fr.free.nrw.commons.upload.WikidataItem
|
||||||
|
import fr.free.nrw.commons.upload.WikidataPlace
|
||||||
|
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
|
||||||
|
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
|
||||||
|
import fr.free.nrw.commons.wikidata.WikidataProperties.DEPICTS
|
||||||
|
import fr.free.nrw.commons.wikidata.WikidataProperties.IMAGE
|
||||||
|
import fr.free.nrw.commons.wikidata.WikidataProperties.MEDIA_LEGENDS
|
||||||
|
import fr.free.nrw.commons.wikidata.model.DataValue.MonoLingualText
|
||||||
|
import fr.free.nrw.commons.wikidata.model.DataValue.ValueString
|
||||||
|
import fr.free.nrw.commons.wikidata.model.EditClaim
|
||||||
|
import fr.free.nrw.commons.wikidata.model.RemoveClaim
|
||||||
|
import fr.free.nrw.commons.wikidata.model.SnakPartial
|
||||||
|
import fr.free.nrw.commons.wikidata.model.StatementPartial
|
||||||
|
import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue
|
||||||
|
import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse
|
||||||
|
import io.reactivex.Observable
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.Arrays
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.Objects
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Named
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki
|
||||||
|
* Apis to make the necessary calls, log the edits and fire listeners on successful edits
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class WikidataEditService @Inject constructor(
|
||||||
|
private val context: Context,
|
||||||
|
private val wikidataEditListener: WikidataEditListener?,
|
||||||
|
@param:Named("default_preferences") private val directKvStore: JsonKvStore,
|
||||||
|
private val wikiBaseClient: WikiBaseClient,
|
||||||
|
private val wikidataClient: WikidataClient, private val gson: Gson
|
||||||
|
) {
|
||||||
|
@SuppressLint("CheckResult")
|
||||||
|
private fun addDepictsProperty(
|
||||||
|
fileEntityId: String,
|
||||||
|
depictedItems: List<String>
|
||||||
|
): Observable<Boolean> {
|
||||||
|
val data = EditClaim.from(
|
||||||
|
if (isBetaFlavour) listOf("Q10") else depictedItems, DEPICTS.propertyName
|
||||||
|
)
|
||||||
|
|
||||||
|
return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data))
|
||||||
|
.doOnNext { success: Boolean ->
|
||||||
|
if (success) {
|
||||||
|
Timber.d("DEPICTS property was set successfully for %s", fileEntityId)
|
||||||
|
} else {
|
||||||
|
Timber.d("Unable to set DEPICTS property for %s", fileEntityId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.doOnError { throwable: Throwable ->
|
||||||
|
Timber.e(throwable, "Error occurred while setting DEPICTS property")
|
||||||
|
showLongToast(context, throwable.toString())
|
||||||
|
}
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("CheckResult")
|
||||||
|
fun updateDepictsProperty(
|
||||||
|
fileEntityId: String?,
|
||||||
|
depictedItems: List<String>
|
||||||
|
): Observable<Boolean> {
|
||||||
|
val entityId: String = PAGE_ID_PREFIX + fileEntityId
|
||||||
|
val claimIds = getDepictionsClaimIds(entityId)
|
||||||
|
|
||||||
|
/* Please consider removeClaim scenario for BetaDebug */
|
||||||
|
val data = RemoveClaim.from(if (isBetaFlavour) listOf("Q10") else claimIds)
|
||||||
|
|
||||||
|
return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data))
|
||||||
|
.doOnError { throwable: Throwable? ->
|
||||||
|
Timber.e(
|
||||||
|
throwable,
|
||||||
|
"Error occurred while removing existing claims for DEPICTS property"
|
||||||
|
)
|
||||||
|
showLongToast(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.wikidata_edit_failure)
|
||||||
|
)
|
||||||
|
}.switchMap { success: Boolean ->
|
||||||
|
if (success) {
|
||||||
|
Timber.d("DEPICTS property was deleted successfully")
|
||||||
|
return@switchMap addDepictsProperty(fileEntityId!!, depictedItems)
|
||||||
|
} else {
|
||||||
|
Timber.d("Unable to delete DEPICTS property")
|
||||||
|
return@switchMap Observable.empty<Boolean>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("CheckResult")
|
||||||
|
private fun getDepictionsClaimIds(entityId: String): List<String> {
|
||||||
|
return wikiBaseClient.getClaimIdsByProperty(entityId, DEPICTS.propertyName)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.blockingFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSuccessToast(wikiItemName: String) {
|
||||||
|
val successStringTemplate = context.getString(R.string.successful_wikidata_edit)
|
||||||
|
val successMessage = String.format(Locale.getDefault(), successStringTemplate, wikiItemName)
|
||||||
|
showLongToast(context, successMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("CheckResult")
|
||||||
|
private fun addCaption(
|
||||||
|
fileEntityId: Long, languageCode: String,
|
||||||
|
captionValue: String
|
||||||
|
): Observable<Boolean> {
|
||||||
|
return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue)
|
||||||
|
.doOnNext { mwPostResponse: MwPostResponse? ->
|
||||||
|
onAddCaptionResponse(
|
||||||
|
fileEntityId,
|
||||||
|
mwPostResponse
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.doOnError { throwable: Throwable? ->
|
||||||
|
Timber.e(throwable, "Error occurred while setting Captions")
|
||||||
|
showLongToast(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.wikidata_edit_failure)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.map(Objects::nonNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onAddCaptionResponse(fileEntityId: Long, response: MwPostResponse?) {
|
||||||
|
if (response != null) {
|
||||||
|
Timber.d("Caption successfully set, revision id = %s", response)
|
||||||
|
} else {
|
||||||
|
Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createClaim(
|
||||||
|
wikidataPlace: WikidataPlace?, fileName: String,
|
||||||
|
captions: Map<String, String>
|
||||||
|
): Long? {
|
||||||
|
if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) {
|
||||||
|
Timber.d(
|
||||||
|
"Image location and nearby place location mismatched, so Wikidata item won't be edited"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return addImageAndMediaLegends(wikidataPlace!!, fileName, captions)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addImageAndMediaLegends(
|
||||||
|
wikidataItem: WikidataItem, fileName: String,
|
||||||
|
captions: Map<String, String>
|
||||||
|
): Long {
|
||||||
|
val p18 = SnakPartial(
|
||||||
|
"value",
|
||||||
|
IMAGE.propertyName,
|
||||||
|
ValueString(fileName.replace("File:", ""))
|
||||||
|
)
|
||||||
|
|
||||||
|
val snaks: MutableList<SnakPartial> = ArrayList()
|
||||||
|
for ((key, value) in captions) {
|
||||||
|
snaks.add(
|
||||||
|
SnakPartial(
|
||||||
|
"value",
|
||||||
|
MEDIA_LEGENDS.propertyName, MonoLingualText(
|
||||||
|
WikiBaseMonolingualTextValue(value!!, key!!)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val id = wikidataItem.id + "$" + UUID.randomUUID().toString()
|
||||||
|
val claim = StatementPartial(
|
||||||
|
p18, "statement", "normal", id, Collections.singletonMap<String, List<SnakPartial>>(
|
||||||
|
MEDIA_LEGENDS.propertyName, snaks
|
||||||
|
), Arrays.asList(MEDIA_LEGENDS.propertyName)
|
||||||
|
)
|
||||||
|
|
||||||
|
return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleImageClaimResult(wikidataItem: WikidataItem, revisionId: Long?) {
|
||||||
|
if (revisionId != null) {
|
||||||
|
wikidataEditListener?.onSuccessfulWikidataEdit()
|
||||||
|
showSuccessToast(wikidataItem.name)
|
||||||
|
} else {
|
||||||
|
Timber.d("Unable to make wiki data edit for entity %s", wikidataItem)
|
||||||
|
showLongToast(context, context.getString(R.string.wikidata_edit_failure))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addDepictionsAndCaptions(
|
||||||
|
uploadResult: UploadResult,
|
||||||
|
contribution: Contribution
|
||||||
|
): Observable<Boolean> {
|
||||||
|
return wikiBaseClient.getFileEntityId(uploadResult)
|
||||||
|
.doOnError { throwable: Throwable? ->
|
||||||
|
Timber.e(
|
||||||
|
throwable,
|
||||||
|
"Error occurred while getting EntityID to set DEPICTS property"
|
||||||
|
)
|
||||||
|
showLongToast(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.wikidata_edit_failure)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.switchMap { fileEntityId: Long? ->
|
||||||
|
if (fileEntityId != null) {
|
||||||
|
Timber.d("EntityId for image was received successfully: %s", fileEntityId)
|
||||||
|
return@switchMap Observable.concat<Boolean>(
|
||||||
|
depictionEdits(contribution, fileEntityId),
|
||||||
|
captionEdits(contribution, fileEntityId)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Timber.d("Error acquiring EntityId for image: %s", uploadResult)
|
||||||
|
return@switchMap Observable.empty<Boolean>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captionEdits(contribution: Contribution, fileEntityId: Long): Observable<Boolean> {
|
||||||
|
return Observable.fromIterable(contribution.media.captions.entries)
|
||||||
|
.concatMap { addCaption(fileEntityId, it.key, it.value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun depictionEdits(
|
||||||
|
contribution: Contribution,
|
||||||
|
fileEntityId: Long
|
||||||
|
): Observable<Boolean> = addDepictsProperty(fileEntityId.toString(), buildList {
|
||||||
|
for ((_, _, _, _, _, _, id) in contribution.depictedItems) {
|
||||||
|
add(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val COMMONS_APP_TAG: String = "wikimedia-commons-app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata.json;
|
|
||||||
|
|
||||||
import com.google.gson.TypeAdapter;
|
|
||||||
import com.google.gson.stream.JsonReader;
|
|
||||||
import com.google.gson.stream.JsonToken;
|
|
||||||
import com.google.gson.stream.JsonWriter;
|
|
||||||
|
|
||||||
import fr.free.nrw.commons.wikidata.model.page.Namespace;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
public class NamespaceTypeAdapter extends TypeAdapter<Namespace> {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void write(JsonWriter out, Namespace namespace) throws IOException {
|
|
||||||
out.value(namespace.code());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Namespace read(JsonReader in) throws IOException {
|
|
||||||
if (in.peek() == JsonToken.STRING) {
|
|
||||||
// Prior to 3210ce44, we marshaled Namespace as the name string of the enum, instead of
|
|
||||||
// the code number. This introduces a backwards-compatible check for the string value.
|
|
||||||
// TODO: remove after April 2017, when all older namespaces have been deserialized.
|
|
||||||
return Namespace.valueOf(in.nextString());
|
|
||||||
}
|
|
||||||
return Namespace.of(in.nextInt());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package fr.free.nrw.commons.wikidata.json
|
||||||
|
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import com.google.gson.stream.JsonReader
|
||||||
|
import com.google.gson.stream.JsonToken
|
||||||
|
import com.google.gson.stream.JsonWriter
|
||||||
|
import fr.free.nrw.commons.wikidata.model.page.Namespace
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class NamespaceTypeAdapter : TypeAdapter<Namespace>() {
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun write(out: JsonWriter, namespace: Namespace) {
|
||||||
|
out.value(namespace.code().toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun read(reader: JsonReader): Namespace {
|
||||||
|
if (reader.peek() == JsonToken.STRING) {
|
||||||
|
// Prior to 3210ce44, we marshaled Namespace as the name string of the enum, instead of
|
||||||
|
// the code number. This introduces a backwards-compatible check for the string value.
|
||||||
|
// TODO: remove after April 2017, when all older namespaces have been deserialized.
|
||||||
|
return Namespace.valueOf(reader.nextString())
|
||||||
|
}
|
||||||
|
return Namespace.of(reader.nextInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata.json;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.TypeAdapter;
|
|
||||||
import com.google.gson.TypeAdapterFactory;
|
|
||||||
import com.google.gson.reflect.TypeToken;
|
|
||||||
import com.google.gson.stream.JsonReader;
|
|
||||||
import com.google.gson.stream.JsonWriter;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
public class PostProcessingTypeAdapter implements TypeAdapterFactory {
|
|
||||||
public interface PostProcessable {
|
|
||||||
void postProcess();
|
|
||||||
}
|
|
||||||
|
|
||||||
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
|
|
||||||
final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
|
|
||||||
|
|
||||||
return new TypeAdapter<T>() {
|
|
||||||
public void write(JsonWriter out, T value) throws IOException {
|
|
||||||
delegate.write(out, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public T read(JsonReader in) throws IOException {
|
|
||||||
T obj = delegate.read(in);
|
|
||||||
if (obj instanceof PostProcessable) {
|
|
||||||
((PostProcessable)obj).postProcess();
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package fr.free.nrw.commons.wikidata.json
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import com.google.gson.TypeAdapterFactory
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import com.google.gson.stream.JsonReader
|
||||||
|
import com.google.gson.stream.JsonWriter
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class PostProcessingTypeAdapter : TypeAdapterFactory {
|
||||||
|
interface PostProcessable {
|
||||||
|
fun postProcess()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun <T> create(gson: Gson, type: TypeToken<T>): TypeAdapter<T> {
|
||||||
|
val delegate = gson.getDelegateAdapter(this, type)
|
||||||
|
|
||||||
|
return object : TypeAdapter<T>() {
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun write(out: JsonWriter, value: T) {
|
||||||
|
delegate.write(out, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun read(reader: JsonReader): T {
|
||||||
|
val obj = delegate.read(reader)
|
||||||
|
if (obj is PostProcessable) {
|
||||||
|
(obj as PostProcessable).postProcess()
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata.json;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.collection.ArraySet;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.JsonParseException;
|
|
||||||
import com.google.gson.TypeAdapter;
|
|
||||||
import com.google.gson.TypeAdapterFactory;
|
|
||||||
import com.google.gson.reflect.TypeToken;
|
|
||||||
import com.google.gson.stream.JsonReader;
|
|
||||||
import com.google.gson.stream.JsonWriter;
|
|
||||||
|
|
||||||
import fr.free.nrw.commons.wikidata.json.annotations.Required;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.lang.reflect.Field;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TypeAdapterFactory that provides TypeAdapters that return null values for objects that are
|
|
||||||
* missing fields annotated with @Required.
|
|
||||||
*
|
|
||||||
* BEWARE: This means that a List or other Collection of objects that have @Required fields can
|
|
||||||
* contain null elements after deserialization!
|
|
||||||
*
|
|
||||||
* TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements
|
|
||||||
* annotation and another corresponding TypeAdapter(Factory).
|
|
||||||
*/
|
|
||||||
public class RequiredFieldsCheckOnReadTypeAdapterFactory implements TypeAdapterFactory {
|
|
||||||
@Nullable @Override public final <T> TypeAdapter<T> create(@NonNull Gson gson, @NonNull TypeToken<T> typeToken) {
|
|
||||||
Class<?> rawType = typeToken.getRawType();
|
|
||||||
Set<Field> requiredFields = collectRequiredFields(rawType);
|
|
||||||
|
|
||||||
if (requiredFields.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFieldsAccessible(requiredFields, true);
|
|
||||||
return new Adapter<>(gson.getDelegateAdapter(this, typeToken), requiredFields);
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull private Set<Field> collectRequiredFields(@NonNull Class<?> clazz) {
|
|
||||||
Field[] fields = clazz.getDeclaredFields();
|
|
||||||
Set<Field> required = new ArraySet<>();
|
|
||||||
for (Field field : fields) {
|
|
||||||
if (field.isAnnotationPresent(Required.class)) {
|
|
||||||
required.add(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Collections.unmodifiableSet(required);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setFieldsAccessible(Iterable<Field> fields, boolean accessible) {
|
|
||||||
for (Field field : fields) {
|
|
||||||
field.setAccessible(accessible);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class Adapter<T> extends TypeAdapter<T> {
|
|
||||||
@NonNull private final TypeAdapter<T> delegate;
|
|
||||||
@NonNull private final Set<Field> requiredFields;
|
|
||||||
|
|
||||||
private Adapter(@NonNull TypeAdapter<T> delegate, @NonNull final Set<Field> requiredFields) {
|
|
||||||
this.delegate = delegate;
|
|
||||||
this.requiredFields = requiredFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void write(JsonWriter out, T value) throws IOException {
|
|
||||||
delegate.write(out, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override @Nullable public T read(JsonReader in) throws IOException {
|
|
||||||
T deserialized = delegate.read(in);
|
|
||||||
return allRequiredFieldsPresent(deserialized, requiredFields) ? deserialized : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean allRequiredFieldsPresent(@NonNull T deserialized,
|
|
||||||
@NonNull Set<Field> required) {
|
|
||||||
for (Field field : required) {
|
|
||||||
try {
|
|
||||||
if (field.get(deserialized) == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (IllegalArgumentException | IllegalAccessException e) {
|
|
||||||
throw new JsonParseException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package fr.free.nrw.commons.wikidata.json
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonParseException
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import com.google.gson.TypeAdapterFactory
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import com.google.gson.stream.JsonReader
|
||||||
|
import com.google.gson.stream.JsonWriter
|
||||||
|
import fr.free.nrw.commons.wikidata.json.annotations.Required
|
||||||
|
import java.io.IOException
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypeAdapterFactory that provides TypeAdapters that return null values for objects that are
|
||||||
|
* missing fields annotated with @Required.
|
||||||
|
*
|
||||||
|
* BEWARE: This means that a List or other Collection of objects that have @Required fields can
|
||||||
|
* contain null elements after deserialization!
|
||||||
|
*
|
||||||
|
* TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements
|
||||||
|
* annotation and another corresponding TypeAdapter(Factory).
|
||||||
|
*/
|
||||||
|
class RequiredFieldsCheckOnReadTypeAdapterFactory : TypeAdapterFactory {
|
||||||
|
override fun <T> create(gson: Gson, typeToken: TypeToken<T>): TypeAdapter<T>? {
|
||||||
|
val rawType: Class<*> = typeToken.rawType
|
||||||
|
val requiredFields = collectRequiredFields(rawType)
|
||||||
|
|
||||||
|
if (requiredFields.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (field in requiredFields) {
|
||||||
|
field.isAccessible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return Adapter(gson.getDelegateAdapter(this, typeToken), requiredFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectRequiredFields(clazz: Class<*>): Set<Field> = buildSet {
|
||||||
|
for (field in clazz.declaredFields) {
|
||||||
|
if (field.isAnnotationPresent(Required::class.java)) add(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Adapter<T>(
|
||||||
|
private val delegate: TypeAdapter<T>,
|
||||||
|
private val requiredFields: Set<Field>
|
||||||
|
) : TypeAdapter<T>() {
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun write(out: JsonWriter, value: T?) =
|
||||||
|
delegate.write(out, value)
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun read(reader: JsonReader): T? =
|
||||||
|
if (allRequiredFieldsPresent(delegate.read(reader), requiredFields))
|
||||||
|
delegate.read(reader)
|
||||||
|
else
|
||||||
|
null
|
||||||
|
|
||||||
|
fun allRequiredFieldsPresent(deserialized: T, required: Set<Field>): Boolean {
|
||||||
|
for (field in required) {
|
||||||
|
try {
|
||||||
|
if (field[deserialized] == null) return false
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
throw JsonParseException(e)
|
||||||
|
} catch (e: IllegalAccessException) {
|
||||||
|
throw JsonParseException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata.json;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Copyright (C) 2011 Google Inc.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import android.util.Log;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.JsonElement;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import com.google.gson.JsonParseException;
|
|
||||||
import com.google.gson.JsonPrimitive;
|
|
||||||
import com.google.gson.TypeAdapter;
|
|
||||||
import com.google.gson.TypeAdapterFactory;
|
|
||||||
import com.google.gson.internal.Streams;
|
|
||||||
import com.google.gson.reflect.TypeToken;
|
|
||||||
import com.google.gson.stream.JsonReader;
|
|
||||||
import com.google.gson.stream.JsonWriter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapts values whose runtime type may differ from their declaration type. This
|
|
||||||
* is necessary when a field's type is not the same type that GSON should create
|
|
||||||
* when deserializing that field. For example, consider these types:
|
|
||||||
* <pre> {@code
|
|
||||||
* abstract class Shape {
|
|
||||||
* int x;
|
|
||||||
* int y;
|
|
||||||
* }
|
|
||||||
* class Circle extends Shape {
|
|
||||||
* int radius;
|
|
||||||
* }
|
|
||||||
* class Rectangle extends Shape {
|
|
||||||
* int width;
|
|
||||||
* int height;
|
|
||||||
* }
|
|
||||||
* class Diamond extends Shape {
|
|
||||||
* int width;
|
|
||||||
* int height;
|
|
||||||
* }
|
|
||||||
* class Drawing {
|
|
||||||
* Shape bottomShape;
|
|
||||||
* Shape topShape;
|
|
||||||
* }
|
|
||||||
* }</pre>
|
|
||||||
* <p>Without additional type information, the serialized JSON is ambiguous. Is
|
|
||||||
* the bottom shape in this drawing a rectangle or a diamond? <pre> {@code
|
|
||||||
* {
|
|
||||||
* "bottomShape": {
|
|
||||||
* "width": 10,
|
|
||||||
* "height": 5,
|
|
||||||
* "x": 0,
|
|
||||||
* "y": 0
|
|
||||||
* },
|
|
||||||
* "topShape": {
|
|
||||||
* "radius": 2,
|
|
||||||
* "x": 4,
|
|
||||||
* "y": 1
|
|
||||||
* }
|
|
||||||
* }}</pre>
|
|
||||||
* This class addresses this problem by adding type information to the
|
|
||||||
* serialized JSON and honoring that type information when the JSON is
|
|
||||||
* deserialized: <pre> {@code
|
|
||||||
* {
|
|
||||||
* "bottomShape": {
|
|
||||||
* "type": "Diamond",
|
|
||||||
* "width": 10,
|
|
||||||
* "height": 5,
|
|
||||||
* "x": 0,
|
|
||||||
* "y": 0
|
|
||||||
* },
|
|
||||||
* "topShape": {
|
|
||||||
* "type": "Circle",
|
|
||||||
* "radius": 2,
|
|
||||||
* "x": 4,
|
|
||||||
* "y": 1
|
|
||||||
* }
|
|
||||||
* }}</pre>
|
|
||||||
* Both the type field name ({@code "type"}) and the type labels ({@code
|
|
||||||
* "Rectangle"}) are configurable.
|
|
||||||
*
|
|
||||||
* <h3>Registering Types</h3>
|
|
||||||
* Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field
|
|
||||||
* name to the {@link #of} factory method. If you don't supply an explicit type
|
|
||||||
* field name, {@code "type"} will be used. <pre> {@code
|
|
||||||
* RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory
|
|
||||||
* = RuntimeTypeAdapterFactory.of(Shape.class, "type");
|
|
||||||
* }</pre>
|
|
||||||
* Next register all of your subtypes. Every subtype must be explicitly
|
|
||||||
* registered. This protects your application from injection attacks. If you
|
|
||||||
* don't supply an explicit type label, the type's simple name will be used.
|
|
||||||
* <pre> {@code
|
|
||||||
* shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
|
|
||||||
* shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
|
|
||||||
* shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
|
|
||||||
* }</pre>
|
|
||||||
* Finally, register the type adapter factory in your application's GSON builder:
|
|
||||||
* <pre> {@code
|
|
||||||
* Gson gson = new GsonBuilder()
|
|
||||||
* .registerTypeAdapterFactory(shapeAdapterFactory)
|
|
||||||
* .create();
|
|
||||||
* }</pre>
|
|
||||||
* Like {@code GsonBuilder}, this API supports chaining: <pre> {@code
|
|
||||||
* RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
|
|
||||||
* .registerSubtype(Rectangle.class)
|
|
||||||
* .registerSubtype(Circle.class)
|
|
||||||
* .registerSubtype(Diamond.class);
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <h3>Serialization and deserialization</h3>
|
|
||||||
* In order to serialize and deserialize a polymorphic object,
|
|
||||||
* you must specify the base type explicitly.
|
|
||||||
* <pre> {@code
|
|
||||||
* Diamond diamond = new Diamond();
|
|
||||||
* String json = gson.toJson(diamond, Shape.class);
|
|
||||||
* }</pre>
|
|
||||||
* And then:
|
|
||||||
* <pre> {@code
|
|
||||||
* Shape shape = gson.fromJson(json, Shape.class);
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public final class RuntimeTypeAdapterFactory<T> implements TypeAdapterFactory {
|
|
||||||
private final Class<?> baseType;
|
|
||||||
private final String typeFieldName;
|
|
||||||
private final Map<String, Class<?>> labelToSubtype = new LinkedHashMap<String, Class<?>>();
|
|
||||||
private final Map<Class<?>, String> subtypeToLabel = new LinkedHashMap<Class<?>, String>();
|
|
||||||
private final boolean maintainType;
|
|
||||||
|
|
||||||
private RuntimeTypeAdapterFactory(Class<?> baseType, String typeFieldName, boolean maintainType) {
|
|
||||||
if (typeFieldName == null || baseType == null) {
|
|
||||||
throw new NullPointerException();
|
|
||||||
}
|
|
||||||
this.baseType = baseType;
|
|
||||||
this.typeFieldName = typeFieldName;
|
|
||||||
this.maintainType = maintainType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new runtime type adapter using for {@code baseType} using {@code
|
|
||||||
* typeFieldName} as the type field name. Type field names are case sensitive.
|
|
||||||
* {@code maintainType} flag decide if the type will be stored in pojo or not.
|
|
||||||
*/
|
|
||||||
public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName, boolean maintainType) {
|
|
||||||
return new RuntimeTypeAdapterFactory<T>(baseType, typeFieldName, maintainType);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new runtime type adapter using for {@code baseType} using {@code
|
|
||||||
* typeFieldName} as the type field name. Type field names are case sensitive.
|
|
||||||
*/
|
|
||||||
public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName) {
|
|
||||||
return new RuntimeTypeAdapterFactory<T>(baseType, typeFieldName, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new runtime type adapter for {@code baseType} using {@code "type"} as
|
|
||||||
* the type field name.
|
|
||||||
*/
|
|
||||||
public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType) {
|
|
||||||
return new RuntimeTypeAdapterFactory<T>(baseType, "type", false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers {@code type} identified by {@code label}. Labels are case
|
|
||||||
* sensitive.
|
|
||||||
*
|
|
||||||
* @throws IllegalArgumentException if either {@code type} or {@code label}
|
|
||||||
* have already been registered on this type adapter.
|
|
||||||
*/
|
|
||||||
public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type, String label) {
|
|
||||||
if (type == null || label == null) {
|
|
||||||
throw new NullPointerException();
|
|
||||||
}
|
|
||||||
if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) {
|
|
||||||
throw new IllegalArgumentException("types and labels must be unique");
|
|
||||||
}
|
|
||||||
labelToSubtype.put(label, type);
|
|
||||||
subtypeToLabel.put(type, label);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers {@code type} identified by its {@link Class#getSimpleName simple
|
|
||||||
* name}. Labels are case sensitive.
|
|
||||||
*
|
|
||||||
* @throws IllegalArgumentException if either {@code type} or its simple name
|
|
||||||
* have already been registered on this type adapter.
|
|
||||||
*/
|
|
||||||
public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type) {
|
|
||||||
return registerSubtype(type, type.getSimpleName());
|
|
||||||
}
|
|
||||||
|
|
||||||
public <R> TypeAdapter<R> create(Gson gson, TypeToken<R> type) {
|
|
||||||
if (type.getRawType() != baseType) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, TypeAdapter<?>> labelToDelegate
|
|
||||||
= new LinkedHashMap<String, TypeAdapter<?>>();
|
|
||||||
final Map<Class<?>, TypeAdapter<?>> subtypeToDelegate
|
|
||||||
= new LinkedHashMap<Class<?>, TypeAdapter<?>>();
|
|
||||||
for (Map.Entry<String, Class<?>> entry : labelToSubtype.entrySet()) {
|
|
||||||
TypeAdapter<?> delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue()));
|
|
||||||
labelToDelegate.put(entry.getKey(), delegate);
|
|
||||||
subtypeToDelegate.put(entry.getValue(), delegate);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TypeAdapter<R>() {
|
|
||||||
@Override public R read(JsonReader in) throws IOException {
|
|
||||||
JsonElement jsonElement = Streams.parse(in);
|
|
||||||
JsonElement labelJsonElement;
|
|
||||||
if (maintainType) {
|
|
||||||
labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName);
|
|
||||||
} else {
|
|
||||||
labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (labelJsonElement == null) {
|
|
||||||
throw new JsonParseException("cannot deserialize " + baseType
|
|
||||||
+ " because it does not define a field named " + typeFieldName);
|
|
||||||
}
|
|
||||||
String label = labelJsonElement.getAsString();
|
|
||||||
@SuppressWarnings("unchecked") // registration requires that subtype extends T
|
|
||||||
TypeAdapter<R> delegate = (TypeAdapter<R>) labelToDelegate.get(label);
|
|
||||||
if (delegate == null) {
|
|
||||||
|
|
||||||
Log.e("RuntimeTypeAdapter", "cannot deserialize " + baseType + " subtype named "
|
|
||||||
+ label + "; did you forget to register a subtype? " +jsonElement);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return delegate.fromJsonTree(jsonElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void write(JsonWriter out, R value) throws IOException {
|
|
||||||
Class<?> srcType = value.getClass();
|
|
||||||
String label = subtypeToLabel.get(srcType);
|
|
||||||
@SuppressWarnings("unchecked") // registration requires that subtype extends T
|
|
||||||
TypeAdapter<R> delegate = (TypeAdapter<R>) subtypeToDelegate.get(srcType);
|
|
||||||
if (delegate == null) {
|
|
||||||
throw new JsonParseException("cannot serialize " + srcType.getName()
|
|
||||||
+ "; did you forget to register a subtype?");
|
|
||||||
}
|
|
||||||
JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject();
|
|
||||||
|
|
||||||
if (maintainType) {
|
|
||||||
Streams.write(jsonObject, out);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonObject clone = new JsonObject();
|
|
||||||
|
|
||||||
if (jsonObject.has(typeFieldName)) {
|
|
||||||
throw new JsonParseException("cannot serialize " + srcType.getName()
|
|
||||||
+ " because it already defines a field named " + typeFieldName);
|
|
||||||
}
|
|
||||||
clone.add(typeFieldName, new JsonPrimitive(label));
|
|
||||||
|
|
||||||
for (Map.Entry<String, JsonElement> e : jsonObject.entrySet()) {
|
|
||||||
clone.add(e.getKey(), e.getValue());
|
|
||||||
}
|
|
||||||
Streams.write(clone, out);
|
|
||||||
}
|
|
||||||
}.nullSafe();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
package fr.free.nrw.commons.wikidata.json
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.google.gson.JsonParseException
|
||||||
|
import com.google.gson.JsonPrimitive
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import com.google.gson.TypeAdapterFactory
|
||||||
|
import com.google.gson.internal.Streams
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import com.google.gson.stream.JsonReader
|
||||||
|
import com.google.gson.stream.JsonWriter
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2011 Google Inc.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapts values whose runtime type may differ from their declaration type. This
|
||||||
|
* is necessary when a field's type is not the same type that GSON should create
|
||||||
|
* when deserializing that field. For example, consider these types:
|
||||||
|
* <pre> `abstract class Shape {
|
||||||
|
* int x;
|
||||||
|
* int y;
|
||||||
|
* }
|
||||||
|
* class Circle extends Shape {
|
||||||
|
* int radius;
|
||||||
|
* }
|
||||||
|
* class Rectangle extends Shape {
|
||||||
|
* int width;
|
||||||
|
* int height;
|
||||||
|
* }
|
||||||
|
* class Diamond extends Shape {
|
||||||
|
* int width;
|
||||||
|
* int height;
|
||||||
|
* }
|
||||||
|
* class Drawing {
|
||||||
|
* Shape bottomShape;
|
||||||
|
* Shape topShape;
|
||||||
|
* }
|
||||||
|
`</pre> *
|
||||||
|
*
|
||||||
|
* Without additional type information, the serialized JSON is ambiguous. Is
|
||||||
|
* the bottom shape in this drawing a rectangle or a diamond? <pre> `{
|
||||||
|
* "bottomShape": {
|
||||||
|
* "width": 10,
|
||||||
|
* "height": 5,
|
||||||
|
* "x": 0,
|
||||||
|
* "y": 0
|
||||||
|
* },
|
||||||
|
* "topShape": {
|
||||||
|
* "radius": 2,
|
||||||
|
* "x": 4,
|
||||||
|
* "y": 1
|
||||||
|
* }
|
||||||
|
* }`</pre>
|
||||||
|
* This class addresses this problem by adding type information to the
|
||||||
|
* serialized JSON and honoring that type information when the JSON is
|
||||||
|
* deserialized: <pre> `{
|
||||||
|
* "bottomShape": {
|
||||||
|
* "type": "Diamond",
|
||||||
|
* "width": 10,
|
||||||
|
* "height": 5,
|
||||||
|
* "x": 0,
|
||||||
|
* "y": 0
|
||||||
|
* },
|
||||||
|
* "topShape": {
|
||||||
|
* "type": "Circle",
|
||||||
|
* "radius": 2,
|
||||||
|
* "x": 4,
|
||||||
|
* "y": 1
|
||||||
|
* }
|
||||||
|
* }`</pre>
|
||||||
|
* Both the type field name (`"type"`) and the type labels (`"Rectangle"`) are configurable.
|
||||||
|
*
|
||||||
|
* <h3>Registering Types</h3>
|
||||||
|
* Create a `RuntimeTypeAdapterFactory` by passing the base type and type field
|
||||||
|
* name to the [.of] factory method. If you don't supply an explicit type
|
||||||
|
* field name, `"type"` will be used. <pre> `RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory
|
||||||
|
* = RuntimeTypeAdapterFactory.of(Shape.class, "type");
|
||||||
|
`</pre> *
|
||||||
|
* Next register all of your subtypes. Every subtype must be explicitly
|
||||||
|
* registered. This protects your application from injection attacks. If you
|
||||||
|
* don't supply an explicit type label, the type's simple name will be used.
|
||||||
|
* <pre> `shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
|
||||||
|
* shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
|
||||||
|
* shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
|
||||||
|
`</pre> *
|
||||||
|
* Finally, register the type adapter factory in your application's GSON builder:
|
||||||
|
* <pre> `Gson gson = new GsonBuilder()
|
||||||
|
* .registerTypeAdapterFactory(shapeAdapterFactory)
|
||||||
|
* .create();
|
||||||
|
`</pre> *
|
||||||
|
* Like `GsonBuilder`, this API supports chaining: <pre> `RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
|
||||||
|
* .registerSubtype(Rectangle.class)
|
||||||
|
* .registerSubtype(Circle.class)
|
||||||
|
* .registerSubtype(Diamond.class);
|
||||||
|
`</pre> *
|
||||||
|
*
|
||||||
|
* <h3>Serialization and deserialization</h3>
|
||||||
|
* In order to serialize and deserialize a polymorphic object,
|
||||||
|
* you must specify the base type explicitly.
|
||||||
|
* <pre> `Diamond diamond = new Diamond();
|
||||||
|
* String json = gson.toJson(diamond, Shape.class);
|
||||||
|
`</pre> *
|
||||||
|
* And then:
|
||||||
|
* <pre> `Shape shape = gson.fromJson(json, Shape.class);
|
||||||
|
`</pre> *
|
||||||
|
*/
|
||||||
|
class RuntimeTypeAdapterFactory<T>(
|
||||||
|
baseType: Class<*>?,
|
||||||
|
typeFieldName: String?,
|
||||||
|
maintainType: Boolean
|
||||||
|
) : TypeAdapterFactory {
|
||||||
|
|
||||||
|
private val baseType: Class<*>
|
||||||
|
private val typeFieldName: String
|
||||||
|
private val labelToSubtype = mutableMapOf<String, Class<*>>()
|
||||||
|
private val subtypeToLabel = mutableMapOf<Class<*>, String>()
|
||||||
|
private val maintainType: Boolean
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (typeFieldName == null || baseType == null) {
|
||||||
|
throw NullPointerException()
|
||||||
|
}
|
||||||
|
this.baseType = baseType
|
||||||
|
this.typeFieldName = typeFieldName
|
||||||
|
this.maintainType = maintainType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers `type` identified by `label`. Labels are case
|
||||||
|
* sensitive.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if either `type` or `label`
|
||||||
|
* have already been registered on this type adapter.
|
||||||
|
*/
|
||||||
|
fun registerSubtype(type: Class<out T>?, label: String?): RuntimeTypeAdapterFactory<T> {
|
||||||
|
if (type == null || label == null) {
|
||||||
|
throw NullPointerException()
|
||||||
|
}
|
||||||
|
require(!(subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label))) {
|
||||||
|
"types and labels must be unique"
|
||||||
|
}
|
||||||
|
|
||||||
|
labelToSubtype[label] = type
|
||||||
|
subtypeToLabel[type] = label
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers `type` identified by its [simple][Class.getSimpleName]. Labels are case sensitive.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if either `type` or its simple name
|
||||||
|
* have already been registered on this type adapter.
|
||||||
|
*/
|
||||||
|
fun registerSubtype(type: Class<out T>): RuntimeTypeAdapterFactory<T> {
|
||||||
|
return registerSubtype(type, type.simpleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun <R : Any> create(gson: Gson, type: TypeToken<R>): TypeAdapter<R>? {
|
||||||
|
if (type.rawType != baseType) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val labelToDelegate = mutableMapOf<String, TypeAdapter<*>>()
|
||||||
|
val subtypeToDelegate = mutableMapOf<Class<*>, TypeAdapter<*>>()
|
||||||
|
for ((key, value) in labelToSubtype) {
|
||||||
|
val delegate = gson.getDelegateAdapter(
|
||||||
|
this, TypeToken.get(
|
||||||
|
value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
labelToDelegate[key] = delegate
|
||||||
|
subtypeToDelegate[value] = delegate
|
||||||
|
}
|
||||||
|
|
||||||
|
return object : TypeAdapter<R>() {
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun read(reader: JsonReader): R? {
|
||||||
|
val jsonElement = Streams.parse(reader)
|
||||||
|
val labelJsonElement = if (maintainType) {
|
||||||
|
jsonElement.asJsonObject[typeFieldName]
|
||||||
|
} else {
|
||||||
|
jsonElement.asJsonObject.remove(typeFieldName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labelJsonElement == null) {
|
||||||
|
throw JsonParseException(
|
||||||
|
"cannot deserialize $baseType because it does not define a field named $typeFieldName"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val label = labelJsonElement.asString
|
||||||
|
val delegate = labelToDelegate[label] as TypeAdapter<R>?
|
||||||
|
if (delegate == null) {
|
||||||
|
Timber.tag("RuntimeTypeAdapter").e(
|
||||||
|
"cannot deserialize $baseType subtype named $label; did you forget to register a subtype? $jsonElement"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return delegate.fromJsonTree(jsonElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun write(out: JsonWriter, value: R) {
|
||||||
|
val srcType: Class<*> = value::class.java.javaClass
|
||||||
|
val delegate =
|
||||||
|
subtypeToDelegate[srcType] as TypeAdapter<R?>? ?: throw JsonParseException(
|
||||||
|
"cannot serialize ${srcType.name}; did you forget to register a subtype?"
|
||||||
|
)
|
||||||
|
|
||||||
|
val jsonObject = delegate.toJsonTree(value).asJsonObject
|
||||||
|
if (maintainType) {
|
||||||
|
Streams.write(jsonObject, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonObject.has(typeFieldName)) {
|
||||||
|
throw JsonParseException(
|
||||||
|
"cannot serialize ${srcType.name} because it already defines a field named $typeFieldName"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val clone = JsonObject()
|
||||||
|
val label = subtypeToLabel[srcType]
|
||||||
|
clone.add(typeFieldName, JsonPrimitive(label))
|
||||||
|
for ((key, value1) in jsonObject.entrySet()) {
|
||||||
|
clone.add(key, value1)
|
||||||
|
}
|
||||||
|
Streams.write(clone, out)
|
||||||
|
}
|
||||||
|
}.nullSafe()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive.
|
||||||
|
* `maintainType` flag decide if the type will be stored in pojo or not.
|
||||||
|
*/
|
||||||
|
fun <T> of(
|
||||||
|
baseType: Class<T>,
|
||||||
|
typeFieldName: String,
|
||||||
|
maintainType: Boolean
|
||||||
|
): RuntimeTypeAdapterFactory<T> =
|
||||||
|
RuntimeTypeAdapterFactory(baseType, typeFieldName, maintainType)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive.
|
||||||
|
*/
|
||||||
|
fun <T> of(baseType: Class<T>, typeFieldName: String): RuntimeTypeAdapterFactory<T> =
|
||||||
|
RuntimeTypeAdapterFactory(baseType, typeFieldName, false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new runtime type adapter for `baseType` using `"type"` as
|
||||||
|
* the type field name.
|
||||||
|
*/
|
||||||
|
fun <T> of(baseType: Class<T>): RuntimeTypeAdapterFactory<T> =
|
||||||
|
RuntimeTypeAdapterFactory(baseType, "type", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata.json;
|
|
||||||
|
|
||||||
import android.net.Uri;
|
|
||||||
|
|
||||||
import com.google.gson.TypeAdapter;
|
|
||||||
import com.google.gson.stream.JsonReader;
|
|
||||||
import com.google.gson.stream.JsonWriter;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
public class UriTypeAdapter extends TypeAdapter<Uri> {
|
|
||||||
@Override
|
|
||||||
public void write(JsonWriter out, Uri value) throws IOException {
|
|
||||||
out.value(value.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Uri read(JsonReader in) throws IOException {
|
|
||||||
String url = in.nextString();
|
|
||||||
return Uri.parse(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package fr.free.nrw.commons.wikidata.json
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import com.google.gson.stream.JsonReader
|
||||||
|
import com.google.gson.stream.JsonWriter
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class UriTypeAdapter : TypeAdapter<Uri>() {
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun write(out: JsonWriter, value: Uri) {
|
||||||
|
out.value(value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun read(reader: JsonReader): Uri {
|
||||||
|
return Uri.parse(reader.nextString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata.json;
|
|
||||||
|
|
||||||
import android.net.Uri;
|
|
||||||
|
|
||||||
import com.google.gson.JsonParseException;
|
|
||||||
import com.google.gson.TypeAdapter;
|
|
||||||
import com.google.gson.stream.JsonReader;
|
|
||||||
import com.google.gson.stream.JsonToken;
|
|
||||||
import com.google.gson.stream.JsonWriter;
|
|
||||||
|
|
||||||
import fr.free.nrw.commons.wikidata.model.WikiSite;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
public class WikiSiteTypeAdapter extends TypeAdapter<WikiSite> {
|
|
||||||
private static final String DOMAIN = "domain";
|
|
||||||
private static final String LANGUAGE_CODE = "languageCode";
|
|
||||||
|
|
||||||
@Override public void write(JsonWriter out, WikiSite value) throws IOException {
|
|
||||||
out.beginObject();
|
|
||||||
out.name(DOMAIN);
|
|
||||||
out.value(value.url());
|
|
||||||
|
|
||||||
out.name(LANGUAGE_CODE);
|
|
||||||
out.value(value.languageCode());
|
|
||||||
out.endObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public WikiSite read(JsonReader in) throws IOException {
|
|
||||||
// todo: legacy; remove in June 2018
|
|
||||||
if (in.peek() == JsonToken.STRING) {
|
|
||||||
return new WikiSite(Uri.parse(in.nextString()));
|
|
||||||
}
|
|
||||||
|
|
||||||
String domain = null;
|
|
||||||
String languageCode = null;
|
|
||||||
in.beginObject();
|
|
||||||
while (in.hasNext()) {
|
|
||||||
String field = in.nextName();
|
|
||||||
String val = in.nextString();
|
|
||||||
switch (field) {
|
|
||||||
case DOMAIN:
|
|
||||||
domain = val;
|
|
||||||
break;
|
|
||||||
case LANGUAGE_CODE:
|
|
||||||
languageCode = val;
|
|
||||||
break;
|
|
||||||
default: break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
in.endObject();
|
|
||||||
|
|
||||||
if (domain == null) {
|
|
||||||
throw new JsonParseException("Missing domain");
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo: legacy; remove in June 2018
|
|
||||||
if (languageCode == null) {
|
|
||||||
return new WikiSite(domain);
|
|
||||||
}
|
|
||||||
return new WikiSite(domain, languageCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package fr.free.nrw.commons.wikidata.json
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.google.gson.JsonParseException
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import com.google.gson.stream.JsonReader
|
||||||
|
import com.google.gson.stream.JsonToken
|
||||||
|
import com.google.gson.stream.JsonWriter
|
||||||
|
import fr.free.nrw.commons.wikidata.model.WikiSite
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class WikiSiteTypeAdapter : TypeAdapter<WikiSite>() {
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun write(out: JsonWriter, value: WikiSite) {
|
||||||
|
out.beginObject()
|
||||||
|
out.name(DOMAIN)
|
||||||
|
out.value(value.url())
|
||||||
|
|
||||||
|
out.name(LANGUAGE_CODE)
|
||||||
|
out.value(value.languageCode())
|
||||||
|
out.endObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun read(reader: JsonReader): WikiSite {
|
||||||
|
// todo: legacy; remove reader June 2018
|
||||||
|
if (reader.peek() == JsonToken.STRING) {
|
||||||
|
return WikiSite(Uri.parse(reader.nextString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var domain: String? = null
|
||||||
|
var languageCode: String? = null
|
||||||
|
reader.beginObject()
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
val field = reader.nextName()
|
||||||
|
val value = reader.nextString()
|
||||||
|
when (field) {
|
||||||
|
DOMAIN -> domain = value
|
||||||
|
LANGUAGE_CODE -> languageCode = value
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.endObject()
|
||||||
|
|
||||||
|
if (domain == null) {
|
||||||
|
throw JsonParseException("Missing domain")
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: legacy; remove reader June 2018
|
||||||
|
return if (languageCode == null) {
|
||||||
|
WikiSite(domain)
|
||||||
|
} else {
|
||||||
|
WikiSite(domain, languageCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DOMAIN = "domain"
|
||||||
|
private const val LANGUAGE_CODE = "languageCode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata.json.annotations;
|
|
||||||
|
|
||||||
|
|
||||||
import java.lang.annotation.Documented;
|
|
||||||
import java.lang.annotation.Retention;
|
|
||||||
import java.lang.annotation.RetentionPolicy;
|
|
||||||
import java.lang.annotation.Target;
|
|
||||||
|
|
||||||
import static java.lang.annotation.ElementType.FIELD;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return
|
|
||||||
* an instantiated object.
|
|
||||||
*
|
|
||||||
* E.g.: @NonNull @Required private String title;
|
|
||||||
*/
|
|
||||||
@Documented
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
@Target(FIELD)
|
|
||||||
public @interface Required {
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package fr.free.nrw.commons.wikidata.json.annotations
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return
|
||||||
|
* an instantiated object.
|
||||||
|
*
|
||||||
|
* E.g.: @NonNull @Required private String title;
|
||||||
|
*/
|
||||||
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
|
@Target(AnnotationTarget.FIELD)
|
||||||
|
annotation class Required
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata.model;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Model class for API response obtained from search for depictions
|
|
||||||
*/
|
|
||||||
public class DepictSearchResponse {
|
|
||||||
private final List<DepictSearchItem> search;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor to initialise value of the search object
|
|
||||||
*/
|
|
||||||
public DepictSearchResponse(List<DepictSearchItem> search) {
|
|
||||||
this.search = search;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return List<DepictSearchItem> for the DepictSearchResponse
|
|
||||||
*/
|
|
||||||
public List<DepictSearchItem> getSearch() {
|
|
||||||
return search;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package fr.free.nrw.commons.wikidata.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model class for API response obtained from search for depictions
|
||||||
|
*/
|
||||||
|
class DepictSearchResponse(
|
||||||
|
/**
|
||||||
|
* @return List<DepictSearchItem> for the DepictSearchResponse
|
||||||
|
</DepictSearchItem>
|
||||||
|
*/
|
||||||
|
val search: List<DepictSearchItem>
|
||||||
|
)
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata.model;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import com.google.gson.annotations.SerializedName;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
|
||||||
import fr.free.nrw.commons.wikidata.mwapi.MwResponse;
|
|
||||||
|
|
||||||
|
|
||||||
public class Entities extends MwResponse {
|
|
||||||
@Nullable private Map<String, Entity> entities;
|
|
||||||
private int success;
|
|
||||||
|
|
||||||
@NotNull
|
|
||||||
public Map<String, Entity> entities() {
|
|
||||||
return entities != null ? entities : Collections.emptyMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getSuccess() {
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable public Entity getFirst() {
|
|
||||||
if (entities == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return entities.values().iterator().next();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void postProcess() {
|
|
||||||
if (getFirst() != null && getFirst().isMissing()) {
|
|
||||||
throw new RuntimeException("The requested entity was not found.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class Entity {
|
|
||||||
@Nullable private String type;
|
|
||||||
@Nullable private String id;
|
|
||||||
@Nullable private Map<String, Label> labels;
|
|
||||||
@Nullable private Map<String, Label> descriptions;
|
|
||||||
@Nullable private Map<String, SiteLink> sitelinks;
|
|
||||||
@Nullable @SerializedName(value = "statements", alternate = "claims") private Map<String, List<StatementPartial>> statements;
|
|
||||||
@Nullable private String missing;
|
|
||||||
|
|
||||||
@NonNull public String id() {
|
|
||||||
return StringUtils.defaultString(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull public Map<String, Label> labels() {
|
|
||||||
return labels != null ? labels : Collections.emptyMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull public Map<String, Label> descriptions() {
|
|
||||||
return descriptions != null ? descriptions : Collections.emptyMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull public Map<String, SiteLink> sitelinks() {
|
|
||||||
return sitelinks != null ? sitelinks : Collections.emptyMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
public Map<String, List<StatementPartial>> getStatements() {
|
|
||||||
return statements;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isMissing() {
|
|
||||||
return "-1".equals(id) && missing != null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class Label {
|
|
||||||
@Nullable private String language;
|
|
||||||
@Nullable private String value;
|
|
||||||
|
|
||||||
public Label(@Nullable final String language, @Nullable final String value) {
|
|
||||||
this.language = language;
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull public String language() {
|
|
||||||
return StringUtils.defaultString(language);
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull public String value() {
|
|
||||||
return StringUtils.defaultString(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class SiteLink {
|
|
||||||
@Nullable private String site;
|
|
||||||
@Nullable private String title;
|
|
||||||
|
|
||||||
@NonNull public String getSite() {
|
|
||||||
return StringUtils.defaultString(site);
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull public String getTitle() {
|
|
||||||
return StringUtils.defaultString(title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package fr.free.nrw.commons.wikidata.model
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import fr.free.nrw.commons.wikidata.mwapi.MwResponse
|
||||||
|
import org.apache.commons.lang3.StringUtils
|
||||||
|
|
||||||
|
class Entities : MwResponse() {
|
||||||
|
private val entities: Map<String, Entity>? = null
|
||||||
|
val success: Int = 0
|
||||||
|
|
||||||
|
fun entities(): Map<String, Entity> = entities ?: emptyMap()
|
||||||
|
|
||||||
|
private val first : Entity?
|
||||||
|
get() = entities?.values?.iterator()?.next()
|
||||||
|
|
||||||
|
override fun postProcess() {
|
||||||
|
first?.let {
|
||||||
|
if (it.isMissing()) throw RuntimeException("The requested entity was not found.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Entity {
|
||||||
|
private val type: String? = null
|
||||||
|
private val id: String? = null
|
||||||
|
private val labels: Map<String, Label>? = null
|
||||||
|
private val descriptions: Map<String, Label>? = null
|
||||||
|
private val sitelinks: Map<String, SiteLink>? = null
|
||||||
|
|
||||||
|
@SerializedName(value = "statements", alternate = ["claims"])
|
||||||
|
val statements: Map<String, List<StatementPartial>>? = null
|
||||||
|
private val missing: String? = null
|
||||||
|
|
||||||
|
fun id(): String =
|
||||||
|
StringUtils.defaultString(id)
|
||||||
|
|
||||||
|
fun labels(): Map<String, Label> =
|
||||||
|
labels ?: emptyMap()
|
||||||
|
|
||||||
|
fun descriptions(): Map<String, Label> =
|
||||||
|
descriptions ?: emptyMap()
|
||||||
|
|
||||||
|
fun sitelinks(): Map<String, SiteLink> =
|
||||||
|
sitelinks ?: emptyMap()
|
||||||
|
|
||||||
|
fun isMissing(): Boolean =
|
||||||
|
"-1" == id && missing != null
|
||||||
|
}
|
||||||
|
|
||||||
|
class Label(private val language: String?, private val value: String?) {
|
||||||
|
fun language(): String =
|
||||||
|
StringUtils.defaultString(language)
|
||||||
|
|
||||||
|
fun value(): String =
|
||||||
|
StringUtils.defaultString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
class SiteLink {
|
||||||
|
val site: String? = null
|
||||||
|
get() = StringUtils.defaultString(field)
|
||||||
|
|
||||||
|
private val title: String? = null
|
||||||
|
get() = StringUtils.defaultString(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,292 +0,0 @@
|
||||||
package fr.free.nrw.commons.wikidata.model;
|
|
||||||
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Parcel;
|
|
||||||
import android.os.Parcelable;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName;
|
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import fr.free.nrw.commons.language.AppLanguageLookUpTable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The base URL and Wikipedia language code for a MediaWiki site. Examples:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <lh>Name: scheme / authority / language code</lh>
|
|
||||||
* <li>English Wikipedia: HTTPS / en.wikipedia.org / en</li>
|
|
||||||
* <li>Chinese Wikipedia: HTTPS / zh.wikipedia.org / zh-hans or zh-hant</li>
|
|
||||||
* <li>Meta-Wiki: HTTPS / meta.wikimedia.org / (none)</li>
|
|
||||||
* <li>Test Wikipedia: HTTPS / test.wikipedia.org / test</li>
|
|
||||||
* <li>Võro Wikipedia: HTTPS / fiu-vro.wikipedia.org / fiu-vro</li>
|
|
||||||
* <li>Simple English Wikipedia: HTTPS / simple.wikipedia.org / simple</li>
|
|
||||||
* <li>Simple English Wikipedia (beta cluster mirror): HTTP / simple.wikipedia.beta.wmflabs.org / simple</li>
|
|
||||||
* <li>Development: HTTP / 192.168.1.11:8080 / (none)</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <strong>As shown above, the language code or mapping is part of the authority:</strong>
|
|
||||||
* <ul>
|
|
||||||
* <lh>Validity: authority / language code</lh>
|
|
||||||
* <li>Correct: "test.wikipedia.org" / "test"</li>
|
|
||||||
* <li>Correct: "wikipedia.org", ""</li>
|
|
||||||
* <li>Correct: "no.wikipedia.org", "nb"</li>
|
|
||||||
* <li>Incorrect: "wikipedia.org", "test"</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
public class WikiSite implements Parcelable {
|
|
||||||
private static String WIKIPEDIA_URL = "https://wikipedia.org/";
|
|
||||||
|
|
||||||
public static final String DEFAULT_SCHEME = "https";
|
|
||||||
private static String DEFAULT_BASE_URL = WIKIPEDIA_URL;
|
|
||||||
|
|
||||||
public static final Parcelable.Creator<WikiSite> CREATOR = new Parcelable.Creator<WikiSite>() {
|
|
||||||
@Override
|
|
||||||
public WikiSite createFromParcel(Parcel in) {
|
|
||||||
return new WikiSite(in);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public WikiSite[] newArray(int size) {
|
|
||||||
return new WikiSite[size];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// todo: remove @SerializedName. this is now in the TypeAdapter and a "uri" case may be added
|
|
||||||
@SerializedName("domain") @NonNull private final Uri uri;
|
|
||||||
@NonNull private String languageCode;
|
|
||||||
|
|
||||||
public static WikiSite forLanguageCode(@NonNull String languageCode) {
|
|
||||||
Uri uri = ensureScheme(Uri.parse(DEFAULT_BASE_URL));
|
|
||||||
return new WikiSite((languageCode.isEmpty()
|
|
||||||
? "" : (languageCodeToSubdomain(languageCode) + ".")) + uri.getAuthority(),
|
|
||||||
languageCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
public WikiSite(@NonNull Uri uri) {
|
|
||||||
Uri tempUri = ensureScheme(uri);
|
|
||||||
String authority = tempUri.getAuthority();
|
|
||||||
if (("wikipedia.org".equals(authority) || "www.wikipedia.org".equals(authority))
|
|
||||||
&& tempUri.getPath() != null && tempUri.getPath().startsWith("/wiki")) {
|
|
||||||
// Special case for Wikipedia only: assume English subdomain when none given.
|
|
||||||
authority = "en.wikipedia.org";
|
|
||||||
}
|
|
||||||
String langVariant = getLanguageVariantFromUri(tempUri);
|
|
||||||
if (!TextUtils.isEmpty(langVariant)) {
|
|
||||||
languageCode = langVariant;
|
|
||||||
} else {
|
|
||||||
languageCode = authorityToLanguageCode(authority);
|
|
||||||
}
|
|
||||||
this.uri = new Uri.Builder()
|
|
||||||
.scheme(tempUri.getScheme())
|
|
||||||
.encodedAuthority(authority)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get language variant code from a Uri, e.g. "zh-*", otherwise returns empty string. */
|
|
||||||
@NonNull
|
|
||||||
private String getLanguageVariantFromUri(@NonNull Uri uri) {
|
|
||||||
if (TextUtils.isEmpty(uri.getPath())) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
String[] parts = StringUtils.split(StringUtils.defaultString(uri.getPath()), '/');
|
|
||||||
return parts.length > 1 && !parts[0].equals("wiki") ? parts[0] : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public WikiSite(@NonNull String url) {
|
|
||||||
this(url.startsWith("http") ? Uri.parse(url) : url.startsWith("//")
|
|
||||||
? Uri.parse(DEFAULT_SCHEME + ":" + url) : Uri.parse(DEFAULT_SCHEME + "://" + url));
|
|
||||||
}
|
|
||||||
|
|
||||||
public WikiSite(@NonNull String authority, @NonNull String languageCode) {
|
|
||||||
this(authority);
|
|
||||||
this.languageCode = languageCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
public String scheme() {
|
|
||||||
return TextUtils.isEmpty(uri.getScheme()) ? DEFAULT_SCHEME : uri.getScheme();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return The complete wiki authority including language subdomain but not including scheme,
|
|
||||||
* authentication, port, nor trailing slash.
|
|
||||||
*
|
|
||||||
* @see <a href='https://en.wikipedia.org/wiki/Uniform_Resource_Locator#Syntax'>URL syntax</a>
|
|
||||||
*/
|
|
||||||
@NonNull
|
|
||||||
public String authority() {
|
|
||||||
return uri.getAuthority();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like {@link #authority()} but with a "m." between the language subdomain and the rest of the host.
|
|
||||||
* Examples:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>English Wikipedia: en.m.wikipedia.org</li>
|
|
||||||
* <li>Chinese Wikipedia: zh.m.wikipedia.org</li>
|
|
||||||
* <li>Meta-Wiki: meta.m.wikimedia.org</li>
|
|
||||||
* <li>Test Wikipedia: test.m.wikipedia.org</li>
|
|
||||||
* <li>Võro Wikipedia: fiu-vro.m.wikipedia.org</li>
|
|
||||||
* <li>Simple English Wikipedia: simple.m.wikipedia.org</li>
|
|
||||||
* <li>Simple English Wikipedia (beta cluster mirror): simple.m.wikipedia.beta.wmflabs.org</li>
|
|
||||||
* <li>Development: m.192.168.1.11</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
@NonNull
|
|
||||||
public String mobileAuthority() {
|
|
||||||
return authorityToMobile(authority());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get wiki's mobile URL
|
|
||||||
* Eg. https://en.m.wikipedia.org
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
public String mobileUrl() {
|
|
||||||
return String.format("%1$s://%2$s", scheme(), mobileAuthority());
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
public String subdomain() {
|
|
||||||
return languageCodeToSubdomain(languageCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return A path without an authority for the segment including a leading "/".
|
|
||||||
*/
|
|
||||||
@NonNull
|
|
||||||
public String path(@NonNull String segment) {
|
|
||||||
return "/w/" + segment;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@NonNull public Uri uri() {
|
|
||||||
return uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return The canonical URL. e.g., https://en.wikipedia.org.
|
|
||||||
*/
|
|
||||||
@NonNull public String url() {
|
|
||||||
return uri.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return The canonical URL for segment. e.g., https://en.wikipedia.org/w/foo.
|
|
||||||
*/
|
|
||||||
@NonNull public String url(@NonNull String segment) {
|
|
||||||
return url() + path(segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return The wiki language code which may differ from the language subdomain. Empty if
|
|
||||||
* language code is unknown. Ex: "en", "zh-hans", ""
|
|
||||||
*
|
|
||||||
* @see AppLanguageLookUpTable
|
|
||||||
*/
|
|
||||||
@NonNull
|
|
||||||
public String languageCode() {
|
|
||||||
return languageCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-generated
|
|
||||||
@Override
|
|
||||||
public boolean equals(Object o) {
|
|
||||||
if (this == o) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (o == null || getClass() != o.getClass()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
WikiSite wiki = (WikiSite) o;
|
|
||||||
|
|
||||||
if (!uri.equals(wiki.uri)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return languageCode.equals(wiki.languageCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-generated
|
|
||||||
@Override
|
|
||||||
public int hashCode() {
|
|
||||||
int result = uri.hashCode();
|
|
||||||
result = 31 * result + languageCode.hashCode();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-generated
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return "WikiSite{"
|
|
||||||
+ "uri=" + uri
|
|
||||||
+ ", languageCode='" + languageCode + '\''
|
|
||||||
+ '}';
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int describeContents() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
|
||||||
dest.writeParcelable(uri, 0);
|
|
||||||
dest.writeString(languageCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected WikiSite(@NonNull Parcel in) {
|
|
||||||
this.uri = in.readParcelable(Uri.class.getClassLoader());
|
|
||||||
this.languageCode = in.readString();
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
private static String languageCodeToSubdomain(@NonNull String languageCode) {
|
|
||||||
switch (languageCode) {
|
|
||||||
case AppLanguageLookUpTable.SIMPLIFIED_CHINESE_LANGUAGE_CODE:
|
|
||||||
case AppLanguageLookUpTable.TRADITIONAL_CHINESE_LANGUAGE_CODE:
|
|
||||||
case AppLanguageLookUpTable.CHINESE_CN_LANGUAGE_CODE:
|
|
||||||
case AppLanguageLookUpTable.CHINESE_HK_LANGUAGE_CODE:
|
|
||||||
case AppLanguageLookUpTable.CHINESE_MO_LANGUAGE_CODE:
|
|
||||||
case AppLanguageLookUpTable.CHINESE_SG_LANGUAGE_CODE:
|
|
||||||
case AppLanguageLookUpTable.CHINESE_TW_LANGUAGE_CODE:
|
|
||||||
return AppLanguageLookUpTable.CHINESE_LANGUAGE_CODE;
|
|
||||||
case AppLanguageLookUpTable.NORWEGIAN_BOKMAL_LANGUAGE_CODE:
|
|
||||||
return AppLanguageLookUpTable.NORWEGIAN_LEGACY_LANGUAGE_CODE; // T114042
|
|
||||||
default:
|
|
||||||
return languageCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull private static String authorityToLanguageCode(@NonNull String authority) {
|
|
||||||
String[] parts = authority.split("\\.");
|
|
||||||
final int minLengthForSubdomain = 3;
|
|
||||||
if (parts.length < minLengthForSubdomain
|
|
||||||
|| parts.length == minLengthForSubdomain && parts[0].equals("m")) {
|
|
||||||
// ""
|
|
||||||
// wikipedia.org
|
|
||||||
// m.wikipedia.org
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return parts[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull private static Uri ensureScheme(@NonNull Uri uri) {
|
|
||||||
if (TextUtils.isEmpty(uri.getScheme())) {
|
|
||||||
return uri.buildUpon().scheme(DEFAULT_SCHEME).build();
|
|
||||||
}
|
|
||||||
return uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param authority Host and optional port. */
|
|
||||||
@NonNull private String authorityToMobile(@NonNull String authority) {
|
|
||||||
if (authority.startsWith("m.") || authority.contains(".m.")) {
|
|
||||||
return authority;
|
|
||||||
}
|
|
||||||
return authority.replaceFirst("^" + subdomain() + "\\.?", "$0m.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
269
app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt
Normal file
269
app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
package fr.free.nrw.commons.wikidata.model
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.text.TextUtils
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import fr.free.nrw.commons.language.AppLanguageLookUpTable
|
||||||
|
import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_CN_LANGUAGE_CODE
|
||||||
|
import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_HK_LANGUAGE_CODE
|
||||||
|
import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_LANGUAGE_CODE
|
||||||
|
import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_MO_LANGUAGE_CODE
|
||||||
|
import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_SG_LANGUAGE_CODE
|
||||||
|
import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_TW_LANGUAGE_CODE
|
||||||
|
import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.NORWEGIAN_BOKMAL_LANGUAGE_CODE
|
||||||
|
import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.NORWEGIAN_LEGACY_LANGUAGE_CODE
|
||||||
|
import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.SIMPLIFIED_CHINESE_LANGUAGE_CODE
|
||||||
|
import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.TRADITIONAL_CHINESE_LANGUAGE_CODE
|
||||||
|
import org.apache.commons.lang3.StringUtils
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base URL and Wikipedia language code for a MediaWiki site. Examples:
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* <lh>Name: scheme / authority / language code</lh>
|
||||||
|
* * English Wikipedia: HTTPS / en.wikipedia.org / en
|
||||||
|
* * Chinese Wikipedia: HTTPS / zh.wikipedia.org / zh-hans or zh-hant
|
||||||
|
* * Meta-Wiki: HTTPS / meta.wikimedia.org / (none)
|
||||||
|
* * Test Wikipedia: HTTPS / test.wikipedia.org / test
|
||||||
|
* * Võro Wikipedia: HTTPS / fiu-vro.wikipedia.org / fiu-vro
|
||||||
|
* * Simple English Wikipedia: HTTPS / simple.wikipedia.org / simple
|
||||||
|
* * Simple English Wikipedia (beta cluster mirror): HTTP / simple.wikipedia.beta.wmflabs.org / simple
|
||||||
|
* * Development: HTTP / 192.168.1.11:8080 / (none)
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* **As shown above, the language code or mapping is part of the authority:**
|
||||||
|
*
|
||||||
|
* <lh>Validity: authority / language code</lh>
|
||||||
|
* * Correct: "test.wikipedia.org" / "test"
|
||||||
|
* * Correct: "wikipedia.org", ""
|
||||||
|
* * Correct: "no.wikipedia.org", "nb"
|
||||||
|
* * Incorrect: "wikipedia.org", "test"
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class WikiSite : Parcelable {
|
||||||
|
//TODO: remove @SerializedName. this is now in the TypeAdapter and a "uri" case may be added
|
||||||
|
@SerializedName("domain")
|
||||||
|
private val uri: Uri
|
||||||
|
|
||||||
|
private var languageCode: String? = null
|
||||||
|
|
||||||
|
constructor(uri: Uri) {
|
||||||
|
val tempUri = ensureScheme(uri)
|
||||||
|
var authority = tempUri.authority
|
||||||
|
|
||||||
|
if (authority.isWikipedia && tempUri.path?.startsWith("/wiki") == true) {
|
||||||
|
// Special case for Wikipedia only: assume English subdomain when none given.
|
||||||
|
authority = "en.wikipedia.org"
|
||||||
|
}
|
||||||
|
|
||||||
|
val langVariant = getLanguageVariantFromUri(tempUri)
|
||||||
|
languageCode = if (!TextUtils.isEmpty(langVariant)) {
|
||||||
|
langVariant
|
||||||
|
} else {
|
||||||
|
authorityToLanguageCode(authority!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.uri = Uri.Builder()
|
||||||
|
.scheme(tempUri.scheme)
|
||||||
|
.encodedAuthority(authority)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val String?.isWikipedia: Boolean get() =
|
||||||
|
(this == "wikipedia.org" || this == "www.wikipedia.org")
|
||||||
|
|
||||||
|
/** Get language variant code from a Uri, e.g. "zh-*", otherwise returns empty string. */
|
||||||
|
private fun getLanguageVariantFromUri(uri: Uri): String {
|
||||||
|
if (TextUtils.isEmpty(uri.path)) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
val parts = StringUtils.split(StringUtils.defaultString(uri.path), '/')
|
||||||
|
return if (parts.size > 1 && parts[0] != "wiki") parts[0] else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(url: String) : this(
|
||||||
|
if (url.startsWith("http")) Uri.parse(url) else if (url.startsWith("//"))
|
||||||
|
Uri.parse("$DEFAULT_SCHEME:$url")
|
||||||
|
else
|
||||||
|
Uri.parse("$DEFAULT_SCHEME://$url")
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(authority: String, languageCode: String) : this(authority) {
|
||||||
|
this.languageCode = languageCode
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheme(): String =
|
||||||
|
if (TextUtils.isEmpty(uri.scheme)) DEFAULT_SCHEME else uri.scheme!!
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The complete wiki authority including language subdomain but not including scheme,
|
||||||
|
* authentication, port, nor trailing slash.
|
||||||
|
*
|
||||||
|
* @see [URL syntax](https://en.wikipedia.org/wiki/Uniform_Resource_Locator.Syntax)
|
||||||
|
*/
|
||||||
|
fun authority(): String = uri.authority!!
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like [.authority] but with a "m." between the language subdomain and the rest of the host.
|
||||||
|
* Examples:
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* * English Wikipedia: en.m.wikipedia.org
|
||||||
|
* * Chinese Wikipedia: zh.m.wikipedia.org
|
||||||
|
* * Meta-Wiki: meta.m.wikimedia.org
|
||||||
|
* * Test Wikipedia: test.m.wikipedia.org
|
||||||
|
* * Võro Wikipedia: fiu-vro.m.wikipedia.org
|
||||||
|
* * Simple English Wikipedia: simple.m.wikipedia.org
|
||||||
|
* * Simple English Wikipedia (beta cluster mirror): simple.m.wikipedia.beta.wmflabs.org
|
||||||
|
* * Development: m.192.168.1.11
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
fun mobileAuthority(): String = authorityToMobile(authority())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get wiki's mobile URL
|
||||||
|
* Eg. https://en.m.wikipedia.org
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
fun mobileUrl(): String = String.format("%1\$s://%2\$s", scheme(), mobileAuthority())
|
||||||
|
|
||||||
|
fun subdomain(): String = languageCodeToSubdomain(languageCode!!)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return A path without an authority for the segment including a leading "/".
|
||||||
|
*/
|
||||||
|
fun path(segment: String): String = "/w/$segment"
|
||||||
|
|
||||||
|
|
||||||
|
fun uri(): Uri = uri
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The canonical URL. e.g., https://en.wikipedia.org.
|
||||||
|
*/
|
||||||
|
fun url(): String = uri.toString()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The canonical URL for segment. e.g., https://en.wikipedia.org/w/foo.
|
||||||
|
*/
|
||||||
|
fun url(segment: String): String = url() + path(segment)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The wiki language code which may differ from the language subdomain. Empty if
|
||||||
|
* language code is unknown. Ex: "en", "zh-hans", ""
|
||||||
|
*
|
||||||
|
* @see AppLanguageLookUpTable
|
||||||
|
*/
|
||||||
|
fun languageCode(): String = languageCode!!
|
||||||
|
|
||||||
|
// Auto-generated
|
||||||
|
override fun equals(o: Any?): Boolean {
|
||||||
|
if (this === o) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (o == null || javaClass != o.javaClass) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val wiki = o as WikiSite
|
||||||
|
|
||||||
|
if (uri != wiki.uri) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return languageCode == wiki.languageCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-generated
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = uri.hashCode()
|
||||||
|
result = 31 * result + languageCode.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-generated
|
||||||
|
override fun toString(): String {
|
||||||
|
return ("WikiSite{"
|
||||||
|
+ "uri=" + uri
|
||||||
|
+ ", languageCode='" + languageCode + '\''
|
||||||
|
+ '}')
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun describeContents(): Int = 0
|
||||||
|
|
||||||
|
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||||
|
dest.writeParcelable(uri, 0)
|
||||||
|
dest.writeString(languageCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected constructor(`in`: Parcel) {
|
||||||
|
uri = `in`.readParcelable(Uri::class.java.classLoader)!!
|
||||||
|
languageCode = `in`.readString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param authority Host and optional port.
|
||||||
|
*/
|
||||||
|
private fun authorityToMobile(authority: String): String {
|
||||||
|
if (authority.startsWith("m.") || authority.contains(".m.")) {
|
||||||
|
return authority
|
||||||
|
}
|
||||||
|
return authority.replaceFirst(("^" + subdomain() + "\\.?").toRegex(), "$0m.")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val WIKIPEDIA_URL = "https://wikipedia.org/"
|
||||||
|
const val DEFAULT_SCHEME: String = "https"
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
val CREATOR: Parcelable.Creator<WikiSite> = object : Parcelable.Creator<WikiSite> {
|
||||||
|
override fun createFromParcel(parcel: Parcel): WikiSite {
|
||||||
|
return WikiSite(parcel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newArray(size: Int): Array<WikiSite?> {
|
||||||
|
return arrayOfNulls(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forDefaultLocaleLanguageCode(): WikiSite {
|
||||||
|
val languageCode: String = Locale.getDefault().language
|
||||||
|
val subdomain = if (languageCode.isEmpty()) "" else languageCodeToSubdomain(languageCode) + "."
|
||||||
|
val uri = ensureScheme(Uri.parse(WIKIPEDIA_URL))
|
||||||
|
return WikiSite(subdomain + uri.authority, languageCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun languageCodeToSubdomain(languageCode: String): String = when (languageCode) {
|
||||||
|
SIMPLIFIED_CHINESE_LANGUAGE_CODE,
|
||||||
|
TRADITIONAL_CHINESE_LANGUAGE_CODE,
|
||||||
|
CHINESE_CN_LANGUAGE_CODE,
|
||||||
|
CHINESE_HK_LANGUAGE_CODE,
|
||||||
|
CHINESE_MO_LANGUAGE_CODE,
|
||||||
|
CHINESE_SG_LANGUAGE_CODE,
|
||||||
|
CHINESE_TW_LANGUAGE_CODE -> CHINESE_LANGUAGE_CODE
|
||||||
|
|
||||||
|
NORWEGIAN_BOKMAL_LANGUAGE_CODE -> NORWEGIAN_LEGACY_LANGUAGE_CODE // T114042
|
||||||
|
|
||||||
|
else -> languageCode
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authorityToLanguageCode(authority: String): String {
|
||||||
|
val parts = authority.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||||
|
val minLengthForSubdomain = 3
|
||||||
|
if (parts.size < minLengthForSubdomain || parts.size == minLengthForSubdomain && parts[0] == "m") {
|
||||||
|
// ""
|
||||||
|
// wikipedia.org
|
||||||
|
// m.wikipedia.org
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureScheme(uri: Uri): Uri {
|
||||||
|
if (TextUtils.isEmpty(uri.scheme)) {
|
||||||
|
return uri.buildUpon().scheme(DEFAULT_SCHEME).build()
|
||||||
|
}
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue