diff --git a/app/build.gradle b/app/build.gradle index 468255d38c..14bf5f3b7d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,18 +47,19 @@ dependencies { implementation 'com.jakewharton.timber:timber:4.7.1' implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0' - implementation "com.google.android.material:material:1.9.0" + implementation "com.google.android.material:material:1.12.0" implementation 'com.karumi:dexter:5.0.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' // Jetpack Compose - def composeBom = platform('androidx.compose:compose-bom:2024.08.00') + def composeBom = platform('androidx.compose:compose-bom:2024.11.00') - implementation "androidx.activity:activity-compose:1.9.1" + implementation "androidx.activity:activity-compose:1.9.3" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4" implementation (composeBom) implementation "androidx.compose.runtime:runtime" implementation "androidx.compose.ui:ui" + implementation "androidx.compose.ui:ui-viewbinding" implementation "androidx.compose.ui:ui-graphics" implementation "androidx.compose.ui:ui-tooling" implementation "androidx.compose.foundation:foundation" @@ -138,7 +139,7 @@ dependencies { implementation "androidx.browser:browser:1.3.0" implementation "androidx.cardview:cardview:1.0.0" implementation 'androidx.constraintlayout:constraintlayout:1.1.3' - implementation "androidx.exifinterface:exifinterface:1.3.2" + implementation 'androidx.exifinterface:exifinterface:1.3.7' implementation "androidx.core:core-ktx:$CORE_KTX_VERSION" implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1' @@ -313,6 +314,7 @@ android { buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\"" buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\"" buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"" + buildConfigField "String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"" buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\"" buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\"" buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\"" @@ -348,6 +350,7 @@ android { buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\"" buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\"" buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"" + buildConfigField "String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"" buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\"" buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\"" buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\"" diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt index 45ff9e49dd..50dfe8e7f0 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt @@ -105,7 +105,7 @@ class AboutActivityTest { fun testLaunchTranslate() { Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click()) Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click()) - val langCode = CommonsApplication.instance.languageLookUpTable!!.codes[0] + val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0] Intents.intended( CoreMatchers.allOf( IntentMatchers.hasAction(Intent.ACTION_VIEW), diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt index 48ca37cf0e..2a799c8479 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt @@ -237,7 +237,7 @@ class LoginClient( .subscribe({ response: MwQueryResponse? -> loginResult.userId = response?.query()?.userInfo()?.id() ?: 0 loginResult.groups = - response?.query()?.getUserResponse(userName)?.groups ?: emptySet() + response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet() cb.success(loginResult) }, { caught: Throwable -> Timber.e(caught, "Login succeeded but getting group information failed. ") diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt index ffbf925406..4743e0e543 100644 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt @@ -52,12 +52,12 @@ class CampaignsPresenter @Inject constructor( return } - okHttpJsonApiClient.campaigns + okHttpJsonApiClient.getCampaigns() .observeOn(mainThreadScheduler) .subscribeOn(ioScheduler) .doOnSubscribe { disposable = it } .subscribe({ campaignResponseDTO -> - val campaigns = campaignResponseDTO.campaigns?.toMutableList() + val campaigns = campaignResponseDTO?.campaigns?.toMutableList() if (campaigns.isNullOrEmpty()) { Timber.e("The campaigns list is empty") view!!.showCampaigns(null) diff --git a/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.kt b/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.kt index 5ecc041209..0e9d834784 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.kt +++ b/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.kt @@ -44,7 +44,6 @@ import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor.Level import timber.log.Timber import java.io.File -import java.util.Locale import java.util.concurrent.TimeUnit import javax.inject.Named import javax.inject.Singleton @@ -170,14 +169,13 @@ class NetworkingModule { @Named(NAMED_WIKI_DATA_WIKI_SITE) fun provideWikidataWikiSite(): WikiSite = WikiSite(BuildConfig.WIKIDATA_URL) - /** * Gson objects are very heavy. The app should ideally be using just one instance of it instead of creating new instances everywhere. * @return returns a singleton Gson instance */ @Provides @Singleton - fun provideGson(): Gson = GsonUtil.getDefaultGson() + fun provideGson(): Gson = GsonUtil.defaultGson @Provides @Singleton @@ -294,9 +292,8 @@ class NetworkingModule { @Provides @Singleton @Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) - fun provideLanguageWikipediaSite(): WikiSite { - return WikiSite.forLanguageCode(Locale.getDefault().language) - } + fun provideLanguageWikipediaSite(): WikiSite = + WikiSite.forDefaultLocaleLanguageCode() companion object { private const val WIKIDATA_SPARQL_QUERY_URL = "https://query.wikidata.org/sparql" diff --git a/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt b/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt index d585a99e91..3da98075e4 100644 --- a/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt @@ -6,9 +6,7 @@ import android.animation.ValueAnimator import android.content.Intent import android.graphics.BitmapFactory import android.graphics.Matrix -import android.media.ExifInterface import android.os.Bundle -import android.util.Log import android.view.animation.AccelerateDecelerateInterpolator import android.widget.ImageView import android.widget.Toast @@ -16,10 +14,12 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.graphics.rotationMatrix import androidx.core.graphics.scaleMatrix import androidx.core.net.toUri +import androidx.exifinterface.media.ExifInterface import androidx.lifecycle.ViewModelProvider import fr.free.nrw.commons.databinding.ActivityEditBinding import timber.log.Timber import java.io.File +import kotlin.math.ceil /** * An activity class for editing and rotating images using LLJTran with EXIF attribute preservation. @@ -42,11 +42,12 @@ class EditActivity : AppCompatActivity() { supportActionBar?.title = "" val intent = intent imageUri = intent.getStringExtra("image") ?: "" - vm = ViewModelProvider(this).get(EditViewModel::class.java) + vm = ViewModelProvider(this)[EditViewModel::class.java] val sourceExif = imageUri.toUri().path?.let { ExifInterface(it) } + val exifTags = arrayOf( - ExifInterface.TAG_APERTURE, + ExifInterface.TAG_F_NUMBER, ExifInterface.TAG_DATETIME, ExifInterface.TAG_EXPOSURE_TIME, ExifInterface.TAG_FLASH, @@ -62,13 +63,13 @@ class EditActivity : AppCompatActivity() { ExifInterface.TAG_GPS_TIMESTAMP, ExifInterface.TAG_IMAGE_LENGTH, ExifInterface.TAG_IMAGE_WIDTH, - ExifInterface.TAG_ISO, + ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY, ExifInterface.TAG_MAKE, ExifInterface.TAG_MODEL, ExifInterface.TAG_ORIENTATION, ExifInterface.TAG_WHITE_BALANCE, - ExifInterface.WHITEBALANCE_AUTO, - ExifInterface.WHITEBALANCE_MANUAL, + ExifInterface.WHITE_BALANCE_AUTO, + ExifInterface.WHITE_BALANCE_MANUAL, ) for (tag in exifTags) { val attribute = sourceExif?.getAttribute(tag.toString()) @@ -88,38 +89,36 @@ class EditActivity : AppCompatActivity() { private fun init() { binding.iv.adjustViewBounds = true binding.iv.scaleType = ImageView.ScaleType.MATRIX - binding.iv.post( - Runnable { - val options = BitmapFactory.Options() - options.inJustDecodeBounds = true - BitmapFactory.decodeFile(imageUri, options) + binding.iv.post { + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeFile(imageUri, options) - val bitmapWidth = options.outWidth - val bitmapHeight = options.outHeight + val bitmapWidth = options.outWidth + val bitmapHeight = options.outHeight - // Check if the bitmap dimensions exceed a certain threshold - val maxBitmapSize = 2000 // Set your maximum size here - if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) { - val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize) - options.inSampleSize = scaleFactor - options.inJustDecodeBounds = false - val scaledBitmap = BitmapFactory.decodeFile(imageUri, options) - binding.iv.setImageBitmap(scaledBitmap) - // Update the ImageView with the scaled bitmap - val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat() - binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt() - binding.iv.imageMatrix = scaleMatrix(scale, scale) - } else { - options.inJustDecodeBounds = false - val bitmap = BitmapFactory.decodeFile(imageUri, options) - binding.iv.setImageBitmap(bitmap) + // Check if the bitmap dimensions exceed a certain threshold + val maxBitmapSize = 2000 // Set your maximum size here + if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) { + val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize) + options.inSampleSize = scaleFactor + options.inJustDecodeBounds = false + val scaledBitmap = BitmapFactory.decodeFile(imageUri, options) + binding.iv.setImageBitmap(scaledBitmap) + // Update the ImageView with the scaled bitmap + val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat() + binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt() + binding.iv.imageMatrix = scaleMatrix(scale, scale) + } else { + options.inJustDecodeBounds = false + val bitmap = BitmapFactory.decodeFile(imageUri, options) + binding.iv.setImageBitmap(bitmap) - val scale = binding.iv.measuredWidth.toFloat() / bitmapWidth.toFloat() - binding.iv.layoutParams.height = (scale * bitmapHeight).toInt() - binding.iv.imageMatrix = scaleMatrix(scale, scale) - } - }, - ) + val scale = binding.iv.measuredWidth.toFloat() / bitmapWidth.toFloat() + binding.iv.layoutParams.height = (scale * bitmapHeight).toInt() + binding.iv.imageMatrix = scaleMatrix(scale, scale) + } + } binding.rotateBtn.setOnClickListener { animateImageHeight() } @@ -143,15 +142,15 @@ class EditActivity : AppCompatActivity() { val drawableWidth: Float = binding.iv .getDrawable() - .getIntrinsicWidth() + .intrinsicWidth .toFloat() val drawableHeight: Float = binding.iv .getDrawable() - .getIntrinsicHeight() + .intrinsicHeight .toFloat() - val viewWidth: Float = binding.iv.getMeasuredWidth().toFloat() - val viewHeight: Float = binding.iv.getMeasuredHeight().toFloat() + val viewWidth: Float = binding.iv.measuredWidth.toFloat() + val viewHeight: Float = binding.iv.measuredHeight.toFloat() val rotation = imageRotation % 360 val newRotation = rotation + 90 @@ -162,16 +161,23 @@ class EditActivity : AppCompatActivity() { Timber.d("Rotation $rotation") Timber.d("new Rotation $newRotation") - if (rotation == 0 || rotation == 180) { - imageScale = viewWidth / drawableWidth - newImageScale = viewWidth / drawableHeight - newViewHeight = (drawableWidth * newImageScale).toInt() - } else if (rotation == 90 || rotation == 270) { - imageScale = viewWidth / drawableHeight - newImageScale = viewWidth / drawableWidth - newViewHeight = (drawableHeight * newImageScale).toInt() - } else { - throw UnsupportedOperationException("rotation can 0, 90, 180 or 270. \${rotation} is unsupported") + when (rotation) { + 0, 180 -> { + imageScale = viewWidth / drawableWidth + newImageScale = viewWidth / drawableHeight + newViewHeight = (drawableWidth * newImageScale).toInt() + } + 90, 270 -> { + imageScale = viewWidth / drawableHeight + newImageScale = viewWidth / drawableWidth + newViewHeight = (drawableHeight * newImageScale).toInt() + } + else -> { + throw + UnsupportedOperationException( + "rotation can 0, 90, 180 or 270. \${rotation} is unsupported" + ) + } } val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L) @@ -204,7 +210,7 @@ class EditActivity : AppCompatActivity() { (complementaryAnimVal * viewHeight + animVal * newViewHeight).toInt() val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation - binding.iv.getLayoutParams().height = animatedHeight + binding.iv.layoutParams.height = animatedHeight val matrix: Matrix = rotationMatrix( animatedRotation, @@ -218,8 +224,8 @@ class EditActivity : AppCompatActivity() { drawableHeight / 2, ) matrix.postTranslate( - -(drawableWidth - binding.iv.getMeasuredWidth()) / 2, - -(drawableHeight - binding.iv.getMeasuredHeight()) / 2, + -(drawableWidth - binding.iv.measuredWidth) / 2, + -(drawableHeight - binding.iv.measuredHeight) / 2, ) binding.iv.setImageMatrix(matrix) binding.iv.requestLayout() @@ -267,9 +273,9 @@ class EditActivity : AppCompatActivity() { */ private fun copyExifData(editedImageExif: ExifInterface?) { for (attr in sourceExifAttributeList) { - Log.d("Tag is ${attr.first}", "Value is ${attr.second}") + Timber.d("Value is ${attr.second}") editedImageExif!!.setAttribute(attr.first, attr.second) - Log.d("Tag is ${attr.first}", "Value is ${attr.second}") + Timber.d("Value is ${attr.second}") } editedImageExif?.saveAttributes() @@ -298,9 +304,10 @@ class EditActivity : AppCompatActivity() { var scaleFactor = 1 if (originalWidth > maxSize || originalHeight > maxSize) { - // Calculate the largest power of 2 that is less than or equal to the desired width and height - val widthRatio = Math.ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt() - val heightRatio = Math.ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt() + // Calculate the largest power of 2 that is less than or equal to the desired + // width and height + val widthRatio = ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt() + val heightRatio = ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt() scaleFactor = if (widthRatio > heightRatio) widthRatio else heightRatio } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/media/MediaConverter.kt b/app/src/main/java/fr/free/nrw/commons/explore/media/MediaConverter.kt index a3103d41aa..de084ba50c 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/media/MediaConverter.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/media/MediaConverter.kt @@ -11,7 +11,6 @@ import fr.free.nrw.commons.wikidata.model.Entities import fr.free.nrw.commons.wikidata.model.gallery.ExtMetadata import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage -import org.apache.commons.lang3.StringUtils import java.text.ParseException import java.util.Date import javax.inject.Inject @@ -24,7 +23,7 @@ class MediaConverter entity: Entities.Entity, imageInfo: ImageInfo, ): Media { - val metadata = imageInfo.metadata + val metadata = imageInfo.getMetadata() requireNotNull(metadata) { "No metadata" } // Stores mapping of title attribute to hidden attribute of each category val myMap = mutableMapOf() @@ -32,8 +31,8 @@ class MediaConverter return Media( page.pageId().toString(), - imageInfo.thumbUrl.takeIf { it.isNotBlank() } ?: imageInfo.originalUrl, - imageInfo.originalUrl, + imageInfo.getThumbUrl().takeIf { it.isNotBlank() } ?: imageInfo.getOriginalUrl(), + imageInfo.getOriginalUrl(), page.title(), metadata.imageDescription(), safeParseDate(metadata.dateTime()), @@ -41,7 +40,7 @@ class MediaConverter metadata.prefixedLicenseUrl, getAuthor(metadata), getAuthor(metadata), - MediaDataExtractorUtil.extractCategoriesFromList(metadata.categories), + MediaDataExtractorUtil.extractCategoriesFromList(metadata.categories()), metadata.latLng, entity.labels().mapValues { it.value.value() }, entity.descriptions().mapValues { it.value.value() }, @@ -104,9 +103,5 @@ private val ExtMetadata.prefixedLicenseUrl: String } private val ExtMetadata.latLng: LatLng? - get() = - if (!StringUtils.isBlank(gpsLatitude) && !StringUtils.isBlank(gpsLongitude)) { - LatLng(gpsLatitude.toDouble(), gpsLongitude.toDouble(), 0.0f) - } else { - null - } + get() = LatLng.latLongOrNull(gpsLatitude(), gpsLongitude()) + diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackContentCreator.java b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackContentCreator.java deleted file mode 100644 index 2a4b612c02..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackContentCreator.java +++ /dev/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(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackContentCreator.kt b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackContentCreator.kt new file mode 100644 index 0000000000..2a11652ec0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackContentCreator.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.java b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.java deleted file mode 100644 index 2308623ec5..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.java +++ /dev/null @@ -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(); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.kt b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.kt new file mode 100644 index 0000000000..e0977c3d9f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/OnFeedbackSubmitCallback.java b/app/src/main/java/fr/free/nrw/commons/feedback/OnFeedbackSubmitCallback.java deleted file mode 100644 index 0d695061af..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/feedback/OnFeedbackSubmitCallback.java +++ /dev/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); -} diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/OnFeedbackSubmitCallback.kt b/app/src/main/java/fr/free/nrw/commons/feedback/OnFeedbackSubmitCallback.kt new file mode 100644 index 0000000000..5fd4f0490c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/feedback/OnFeedbackSubmitCallback.kt @@ -0,0 +1,7 @@ +package fr.free.nrw.commons.feedback + +import fr.free.nrw.commons.feedback.model.Feedback + +interface OnFeedbackSubmitCallback { + fun onFeedbackSubmit(feedback: Feedback) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/model/Feedback.java b/app/src/main/java/fr/free/nrw/commons/feedback/model/Feedback.java deleted file mode 100644 index 6e3a8cb0f2..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/feedback/model/Feedback.java +++ /dev/null @@ -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; - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/model/Feedback.kt b/app/src/main/java/fr/free/nrw/commons/feedback/model/Feedback.kt new file mode 100644 index 0000000000..f4af3425e8 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/feedback/model/Feedback.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java b/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java deleted file mode 100644 index 97a16acc32..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java +++ /dev/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"; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt new file mode 100644 index 0000000000..e405a6d52c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt @@ -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" + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java b/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java deleted file mode 100644 index e8373dc6fa..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java +++ /dev/null @@ -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) { - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt new file mode 100644 index 0000000000..baaba67b5d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt @@ -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) {} +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java b/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java deleted file mode 100644 index af3dc8622e..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import androidx.core.content.FileProvider; - -public class ExtendedFileProvider extends FileProvider { - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt new file mode 100644 index 0000000000..746058fc43 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.filepicker + +import androidx.core.content.FileProvider + +class ExtendedFileProvider: FileProvider() {} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java deleted file mode 100644 index b64db24c5f..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java +++ /dev/null @@ -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 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 resultLauncher, int type, boolean openDocumentIntentPreferred) { - Intent intent = createGalleryIntent(activity, type, openDocumentIntentPreferred); - resultLauncher.launch(intent); - } - - /** - * Opens Custom Selector - */ - public static void openCustomSelector(Activity activity, ActivityResultLauncher 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 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 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 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 getFilesFromCustomSelector(Intent data, Activity activity) throws IOException, SecurityException { - List files = new ArrayList<>(); - ArrayList 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 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 getFilesFromGalleryPictures(Intent data, Activity activity) throws IOException, SecurityException { - List 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 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 imageFiles, FilePicker.ImageSource source, int type); - - void onCanceled(FilePicker.ImageSource source, int type); - } - - public interface HandleActivityResult{ - void onHandleActivityResult(FilePicker.Callbacks callbacks); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt new file mode 100644 index 0000000000..6bf8a10613 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt @@ -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, + type: Int, + openDocumentIntentPreferred: Boolean + ) { + val intent = createGalleryIntent(activity, type, openDocumentIntentPreferred) + resultLauncher.launch(intent) + } + + /** + * Opens Custom Selector + */ + @JvmStatic + fun openCustomSelector( + activity: Activity, + resultLauncher: ActivityResultLauncher, + 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, + 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 { + 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 { + val files = mutableListOf() + val images = data?.getParcelableArrayListExtra("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 { + val files = mutableListOf() + 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() + 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, source: ImageSource, type: Int) + + fun onCanceled(source: ImageSource, type: Int) + } + + interface HandleActivityResult { + fun onHandleActivityResult(callbacks: Callbacks) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java deleted file mode 100644 index 08a204e8b9..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java +++ /dev/null @@ -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); - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt new file mode 100644 index 0000000000..db025a5442 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java deleted file mode 100644 index e6c82f5c1c..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java +++ /dev/null @@ -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 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); - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt new file mode 100644 index 0000000000..0cf21cc027 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java b/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java deleted file mode 100644 index ca1abba623..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java +++ /dev/null @@ -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 filesToCopy) { - new Thread(() -> { - List 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 singleFileList(UploadableFile file) { - List 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 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); - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt new file mode 100644 index 0000000000..9694dedb53 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt @@ -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) { + Thread { + val copiedFiles = mutableListOf() + 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 { + return listOf(file) + } + + /** + * ScanCopiedImages + * Scans copied images metadata using media scanner. + */ + @JvmStatic + fun scanCopiedImages(context: Context, copiedImages: List) { + 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) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java deleted file mode 100644 index 1fe306a8b5..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java +++ /dev/null @@ -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 CREATOR = new Creator() { - @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; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt new file mode 100644 index 0000000000..1398e77853 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt @@ -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 { + override fun createFromParcel(parcel: Parcel): UploadableFile { + return UploadableFile(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesResponse.kt b/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesResponse.kt new file mode 100644 index 0000000000..96d19d1cff --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesResponse.kt @@ -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 +) + +data class Page( + @SerializedName("pageid") val pageId: Int, + @SerializedName("ns") val nameSpace: Int, + @SerializedName("title") val title: String, + @SerializedName("fileusage") val fileUsage: List +) + +data class FileUsage( + @SerializedName("pageid") val pageId: Int, + @SerializedName("ns") val nameSpace: Int, + @SerializedName("title") val title: String, + @SerializedName("redirect") val redirect: Boolean +) diff --git a/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesUiModel.kt b/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesUiModel.kt new file mode 100644 index 0000000000..63b0740d02 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesUiModel.kt @@ -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) +} diff --git a/app/src/main/java/fr/free/nrw/commons/fileusages/GlobalFileUsagesResponse.kt b/app/src/main/java/fr/free/nrw/commons/fileusages/GlobalFileUsagesResponse.kt new file mode 100644 index 0000000000..17580539ed --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/fileusages/GlobalFileUsagesResponse.kt @@ -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 +) + +data class GlobalPage( + @SerializedName("pageid") val pageId: Int, + @SerializedName("ns") val nameSpace: Int, + @SerializedName("title") val title: String, + @SerializedName("globalusage") val fileUsage: List +) + +data class GlobalFileUsage( + @SerializedName("title") val title: String, + @SerializedName("wiki") val wiki: String, + @SerializedName("url") val url: String +) diff --git a/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt b/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt index 4e21b93c2e..7dd9a49ce4 100644 --- a/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt +++ b/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt @@ -41,6 +41,13 @@ data class LatLng( * Accepts a non-null [Location] and converts it to a [LatLng]. */ companion object { + fun latLongOrNull(latitude: String?, longitude: String?): LatLng? = + if (!latitude.isNullOrBlank() && !longitude.isNullOrBlank()) { + LatLng(latitude.toDouble(), longitude.toDouble(), 0.0f) + } else { + null + } + /** * gets the latitude and longitude of a given non-null location * @param location the non-null location of the user diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java deleted file mode 100644 index 66f2221b82..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java +++ /dev/null @@ -1,1466 +0,0 @@ -package fr.free.nrw.commons.media; - -import static android.view.View.GONE; -import static android.view.View.VISIBLE; -import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_NEEDING_CATEGORIES; -import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_UNCATEGORISED; -import static fr.free.nrw.commons.description.EditDescriptionConstants.LIST_OF_DESCRIPTION_AND_CAPTION; -import static fr.free.nrw.commons.description.EditDescriptionConstants.WIKITEXT; -import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION; -import static fr.free.nrw.commons.utils.LangCodeUtils.getLocalizedResources; - -import android.annotation.SuppressLint; -import android.app.AlertDialog; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.graphics.drawable.Animatable; -import android.net.Uri; -import android.os.Bundle; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.View; -import android.view.View.OnKeyListener; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.view.ViewTreeObserver.OnGlobalLayoutListener; -import android.widget.ArrayAdapter; -import android.widget.Button; -import android.widget.EditText; -import android.widget.LinearLayout; -import android.widget.Spinner; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentTransaction; -import com.facebook.drawee.backends.pipeline.Fresco; -import com.facebook.drawee.controller.BaseControllerListener; -import com.facebook.drawee.controller.ControllerListener; -import com.facebook.drawee.interfaces.DraweeController; -import com.facebook.imagepipeline.image.ImageInfo; -import com.facebook.imagepipeline.request.ImageRequest; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.CameraPosition; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.LocationPicker.LocationPicker; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.MediaDataExtractor; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.actions.ThanksClient; -import fr.free.nrw.commons.auth.AccountUtilKt; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException; -import fr.free.nrw.commons.category.CategoryClient; -import fr.free.nrw.commons.category.CategoryDetailsActivity; -import fr.free.nrw.commons.category.CategoryEditHelper; -import fr.free.nrw.commons.contributions.ContributionsFragment; -import fr.free.nrw.commons.coordinates.CoordinateEditHelper; -import fr.free.nrw.commons.databinding.FragmentMediaDetailBinding; -import fr.free.nrw.commons.delete.DeleteHelper; -import fr.free.nrw.commons.delete.ReasonBuilder; -import fr.free.nrw.commons.description.DescriptionEditActivity; -import fr.free.nrw.commons.description.DescriptionEditHelper; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.language.AppLanguageLookUpTable; -import fr.free.nrw.commons.location.LocationServiceManager; -import fr.free.nrw.commons.profile.ProfileActivity; -import fr.free.nrw.commons.review.ReviewHelper; -import fr.free.nrw.commons.settings.Prefs; -import fr.free.nrw.commons.upload.UploadMediaDetail; -import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment; -import fr.free.nrw.commons.upload.depicts.DepictsFragment; -import fr.free.nrw.commons.utils.DateUtil; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.PermissionUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import fr.free.nrw.commons.utils.ViewUtilWrapper; -import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage; -import io.reactivex.Observable; -import io.reactivex.ObservableSource; -import io.reactivex.Single; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Date; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.Callable; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import javax.inject.Inject; -import javax.inject.Named; -import org.apache.commons.lang3.StringUtils; -import timber.log.Timber; - -public class MediaDetailFragment extends CommonsDaggerSupportFragment implements - CategoryEditHelper.Callback { - - private static final String IMAGE_BACKGROUND_COLOR = "image_background_color"; - static final int DEFAULT_IMAGE_BACKGROUND_COLOR = 0; - - private boolean editable; - private boolean isCategoryImage; - private MediaDetailPagerFragment.MediaDetailProvider detailProvider; - private int index; - private boolean isDeleted = false; - private boolean isWikipediaButtonDisplayed; - private Callback callback; - - @Inject - LocationServiceManager locationManager; - - - public static MediaDetailFragment forMedia(int index, boolean editable, boolean isCategoryImage, boolean isWikipediaButtonDisplayed) { - MediaDetailFragment mf = new MediaDetailFragment(); - Bundle state = new Bundle(); - state.putBoolean("editable", editable); - state.putBoolean("isCategoryImage", isCategoryImage); - state.putInt("index", index); - state.putInt("listIndex", 0); - state.putInt("listTop", 0); - state.putBoolean("isWikipediaButtonDisplayed", isWikipediaButtonDisplayed); - mf.setArguments(state); - - return mf; - } - - @Inject - SessionManager sessionManager; - - @Inject - MediaDataExtractor mediaDataExtractor; - @Inject - ReasonBuilder reasonBuilder; - @Inject - DeleteHelper deleteHelper; - @Inject - ReviewHelper reviewHelper; - @Inject - CategoryEditHelper categoryEditHelper; - @Inject - CoordinateEditHelper coordinateEditHelper; - @Inject - DescriptionEditHelper descriptionEditHelper; - @Inject - ViewUtilWrapper viewUtil; - @Inject - CategoryClient categoryClient; - @Inject - ThanksClient thanksClient; - @Inject - @Named("default_preferences") - JsonKvStore applicationKvStore; - - private int initialListTop = 0; - private FragmentMediaDetailBinding binding; - String descriptionHtmlCode; - - - - - private ArrayList categoryNames = new ArrayList<>(); - private String categorySearchQuery; - - /** - * Depicts is a feature part of Structured data. Multiple Depictions can be added for an image just like categories. - * However unlike categories depictions is multi-lingual - * Ex: key: en value: monument - */ - private ImageInfo imageInfoCache; - private int oldWidthOfImageView; - private int newWidthOfImageView; - private boolean heightVerifyingBoolean = true; // helps in maintaining aspect ratio - private ViewTreeObserver.OnGlobalLayoutListener layoutListener; // for layout stuff, only used once! - - //Had to make this class variable, to implement various onClicks, which access the media, also I fell why make separate variables when one can serve the purpose - private Media media; - private ArrayList reasonList; - private ArrayList reasonListEnglishMappings; - - /** - * Height stores the height of the frame layout as soon as it is initialised and updates itself on - * configuration changes. - * Used to adjust aspect ratio of image when length of the image is too large. - */ - private int frameLayoutHeight; - - /** - * Minimum height of the metadata, in pixels. - * Images with a very narrow aspect ratio will be reduced so that the metadata information panel always has at least this height. - */ - private int minimumHeightOfMetadata = 200; - - final static String NOMINATING_FOR_DELETION_MEDIA = "Nominating for deletion %s"; - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putInt("index", index); - outState.putBoolean("editable", editable); - outState.putBoolean("isCategoryImage", isCategoryImage); - outState.putBoolean("isWikipediaButtonDisplayed", isWikipediaButtonDisplayed); - - getScrollPosition(); - outState.putInt("listTop", initialListTop); - } - - private void getScrollPosition() { - initialListTop = binding.mediaDetailScrollView.getScrollY(); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - if (getParentFragment() != null - && getParentFragment() instanceof MediaDetailPagerFragment) { - detailProvider = - ((MediaDetailPagerFragment) getParentFragment()).getMediaDetailProvider(); - } - if (savedInstanceState != null) { - editable = savedInstanceState.getBoolean("editable"); - isCategoryImage = savedInstanceState.getBoolean("isCategoryImage"); - isWikipediaButtonDisplayed = savedInstanceState.getBoolean("isWikipediaButtonDisplayed"); - index = savedInstanceState.getInt("index"); - initialListTop = savedInstanceState.getInt("listTop"); - } else { - editable = getArguments().getBoolean("editable"); - isCategoryImage = getArguments().getBoolean("isCategoryImage"); - isWikipediaButtonDisplayed = getArguments().getBoolean("isWikipediaButtonDisplayed"); - index = getArguments().getInt("index"); - initialListTop = 0; - } - - reasonList = new ArrayList<>(); - reasonList.add(getString(R.string.deletion_reason_uploaded_by_mistake)); - reasonList.add(getString(R.string.deletion_reason_publicly_visible)); - reasonList.add(getString(R.string.deletion_reason_not_interesting)); - reasonList.add(getString(R.string.deletion_reason_no_longer_want_public)); - reasonList.add(getString(R.string.deletion_reason_bad_for_my_privacy)); - - // Add corresponding mappings in english locale so that we can upload it in deletion request - reasonListEnglishMappings = new ArrayList<>(); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_uploaded_by_mistake)); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_publicly_visible)); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_not_interesting)); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_no_longer_want_public)); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_bad_for_my_privacy)); - - binding = FragmentMediaDetailBinding.inflate(inflater, container, false); - final View view = binding.getRoot(); - - - Utils.setUnderlinedText(binding.seeMore, R.string.nominated_see_more, requireContext()); - - if (isCategoryImage){ - binding.authorLinearLayout.setVisibility(VISIBLE); - } else { - binding.authorLinearLayout.setVisibility(GONE); - } - - if (!sessionManager.isUserLoggedIn()) { - binding.categoryEditButton.setVisibility(GONE); - binding.descriptionEdit.setVisibility(GONE); - binding.depictionsEditButton.setVisibility(GONE); - } else { - binding.categoryEditButton.setVisibility(VISIBLE); - binding.descriptionEdit.setVisibility(VISIBLE); - binding.depictionsEditButton.setVisibility(VISIBLE); - } - - if(applicationKvStore.getBoolean("login_skipped")){ - binding.nominateDeletion.setVisibility(GONE); - binding.coordinateEdit.setVisibility(GONE); - } - - handleBackEvent(view); - - //set onCLick listeners - binding.mediaDetailLicense.setOnClickListener(v -> onMediaDetailLicenceClicked()); - binding.mediaDetailCoordinates.setOnClickListener(v -> onMediaDetailCoordinatesClicked()); - binding.sendThanks.setOnClickListener(v -> sendThanksToAuthor()); - binding.dummyCaptionDescriptionContainer.setOnClickListener(v -> showCaptionAndDescription()); - binding.mediaDetailImageView.setOnClickListener(v -> launchZoomActivity(binding.mediaDetailImageView)); - binding.categoryEditButton.setOnClickListener(v -> onCategoryEditButtonClicked()); - binding.depictionsEditButton.setOnClickListener(v -> onDepictionsEditButtonClicked()); - binding.seeMore.setOnClickListener(v -> onSeeMoreClicked()); - binding.mediaDetailAuthor.setOnClickListener(v -> onAuthorViewClicked()); - binding.nominateDeletion.setOnClickListener(v -> onDeleteButtonClicked()); - binding.descriptionEdit.setOnClickListener(v -> onDescriptionEditClicked()); - binding.coordinateEdit.setOnClickListener(v -> onUpdateCoordinatesClicked()); - binding.copyWikicode.setOnClickListener(v -> onCopyWikicodeClicked()); - - - /** - * Gets the height of the frame layout as soon as the view is ready and updates aspect ratio - * of the picture. - */ - view.post(new Runnable() { - @Override - public void run() { - frameLayoutHeight = binding.mediaDetailFrameLayout.getMeasuredHeight(); - updateAspectRatio(binding.mediaDetailScrollView.getWidth()); - } - }); - - return view; - } - - public void launchZoomActivity(final View view) { - final boolean hasPermission = PermissionUtils.hasPermission(getActivity(), PermissionUtils.getPERMISSIONS_STORAGE()); - if (hasPermission) { - launchZoomActivityAfterPermissionCheck(view); - } else { - PermissionUtils.checkPermissionsAndPerformAction(getActivity(), - () -> { - launchZoomActivityAfterPermissionCheck(view); - }, - R.string.storage_permission_title, - R.string.read_storage_permission_rationale, - PermissionUtils.getPERMISSIONS_STORAGE() - ); - } - } - - /** - * launch zoom acitivity after permission check - * @param view as ImageView - */ - private void launchZoomActivityAfterPermissionCheck(final View view) { - if (media.getImageUrl() != null) { - final Context ctx = view.getContext(); - final Intent zoomableIntent = new Intent(ctx, ZoomableActivity.class); - zoomableIntent.setData(Uri.parse(media.getImageUrl())); - zoomableIntent.putExtra( - ZoomableActivity.ZoomableActivityConstants.ORIGIN, "MediaDetails"); - - int backgroundColor = getImageBackgroundColor(); - if (backgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { - zoomableIntent.putExtra( - ZoomableActivity.ZoomableActivityConstants.PHOTO_BACKGROUND_COLOR, - backgroundColor - ); - } - - ctx.startActivity( - zoomableIntent - ); - } - } - - @Override - public void onResume() { - super.onResume(); - if (getParentFragment() != null && getParentFragment().getParentFragment() != null) { - //Added a check because, not necessarily, the parent fragment will have a parent fragment, say - // in the case when MediaDetailPagerFragment is directly started by the CategoryImagesActivity - if (getParentFragment() instanceof ContributionsFragment) { - ((ContributionsFragment) (getParentFragment() - .getParentFragment())).binding.cardViewNearby - .setVisibility(View.GONE); - } - } - // detail provider is null when fragment is shown in review activity - if (detailProvider != null) { - media = detailProvider.getMediaAtPosition(index); - } else { - media = getArguments().getParcelable("media"); - } - - if(media != null && applicationKvStore.getBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), false)) { - enableProgressBar(); - } - - if (AccountUtilKt.getUserName(getContext()) != null && media != null - && AccountUtilKt.getUserName(getContext()).equals(media.getAuthor())) { - binding.sendThanks.setVisibility(GONE); - } else { - binding.sendThanks.setVisibility(VISIBLE); - } - - binding.mediaDetailScrollView.getViewTreeObserver().addOnGlobalLayoutListener( - new OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - if (getContext() == null) { - return; - } - binding.mediaDetailScrollView.getViewTreeObserver().removeOnGlobalLayoutListener(this); - oldWidthOfImageView = binding.mediaDetailScrollView.getWidth(); - if(media != null) { - displayMediaDetails(); - } - } - } - ); - binding.progressBarEdit.setVisibility(GONE); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - binding.mediaDetailScrollView.getViewTreeObserver().addOnGlobalLayoutListener( - new OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - /** - * We update the height of the frame layout as the configuration changes. - */ - binding.mediaDetailFrameLayout.post(new Runnable() { - @Override - public void run() { - frameLayoutHeight = binding.mediaDetailFrameLayout.getMeasuredHeight(); - updateAspectRatio(binding.mediaDetailScrollView.getWidth()); - } - }); - if (binding.mediaDetailScrollView.getWidth() != oldWidthOfImageView) { - if (newWidthOfImageView == 0) { - newWidthOfImageView = binding.mediaDetailScrollView.getWidth(); - updateAspectRatio(newWidthOfImageView); - } - binding.mediaDetailScrollView.getViewTreeObserver().removeOnGlobalLayoutListener(this); - } - } - } - ); - // Ensuring correct aspect ratio for landscape mode - if (heightVerifyingBoolean) { - updateAspectRatio(newWidthOfImageView); - heightVerifyingBoolean = false; - } else { - updateAspectRatio(oldWidthOfImageView); - heightVerifyingBoolean = true; - } - } - - private void displayMediaDetails() { - setTextFields(media); - compositeDisposable.addAll( - mediaDataExtractor.refresh(media) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onMediaRefreshed, Timber::e), - mediaDataExtractor.getCurrentWikiText( - Objects.requireNonNull(media.getFilename())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::updateCategoryList, Timber::e), - mediaDataExtractor.checkDeletionRequestExists(media) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onDeletionPageExists, Timber::e), - mediaDataExtractor.fetchDiscussion(media) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onDiscussionLoaded, Timber::e) - ); - } - - private void onMediaRefreshed(Media media) { - media.setCategories(this.media.getCategories()); - this.media = media; - setTextFields(media); - compositeDisposable.addAll( - mediaDataExtractor.fetchDepictionIdsAndLabels(media) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onDepictionsLoaded, Timber::e) - ); - // compositeDisposable.add(disposable); - } - - private void onDiscussionLoaded(String discussion) { - binding.mediaDetailDisc.setText(prettyDiscussion(discussion.trim())); - } - - private void onDeletionPageExists(Boolean deletionPageExists) { - if (AccountUtilKt.getUserName(getContext()) == null && !AccountUtilKt.getUserName(getContext()).equals(media.getAuthor())) { - binding.nominateDeletion.setVisibility(GONE); - binding.nominatedDeletionBanner.setVisibility(GONE); - } else if (deletionPageExists) { - if (applicationKvStore.getBoolean( - String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), false)) { - applicationKvStore.remove( - String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl())); - binding.progressBarDeletion.setVisibility(GONE); - } - binding.nominateDeletion.setVisibility(GONE); - - binding.nominatedDeletionBanner.setVisibility(VISIBLE); - } else if (!isCategoryImage) { - binding.nominateDeletion.setVisibility(VISIBLE); - binding.nominatedDeletionBanner.setVisibility(GONE); - } - } - - private void onDepictionsLoaded(List idAndCaptions){ - binding.depictsLayout.setVisibility(idAndCaptions.isEmpty() ? GONE : VISIBLE); - binding.depictionsEditButton.setVisibility(idAndCaptions.isEmpty() ? GONE : VISIBLE); - buildDepictionList(idAndCaptions); - } - - /** - * By clicking on the edit depictions button, it will send user to depict fragment - */ - - public void onDepictionsEditButtonClicked() { - binding.mediaDetailDepictionContainer.removeAllViews(); - binding.depictionsEditButton.setVisibility(GONE); - final Fragment depictsFragment = new DepictsFragment(); - final Bundle bundle = new Bundle(); - bundle.putParcelable("Existing_Depicts", media); - depictsFragment.setArguments(bundle); - final FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); - transaction.replace(R.id.mediaDetailFrameLayout, depictsFragment); - transaction.addToBackStack(null); - transaction.commit(); - } - /** - * The imageSpacer is Basically a transparent overlay for the SimpleDraweeView - * which holds the image to be displayed( moreover this image is out of - * the scroll view ) - * - * - * If the image is sufficiently large i.e. the image height extends the view height, we reduce - * the height and change the width to maintain the aspect ratio, otherwise image takes up the - * total possible width and height is adjusted accordingly. - * - * @param scrollWidth the current width of the scrollView - */ - private void updateAspectRatio(int scrollWidth) { - if (imageInfoCache != null) { - int finalHeight = (scrollWidth*imageInfoCache.getHeight()) / imageInfoCache.getWidth(); - ViewGroup.LayoutParams params = binding.mediaDetailImageView.getLayoutParams(); - ViewGroup.LayoutParams spacerParams = binding.mediaDetailImageViewSpacer.getLayoutParams(); - params.width = scrollWidth; - if(finalHeight > frameLayoutHeight - minimumHeightOfMetadata) { - - // Adjust the height and width of image. - int temp = frameLayoutHeight - minimumHeightOfMetadata; - params.width = (scrollWidth*temp) / finalHeight; - finalHeight = temp; - - } - params.height = finalHeight; - spacerParams.height = finalHeight; - binding.mediaDetailImageView.setLayoutParams(params); - binding.mediaDetailImageViewSpacer.setLayoutParams(spacerParams); - } - } - - private final ControllerListener aspectRatioListener = new BaseControllerListener() { - @Override - public void onIntermediateImageSet(String id, @Nullable ImageInfo imageInfo) { - imageInfoCache = imageInfo; - updateAspectRatio(binding.mediaDetailScrollView.getWidth()); - } - @Override - public void onFinalImageSet(String id, @Nullable ImageInfo imageInfo, @Nullable Animatable animatable) { - imageInfoCache = imageInfo; - updateAspectRatio(binding.mediaDetailScrollView.getWidth()); - } - }; - - /** - * Uses two image sources. - * - low resolution thumbnail is shown initially - * - when the high resolution image is available, it replaces the low resolution image - */ - private void setupImageView() { - int imageBackgroundColor = getImageBackgroundColor(); - if (imageBackgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { - binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor); - } - - binding.mediaDetailImageView.getHierarchy().setPlaceholderImage(R.drawable.image_placeholder); - binding.mediaDetailImageView.getHierarchy().setFailureImage(R.drawable.image_placeholder); - - DraweeController controller = Fresco.newDraweeControllerBuilder() - .setLowResImageRequest(ImageRequest.fromUri(media != null ? media.getThumbUrl() : null)) - .setRetainImageOnFailure(true) - .setImageRequest(ImageRequest.fromUri(media != null ? media.getImageUrl() : null)) - .setControllerListener(aspectRatioListener) - .setOldController(binding.mediaDetailImageView.getController()) - .build(); - binding.mediaDetailImageView.setController(controller); - } - - private void updateToDoWarning() { - String toDoMessage = ""; - boolean toDoNeeded = false; - boolean categoriesPresent = media.getCategories() == null ? false : (media.getCategories().size() == 0 ? false : true); - - // Check if the presented category is about need of category - if (categoriesPresent) { - for (String category : media.getCategories()) { - if (category.toLowerCase(Locale.ROOT).contains(CATEGORY_NEEDING_CATEGORIES) || - category.toLowerCase(Locale.ROOT).contains(CATEGORY_UNCATEGORISED)) { - categoriesPresent = false; - } - break; - } - } - if (!categoriesPresent) { - toDoNeeded = true; - toDoMessage += getString(R.string.missing_category); - } - if (isWikipediaButtonDisplayed) { - toDoNeeded = true; - toDoMessage += (toDoMessage.isEmpty()) ? "" : "\n" + getString(R.string.missing_article); - } - - if (toDoNeeded) { - toDoMessage = getString(R.string.todo_improve) + "\n" + toDoMessage; - binding.toDoLayout.setVisibility(VISIBLE); - binding.toDoReason.setText(toDoMessage); - } else { - binding.toDoLayout.setVisibility(GONE); - } - } - - @Override - public void onDestroyView() { - if (layoutListener != null && getView() != null) { - getView().getViewTreeObserver().removeGlobalOnLayoutListener(layoutListener); // old Android was on crack. CRACK IS WHACK - layoutListener = null; - } - - compositeDisposable.clear(); - super.onDestroyView(); - } - - private void setTextFields(Media media) { - setupImageView(); - binding.mediaDetailTitle.setText(media.getDisplayTitle()); - binding.mediaDetailDesc.setHtmlText(prettyDescription(media)); - binding.mediaDetailLicense.setText(prettyLicense(media)); - binding.mediaDetailCoordinates.setText(prettyCoordinates(media)); - binding.mediaDetailuploadeddate.setText(prettyUploadedDate(media)); - if (prettyCaption(media).equals(getContext().getString(R.string.detail_caption_empty))) { - binding.captionLayout.setVisibility(GONE); - } else { - binding.mediaDetailCaption.setText(prettyCaption(media)); - } - - categoryNames.clear(); - categoryNames.addAll(media.getCategories()); - - if (media.getAuthor() == null || media.getAuthor().equals("")) { - binding.authorLinearLayout.setVisibility(GONE); - } else { - binding.mediaDetailAuthor.setText(media.getAuthor()); - } - } - - /** - * Gets new categories from the WikiText and updates it on the UI - * - * @param s WikiText - */ - private void updateCategoryList(final String s) { - final List allCategories = new ArrayList(); - int i = s.indexOf("[[Category:"); - while(i != -1){ - final String category = s.substring(i+11, s.indexOf("]]", i)); - allCategories.add(category); - i = s.indexOf("]]", i); - i = s.indexOf("[[Category:", i); - } - media.setCategories(allCategories); - if (allCategories.isEmpty()) { - // Stick in a filler element. - allCategories.add(getString(R.string.detail_panel_cats_none)); - } - if(sessionManager.isUserLoggedIn()) { - binding.categoryEditButton.setVisibility(VISIBLE); - } - rebuildCatList(allCategories); - } - - /** - * Updates the categories - */ - public void updateCategories() { - List allCategories = new ArrayList(media.getAddedCategories()); - media.setCategories(allCategories); - if (allCategories.isEmpty()) { - // Stick in a filler element. - allCategories.add(getString(R.string.detail_panel_cats_none)); - } - - rebuildCatList(allCategories); - } - - /** - * Populates media details fragment with depiction list - * @param idAndCaptions - */ - private void buildDepictionList(List idAndCaptions) { - binding.mediaDetailDepictionContainer.removeAllViews(); - String locale = Locale.getDefault().getLanguage(); - for (IdAndCaptions idAndCaption : idAndCaptions) { - binding.mediaDetailDepictionContainer.addView(buildDepictLabel( - getDepictionCaption(idAndCaption, locale), - idAndCaption.getId(), - binding.mediaDetailDepictionContainer - )); - } - } - - private String getDepictionCaption(IdAndCaptions idAndCaption, String locale) { - //Check if the Depiction Caption is available in user's locale if not then check for english, else show any available. - if(idAndCaption.getCaptions().get(locale) != null) { - return idAndCaption.getCaptions().get(locale); - } - if(idAndCaption.getCaptions().get("en") != null) { - return idAndCaption.getCaptions().get("en"); - } - return idAndCaption.getCaptions().values().iterator().next(); - } - - public void onMediaDetailLicenceClicked(){ - String url = media.getLicenseUrl(); - if (!StringUtils.isBlank(url) && getActivity() != null) { - Utils.handleWebUrl(getActivity(), Uri.parse(url)); - } else { - viewUtil.showShortToast(getActivity(), getString(R.string.null_url)); - } - } - - public void onMediaDetailCoordinatesClicked(){ - if (media.getCoordinates() != null && getActivity() != null) { - Utils.handleGeoCoordinates(getActivity(), media.getCoordinates()); - } - } - - public void onCopyWikicodeClicked() { - String data = - "[[" + media.getFilename() + "|thumb|" + media.getFallbackDescription() + "]]"; - Utils.copy("wikiCode", data, getContext()); - Timber.d("Generated wikidata copy code: %s", data); - - Toast.makeText(getContext(), getString(R.string.wikicode_copied), Toast.LENGTH_SHORT) - .show(); - } - - /** - * Sends thanks to author if the author is not the user - */ - public void sendThanksToAuthor() { - String fileName = media.getFilename(); - if (TextUtils.isEmpty(fileName)) { - Toast.makeText(getContext(), getString(R.string.error_sending_thanks), - Toast.LENGTH_SHORT).show(); - return; - } - compositeDisposable.add(reviewHelper.getFirstRevisionOfFile(fileName) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(revision -> sendThanks(getContext(), revision))); - } - - /** - * Api call for sending thanks to the author when the author is not the user - * and display toast depending on the result - * @param context context - * @param firstRevision the revision id of the image - */ - @SuppressLint({"CheckResult", "StringFormatInvalid"}) - void sendThanks(Context context, MwQueryPage.Revision firstRevision) { - ViewUtil.showShortToast(context, - context.getString(R.string.send_thank_toast, media.getDisplayTitle())); - - if (firstRevision == null) { - return; - } - - Observable.defer((Callable>) () -> thanksClient.thank( - firstRevision.getRevisionId())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - displayThanksToast(getContext(), result); - }, throwable -> { - if (throwable instanceof InvalidLoginTokenException) { - final String username = sessionManager.getUserName(); - final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener( - getActivity(), - requireActivity().getString(R.string.invalid_login_message), - username - ); - - CommonsApplication.getInstance().clearApplicationData( - requireActivity(), logoutListener); - } else { - Timber.e(throwable); - } - }); - } - - /** - * Method to display toast when api call to thank the author is completed - * @param context context - * @param result true if success, false otherwise - */ - @SuppressLint("StringFormatInvalid") - private void displayThanksToast(final Context context, final boolean result) { - final String message; - final String title; - if (result) { - title = context.getString(R.string.send_thank_success_title); - message = context.getString(R.string.send_thank_success_message, - media.getDisplayTitle()); - } else { - title = context.getString(R.string.send_thank_failure_title); - message = context.getString(R.string.send_thank_failure_message, - media.getDisplayTitle()); - } - - ViewUtil.showShortToast(context, message); - } - - public void onCategoryEditButtonClicked(){ - binding.progressBarEditCategory.setVisibility(VISIBLE); - binding.categoryEditButton.setVisibility(GONE); - getWikiText(); - } - - /** - * Gets WikiText from the server and send it to catgory editor - */ - private void getWikiText() { - compositeDisposable.add(mediaDataExtractor.getCurrentWikiText( - Objects.requireNonNull(media.getFilename())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::gotoCategoryEditor, Timber::e)); - } - - /** - * Opens the category editor - * - * @param s WikiText - */ - private void gotoCategoryEditor(final String s) { - binding.categoryEditButton.setVisibility(VISIBLE); - binding.progressBarEditCategory.setVisibility(GONE); - final Fragment categoriesFragment = new UploadCategoriesFragment(); - final Bundle bundle = new Bundle(); - bundle.putParcelable("Existing_Categories", media); - bundle.putString("WikiText", s); - categoriesFragment.setArguments(bundle); - final FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); - transaction.replace(R.id.mediaDetailFrameLayout, categoriesFragment); - transaction.addToBackStack(null); - transaction.commit(); - } - - public void onUpdateCoordinatesClicked(){ - goToLocationPickerActivity(); - } - - /** - * Start location picker activity with a request code and get the coordinates from the activity. - */ - private void goToLocationPickerActivity() { - /* - If location is not provided in media this coordinates will act as a placeholder in - location picker activity - */ - double defaultLatitude = 37.773972; - double defaultLongitude = -122.431297; - if (media.getCoordinates() != null) { - defaultLatitude = media.getCoordinates().getLatitude(); - defaultLongitude = media.getCoordinates().getLongitude(); - } else { - if(locationManager.getLastLocation()!=null) { - defaultLatitude = locationManager.getLastLocation().getLatitude(); - defaultLongitude = locationManager.getLastLocation().getLongitude(); - } else { - String[] lastLocation = applicationKvStore.getString(LAST_LOCATION,(defaultLatitude + "," + defaultLongitude)).split(","); - defaultLatitude = Double.parseDouble(lastLocation[0]); - defaultLongitude = Double.parseDouble(lastLocation[1]); - } - } - - - startActivity(new LocationPicker.IntentBuilder() - .defaultLocation(new CameraPosition(defaultLatitude,defaultLongitude,16.0)) - .activityKey("MediaActivity") - .media(media) - .build(getActivity())); - } - - public void onDescriptionEditClicked() { - binding.progressBarEdit.setVisibility(VISIBLE); - binding.descriptionEdit.setVisibility(GONE); - getDescriptionList(); - } - - /** - * Gets descriptions from wikitext - */ - private void getDescriptionList() { - compositeDisposable.add(mediaDataExtractor.getCurrentWikiText( - Objects.requireNonNull(media.getFilename())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::extractCaptionDescription, Timber::e)); - } - - /** - * Gets captions and descriptions and merge them according to language code and arranges it in a - * single list. - * Send the list to DescriptionEditActivity - * @param s wikitext - */ - private void extractCaptionDescription(final String s) { - final LinkedHashMap descriptions = getDescriptions(s); - final LinkedHashMap captions = getCaptionsList(); - - final ArrayList descriptionAndCaptions = new ArrayList<>(); - - if(captions.size() >= descriptions.size()) { - for (final Map.Entry mapElement : captions.entrySet()) { - - final String language = (String) mapElement.getKey(); - if (descriptions.containsKey(language)) { - descriptionAndCaptions.add( - new UploadMediaDetail(language, - Objects.requireNonNull(descriptions.get(language)), - (String) mapElement.getValue()) - ); - } else { - descriptionAndCaptions.add( - new UploadMediaDetail(language, "", - (String) mapElement.getValue()) - ); - } - } - for (final Map.Entry mapElement : descriptions.entrySet()) { - - final String language = (String) mapElement.getKey(); - if (!captions.containsKey(language)) { - descriptionAndCaptions.add( - new UploadMediaDetail(language, - Objects.requireNonNull(descriptions.get(language)), - "") - ); - } - } - } else { - for (final Map.Entry mapElement : descriptions.entrySet()) { - - final String language = (String) mapElement.getKey(); - if (captions.containsKey(language)) { - descriptionAndCaptions.add( - new UploadMediaDetail(language, (String) mapElement.getValue(), - Objects.requireNonNull(captions.get(language))) - ); - } else { - descriptionAndCaptions.add( - new UploadMediaDetail(language, (String) mapElement.getValue(), - "") - ); - } - } - for (final Map.Entry mapElement : captions.entrySet()) { - - final String language = (String) mapElement.getKey(); - if (!descriptions.containsKey(language)) { - descriptionAndCaptions.add( - new UploadMediaDetail(language, - "", - Objects.requireNonNull(descriptions.get(language))) - ); - } - } - } - final Intent intent = new Intent(requireContext(), DescriptionEditActivity.class); - final Bundle bundle = new Bundle(); - bundle.putParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION, descriptionAndCaptions); - bundle.putString(WIKITEXT, s); - bundle.putString(Prefs.DESCRIPTION_LANGUAGE, applicationKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, "")); - bundle.putParcelable("media", media); - intent.putExtras(bundle); - startActivity(intent); - } - - /** - * Filters descriptions from current wikiText and arranges it in LinkedHashmap according to the - * language code - * @param s wikitext - * @return LinkedHashMap - */ - private LinkedHashMap getDescriptions(String s) { - final Pattern pattern = Pattern.compile("[dD]escription *=(.*?)\n *\\|", Pattern.DOTALL); - final Matcher matcher = pattern.matcher(s); - String description = null; - if (matcher.find()) { - description = matcher.group(); - } - if(description == null){ - return new LinkedHashMap<>(); - } - - final LinkedHashMap descriptionList = new LinkedHashMap<>(); - - int count = 0; // number of "{{" - int startCode = 0; - int endCode = 0; - int startDescription = 0; - int endDescription = 0; - final HashSet allLanguageCodes = new HashSet<>(Arrays.asList("en","es","de","ja","fr","ru","pt","it","zh-hans","zh-hant","ar","ko","id","pl","nl","fa","hi","th","vi","sv","uk","cs","simple","hu","ro","fi","el","he","nb","da","sr","hr","ms","bg","ca","tr","sk","sh","bn","tl","mr","ta","kk","lt","az","bs","sl","sq","arz","zh-yue","ka","te","et","lv","ml","hy","uz","kn","af","nn","mk","gl","sw","eu","ur","ky","gu","bh","sco","ast","is","mn","be","an","km","si","ceb","jv","eo","als","ig","su","be-x-old","la","my","cy","ne","bar","azb","mzn","as","am","so","pa","map-bms","scn","tg","ckb","ga","lb","war","zh-min-nan","nds","fy","vec","pnb","zh-classical","lmo","tt","io","ia","br","hif","mg","wuu","gan","ang","or","oc","yi","ps","tk","ba","sah","fo","nap","vls","sa","ce","qu","ku","min","bcl","ilo","ht","li","wa","vo","nds-nl","pam","new","mai","sn","pms","eml","yo","ha","gn","frr","gd","hsb","cv","lo","os","se","cdo","sd","ksh","bat-smg","bo","nah","xmf","ace","roa-tara","hak","bjn","gv","mt","pfl","szl","bpy","rue","co","diq","sc","rw","vep","lij","kw","fur","pcd","lad","tpi","ext","csb","rm","kab","gom","udm","mhr","glk","za","pdc","om","iu","nv","mi","nrm","tcy","frp","myv","kbp","dsb","zu","ln","mwl","fiu-vro","tum","tet","tn","pnt","stq","nov","ny","xh","crh","lfn","st","pap","ay","zea","bxr","kl","sm","ak","ve","pag","nso","kaa","lez","gag","kv","bm","to","lbe","krc","jam","ss","roa-rup","dv","ie","av","cbk-zam","chy","inh","ug","ch","arc","pih","mrj","kg","rmy","dty","na","ts","xal","wo","fj","tyv","olo","ltg","ff","jbo","haw","ki","chr","sg","atj","sat","ady","ty","lrc","ti","din","gor","lg","rn","bi","cu","kbd","pi","cr","koi","ik","mdf","bug","ee","shn","tw","dz","srn","ks","test","en-x-piglatin","ab")); - for (int i = 0; i < description.length() - 1; i++) { - if (description.startsWith("{{", i)) { - if (count == 0) { - startCode = i; - endCode = description.indexOf("|", i); - startDescription = endCode + 1; - if (description.startsWith("1=", endCode + 1)) { - startDescription += 2; - i += 2; - } - } - i++; - count++; - } else if (description.startsWith("}}", i)) { - count--; - if (count == 0) { - endDescription = i; - final String languageCode = description.substring(startCode + 2, endCode); - final String languageDescription = description.substring(startDescription, endDescription); - if (allLanguageCodes.contains(languageCode)) { - descriptionList.put(languageCode, languageDescription); - } - } - i++; - } - } - return descriptionList; - } - - /** - * Gets list of caption and arranges it in a LinkedHashmap according to the language code - * @return LinkedHashMap - */ - private LinkedHashMap getCaptionsList() { - final LinkedHashMap captionList = new LinkedHashMap<>(); - final Map captions = media.getCaptions(); - for (final Map.Entry map : captions.entrySet()) { - final String language = map.getKey(); - final String languageCaption = map.getValue(); - captionList.put(language, languageCaption); - } - return captionList; - } - - /** - * Adds caption to the map and updates captions - * @param mediaDetail UploadMediaDetail - * @param updatedCaptions updated captionds - */ - private void updateCaptions(UploadMediaDetail mediaDetail, - LinkedHashMap updatedCaptions) { - updatedCaptions.put(mediaDetail.getLanguageCode(), mediaDetail.getCaptionText()); - media.setCaptions(updatedCaptions); - } - - @SuppressLint("StringFormatInvalid") - public void onDeleteButtonClicked(){ - if (AccountUtilKt.getUserName(getContext()) != null && AccountUtilKt.getUserName(getContext()).equals(media.getAuthor())) { - final ArrayAdapter languageAdapter = new ArrayAdapter<>(getActivity(), - R.layout.simple_spinner_dropdown_list, reasonList); - final Spinner spinner = new Spinner(getActivity()); - spinner.setLayoutParams( - new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT)); - spinner.setAdapter(languageAdapter); - spinner.setGravity(17); - - AlertDialog dialog = DialogUtil.showAlertDialog(getActivity(), - getString(R.string.nominate_delete), - null, - getString(R.string.about_translate_proceed), - getString(R.string.about_translate_cancel), - () -> onDeleteClicked(spinner), - () -> {}, - spinner, - true); - if (isDeleted) { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); - } - } - //Reviewer correct me if i have misunderstood something over here - //But how does this if (delete.getVisibility() == View.VISIBLE) { - // enableDeleteButton(true); makes sense ? - else if (AccountUtilKt.getUserName(getContext()) != null) { - final EditText input = new EditText(getActivity()); - input.requestFocus(); - AlertDialog d = DialogUtil.showAlertDialog(getActivity(), - null, - getString(R.string.dialog_box_text_nomination, media.getDisplayTitle()), - getString(R.string.ok), - getString(R.string.cancel), - () -> { - String reason = input.getText().toString(); - onDeleteClickeddialogtext(reason); - }, - () -> {}, - input, - true); - input.addTextChangedListener(new TextWatcher() { - private void handleText() { - final Button okButton = d.getButton(AlertDialog.BUTTON_POSITIVE); - if (input.getText().length() == 0 || isDeleted) { - okButton.setEnabled(false); - } else { - okButton.setEnabled(true); - } - } - - @Override - public void afterTextChanged(Editable arg0) { - handleText(); - } - - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - }); - d.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); - } - } - - @SuppressLint("CheckResult") - private void onDeleteClicked(Spinner spinner) { - applicationKvStore.putBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), true); - enableProgressBar(); - String reason = reasonListEnglishMappings.get(spinner.getSelectedItemPosition()); - String finalReason = reason; - Single resultSingle = reasonBuilder.getReason(media, reason) - .flatMap(reasonString -> deleteHelper.makeDeletion(getContext(), media, finalReason)); - resultSingle - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(s -> { - if(applicationKvStore.getBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), false)) { - applicationKvStore.remove(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl())); - callback.nominatingForDeletion(index); - } - }); - } - - @SuppressLint("CheckResult") - private void onDeleteClickeddialogtext(String reason) { - applicationKvStore.putBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), true); - enableProgressBar(); - Single resultSingletext = reasonBuilder.getReason(media, reason) - .flatMap(reasonString -> deleteHelper.makeDeletion(getContext(), media, reason)); - resultSingletext - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(s -> { - if(applicationKvStore.getBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), false)) { - applicationKvStore.remove(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl())); - callback.nominatingForDeletion(index); - } - }); - } - - public void onSeeMoreClicked(){ - if (binding.nominatedDeletionBanner.getVisibility() == VISIBLE && getActivity() != null) { - Utils.handleWebUrl(getActivity(), Uri.parse(media.getPageTitle().getMobileUri())); - } - } - - public void onAuthorViewClicked() { - if (media == null || media.getUser() == null) { - return; - } - if (sessionManager.getUserName() == null) { - String userProfileLink = BuildConfig.COMMONS_URL + "/wiki/User:" + media.getUser(); - Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(userProfileLink)); - startActivity(browserIntent); - return; - } - ProfileActivity.startYourself(getActivity(), media.getUser(), !Objects - .equals(sessionManager.getUserName(), media.getUser())); - } - - /** - * Enable Progress Bar and Update delete button text. - */ - private void enableProgressBar() { - binding.progressBarDeletion.setVisibility(VISIBLE); - binding.nominateDeletion.setText("Nominating for Deletion"); - isDeleted = true; - } - - private void rebuildCatList(List categories) { - binding.mediaDetailCategoryContainer.removeAllViews(); - for (String category : categories) { - binding.mediaDetailCategoryContainer.addView(buildCatLabel(sanitise(category), binding.mediaDetailCategoryContainer)); - } - } - - //As per issue #1826(see https://github.com/commons-app/apps-android-commons/issues/1826), some categories come suffixed with strings prefixed with |. As per the discussion - //that was meant for alphabetical sorting of the categories and can be safely removed. - private String sanitise(String category) { - int indexOfPipe = category.indexOf('|'); - if (indexOfPipe != -1) { - //Removed everything after '|' - return category.substring(0, indexOfPipe); - } - return category; - } - - /** - * Add view to depictions obtained also tapping on depictions should open the url - */ - private View buildDepictLabel(String depictionName, String entityId, LinearLayout depictionContainer) { - final View item = LayoutInflater.from(getContext()).inflate(R.layout.detail_category_item, depictionContainer,false); - final TextView textView = item.findViewById(R.id.mediaDetailCategoryItemText); - textView.setText(depictionName); - item.setOnClickListener(view -> { - Intent intent = new Intent(getContext(), WikidataItemDetailsActivity.class); - intent.putExtra("wikidataItemName", depictionName); - intent.putExtra("entityId", entityId); - intent.putExtra("fragment", "MediaDetailFragment"); - getContext().startActivity(intent); - }); - return item; - } - - private View buildCatLabel(final String catName, ViewGroup categoryContainer) { - final View item = LayoutInflater.from(getContext()).inflate(R.layout.detail_category_item, categoryContainer, false); - final TextView textView = item.findViewById(R.id.mediaDetailCategoryItemText); - - textView.setText(catName); - if(!getString(R.string.detail_panel_cats_none).equals(catName)) { - textView.setOnClickListener(view -> { - // Open Category Details page - Intent intent = new Intent(getContext(), CategoryDetailsActivity.class); - intent.putExtra("categoryName", catName); - getContext().startActivity(intent); - }); - } - return item; - } - - /** - * Returns captions for media details - * - * @param media object of class media - * @return caption as string - */ - private String prettyCaption(Media media) { - for (String caption : media.getCaptions().values()) { - if (caption.equals("")) { - return getString(R.string.detail_caption_empty); - } else { - return caption; - } - } - return getString(R.string.detail_caption_empty); - } - - private String prettyDescription(Media media) { - String description = chooseDescription(media); - if (!description.isEmpty()) { - // Remove img tag that sometimes appears as a blue square in the app, - // see https://github.com/commons-app/apps-android-commons/issues/4345 - description = description.replaceAll("[<](/)?img[^>]*[>]", ""); - } - return description.isEmpty() ? getString(R.string.detail_description_empty) - : description; - } - - private String chooseDescription(Media media) { - final Map descriptions = media.getDescriptions(); - final String multilingualDesc = descriptions.get(Locale.getDefault().getLanguage()); - if (multilingualDesc != null) { - return multilingualDesc; - } - for (String description : descriptions.values()) { - return description; - } - return media.getFallbackDescription(); - } - - private String prettyDiscussion(String discussion) { - return discussion.isEmpty() ? getString(R.string.detail_discussion_empty) : discussion; - } - - private String prettyLicense(Media media) { - String licenseKey = media.getLicense(); - Timber.d("Media license is: %s", licenseKey); - if (licenseKey == null || licenseKey.equals("")) { - return getString(R.string.detail_license_empty); - } - return licenseKey; - } - - private String prettyUploadedDate(Media media) { - Date date = media.getDateUploaded(); - if (date == null || date.toString() == null || date.toString().isEmpty()) { - return "Uploaded date not available"; - } - return DateUtil.getDateStringWithSkeletonPattern(date, "dd MMM yyyy"); - } - - /** - * Returns the coordinates nicely formatted. - * - * @return Coordinates as text. - */ - private String prettyCoordinates(Media media) { - if (media.getCoordinates() == null) { - return getString(R.string.media_detail_coordinates_empty); - } - return media.getCoordinates().getPrettyCoordinateString(); - } - - @Override - public boolean updateCategoryDisplay(List categories) { - if (categories == null) { - return false; - } else { - rebuildCatList(categories); - return true; - } - } - - void showCaptionAndDescription() { - if (binding.dummyCaptionDescriptionContainer.getVisibility() == GONE) { - binding.dummyCaptionDescriptionContainer.setVisibility(VISIBLE); - setUpCaptionAndDescriptionLayout(); - } else { - binding.dummyCaptionDescriptionContainer.setVisibility(GONE); - } - } - - /** - * setUp Caption And Description Layout - */ - private void setUpCaptionAndDescriptionLayout() { - List captions = getCaptions(); - - if (descriptionHtmlCode == null) { - binding.showCaptionsBinding.pbCircular.setVisibility(VISIBLE); - } - - getDescription(); - CaptionListViewAdapter adapter = new CaptionListViewAdapter(captions); - binding.showCaptionsBinding.captionListview.setAdapter(adapter); - } - - /** - * Generate the caption with language - */ - private List getCaptions() { - List captionList = new ArrayList<>(); - Map captions = media.getCaptions(); - AppLanguageLookUpTable appLanguageLookUpTable = new AppLanguageLookUpTable(getContext()); - for (Map.Entry map : captions.entrySet()) { - String language = appLanguageLookUpTable.getLocalizedName(map.getKey()); - String languageCaption = map.getValue(); - captionList.add(new Caption(language, languageCaption)); - } - - if (captionList.size() == 0) { - captionList.add(new Caption("", "No Caption")); - } - return captionList; - } - - private void getDescription() { - compositeDisposable.add(mediaDataExtractor.getHtmlOfPage( - Objects.requireNonNull(media.getFilename())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::extractDescription, Timber::e)); - } - - /** - * extract the description from html of imagepage - */ - private void extractDescription(String s) { - String descriptionClassName = ""; - int start = s.indexOf(descriptionClassName) + descriptionClassName.length(); - int end = s.indexOf("", start); - descriptionHtmlCode = ""; - for (int i = start; i < end; i++) { - descriptionHtmlCode = descriptionHtmlCode + s.toCharArray()[i]; - } - - binding.showCaptionsBinding.descriptionWebview - .loadDataWithBaseURL(null, descriptionHtmlCode, "text/html", "utf-8", null); - binding.showCaptionsBinding.pbCircular.setVisibility(GONE); - } - - /** - * Handle back event when fragment when showCaptionAndDescriptionContainer is visible - */ - private void handleBackEvent(View view) { - view.setFocusableInTouchMode(true); - view.requestFocus(); - view.setOnKeyListener(new OnKeyListener() { - @Override - public boolean onKey(View view, int keycode, KeyEvent keyEvent) { - if (keycode == KeyEvent.KEYCODE_BACK) { - if (binding.dummyCaptionDescriptionContainer.getVisibility() == VISIBLE) { - binding.dummyCaptionDescriptionContainer.setVisibility(GONE); - return true; - } - } - return false; - } - }); - - } - - - public interface Callback { - void nominatingForDeletion(int index); - } - - /** - * Called when the image background color is changed. - * You should pass a useable color, not a resource id. - * @param color - */ - public void onImageBackgroundChanged(int color) { - int currentColor = getImageBackgroundColor(); - if (currentColor == color) { - return; - } - - binding.mediaDetailImageView.setBackgroundColor(color); - getImageBackgroundColorPref().edit().putInt(IMAGE_BACKGROUND_COLOR, color).apply(); - } - - private SharedPreferences getImageBackgroundColorPref() { - return getContext().getSharedPreferences(IMAGE_BACKGROUND_COLOR + media.getPageId(), Context.MODE_PRIVATE); - } - - private int getImageBackgroundColor() { - SharedPreferences imageBackgroundColorPref = this.getImageBackgroundColorPref(); - return imageBackgroundColorPref.getInt(IMAGE_BACKGROUND_COLOR, DEFAULT_IMAGE_BACKGROUND_COLOR); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt new file mode 100644 index 0000000000..32785fd425 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt @@ -0,0 +1,2233 @@ +package fr.free.nrw.commons.media + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Configuration +import android.graphics.drawable.Animatable +import android.net.Uri +import android.os.Bundle +import android.text.Editable +import android.text.TextUtils +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.Spinner +import android.widget.TextView +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.viewModels +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.drawee.controller.BaseControllerListener +import com.facebook.drawee.controller.ControllerListener +import com.facebook.drawee.interfaces.DraweeController +import com.facebook.imagepipeline.image.ImageInfo +import com.facebook.imagepipeline.request.ImageRequest +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.CameraPosition +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.CommonsApplication.Companion.instance +import fr.free.nrw.commons.LocationPicker.LocationPicker +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.MediaDataExtractor +import fr.free.nrw.commons.R +import fr.free.nrw.commons.Utils +import fr.free.nrw.commons.actions.ThanksClient +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException +import fr.free.nrw.commons.auth.getUserName +import fr.free.nrw.commons.category.CATEGORY_NEEDING_CATEGORIES +import fr.free.nrw.commons.category.CATEGORY_UNCATEGORISED +import fr.free.nrw.commons.category.CategoryClient +import fr.free.nrw.commons.category.CategoryDetailsActivity +import fr.free.nrw.commons.category.CategoryEditHelper +import fr.free.nrw.commons.contributions.ContributionsFragment +import fr.free.nrw.commons.coordinates.CoordinateEditHelper +import fr.free.nrw.commons.databinding.FragmentMediaDetailBinding +import fr.free.nrw.commons.delete.DeleteHelper +import fr.free.nrw.commons.delete.ReasonBuilder +import fr.free.nrw.commons.description.DescriptionEditActivity +import fr.free.nrw.commons.description.DescriptionEditHelper +import fr.free.nrw.commons.description.EditDescriptionConstants.LIST_OF_DESCRIPTION_AND_CAPTION +import fr.free.nrw.commons.description.EditDescriptionConstants.WIKITEXT +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.language.AppLanguageLookUpTable +import fr.free.nrw.commons.location.LocationServiceManager +import fr.free.nrw.commons.media.MediaDetailPagerFragment.MediaDetailProvider +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.review.ReviewHelper +import fr.free.nrw.commons.settings.Prefs +import fr.free.nrw.commons.upload.UploadMediaDetail +import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment +import fr.free.nrw.commons.upload.depicts.DepictsFragment +import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment +import fr.free.nrw.commons.utils.DateUtil.getDateStringWithSkeletonPattern +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import fr.free.nrw.commons.utils.LangCodeUtils.getLocalizedResources +import fr.free.nrw.commons.utils.PermissionUtils.PERMISSIONS_STORAGE +import fr.free.nrw.commons.utils.PermissionUtils.checkPermissionsAndPerformAction +import fr.free.nrw.commons.utils.PermissionUtils.hasPermission +import fr.free.nrw.commons.utils.ViewUtil.showShortToast +import fr.free.nrw.commons.utils.ViewUtilWrapper +import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage.Revision +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.apache.commons.lang3.StringUtils +import timber.log.Timber +import java.util.Date +import java.util.Locale +import java.util.Objects +import java.util.regex.Matcher +import java.util.regex.Pattern +import javax.inject.Inject +import javax.inject.Named + +class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.Callback { + private var editable: Boolean = false + private var isCategoryImage: Boolean = false + private var detailProvider: MediaDetailProvider? = null + private var index: Int = 0 + private var isDeleted: Boolean = false + private var isWikipediaButtonDisplayed: Boolean = false + private val callback: Callback? = null + + @Inject + lateinit var mediaDetailViewModelFactory: MediaDetailViewModel.MediaDetailViewModelProviderFactory + + @Inject + lateinit var locationManager: LocationServiceManager + + + @Inject + lateinit var sessionManager: SessionManager + + @Inject + lateinit var mediaDataExtractor: MediaDataExtractor + + @Inject + lateinit var reasonBuilder: ReasonBuilder + + @Inject + lateinit var deleteHelper: DeleteHelper + + @Inject + lateinit var reviewHelper: ReviewHelper + + @Inject + lateinit var categoryEditHelper: CategoryEditHelper + + @Inject + lateinit var coordinateEditHelper: CoordinateEditHelper + + @Inject + lateinit var descriptionEditHelper: DescriptionEditHelper + + @Inject + lateinit var viewUtil: ViewUtilWrapper + + @Inject + lateinit var categoryClient: CategoryClient + + @Inject + lateinit var thanksClient: ThanksClient + + @Inject + @field:Named("default_preferences") + lateinit var applicationKvStore: JsonKvStore + + private val viewModel: MediaDetailViewModel by viewModels { mediaDetailViewModelFactory } + + private var initialListTop: Int = 0 + + private var _binding: FragmentMediaDetailBinding? = null + private val binding get() = _binding!! + + private var descriptionHtmlCode: String? = null + + + private val categoryNames: ArrayList = ArrayList() + + /** + * Depicts is a feature part of Structured data. + * Multiple Depictions can be added for an image just like categories. + * However unlike categories depictions is multi-lingual + * Ex: key: en value: monument + */ + private var imageInfoCache: ImageInfo? = null + private var oldWidthOfImageView: Int = 0 + private var newWidthOfImageView: Int = 0 + private var heightVerifyingBoolean: Boolean = true // helps in maintaining aspect ratio + private var layoutListener: OnGlobalLayoutListener? = null // for layout stuff, only used once! + + //Had to make this class variable, to implement various onClicks, which access the media, + // also I fell why make separate variables when one can serve the purpose + private var media: Media? = null + private lateinit var reasonList: ArrayList + private lateinit var reasonListEnglishMappings: ArrayList + + /** + * Height stores the height of the frame layout as soon as it is initialised + * and updates itself on configuration changes. + * Used to adjust aspect ratio of image when length of the image is too large. + */ + private var frameLayoutHeight: Int = 0 + + /** + * Minimum height of the metadata, in pixels. + * Images with a very narrow aspect ratio will be reduced so that the metadata information + * panel always has at least this height. + */ + private val minimumHeightOfMetadata: Int = 200 + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt("index", index) + outState.putBoolean("editable", editable) + outState.putBoolean("isCategoryImage", isCategoryImage) + outState.putBoolean("isWikipediaButtonDisplayed", isWikipediaButtonDisplayed) + + scrollPosition + outState.putInt("listTop", initialListTop) + } + + private val scrollPosition: Unit + get() { + initialListTop = binding.mediaDetailScrollView.scrollY + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + if (parentFragment != null + && parentFragment is MediaDetailPagerFragment + ) { + detailProvider = + (parentFragment as MediaDetailPagerFragment).mediaDetailProvider + } + if (savedInstanceState != null) { + editable = savedInstanceState.getBoolean("editable") + isCategoryImage = savedInstanceState.getBoolean("isCategoryImage") + isWikipediaButtonDisplayed = savedInstanceState.getBoolean("isWikipediaButtonDisplayed") + index = savedInstanceState.getInt("index") + initialListTop = savedInstanceState.getInt("listTop") + } else { + editable = requireArguments().getBoolean("editable") + isCategoryImage = requireArguments().getBoolean("isCategoryImage") + isWikipediaButtonDisplayed = requireArguments().getBoolean("isWikipediaButtonDisplayed") + index = requireArguments().getInt("index") + initialListTop = 0 + } + + reasonList = ArrayList() + reasonList.add(getString(R.string.deletion_reason_uploaded_by_mistake)) + reasonList.add(getString(R.string.deletion_reason_publicly_visible)) + reasonList.add(getString(R.string.deletion_reason_not_interesting)) + reasonList.add(getString(R.string.deletion_reason_no_longer_want_public)) + reasonList.add(getString(R.string.deletion_reason_bad_for_my_privacy)) + + // Add corresponding mappings in english locale so that we can upload it in deletion request + reasonListEnglishMappings = ArrayList() + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_uploaded_by_mistake) + ) + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_publicly_visible) + ) + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_not_interesting) + ) + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_no_longer_want_public) + ) + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_bad_for_my_privacy) + ) + + _binding = FragmentMediaDetailBinding.inflate(inflater, container, false) + val view: View = binding.root + + + Utils.setUnderlinedText(binding.seeMore, R.string.nominated_see_more, requireContext()) + + if (isCategoryImage) { + binding.authorLinearLayout.visibility = View.VISIBLE + } else { + binding.authorLinearLayout.visibility = View.GONE + } + + if (!sessionManager.isUserLoggedIn) { + binding.categoryEditButton.visibility = View.GONE + binding.descriptionEdit.visibility = View.GONE + binding.depictionsEditButton.visibility = View.GONE + } else { + binding.categoryEditButton.visibility = View.VISIBLE + binding.descriptionEdit.visibility = View.VISIBLE + binding.depictionsEditButton.visibility = View.VISIBLE + } + + if (applicationKvStore.getBoolean("login_skipped")) { + binding.nominateDeletion.visibility = View.GONE + binding.coordinateEdit.visibility = View.GONE + } + + handleBackEvent(view) + + //set onCLick listeners + binding.mediaDetailLicense.setOnClickListener { onMediaDetailLicenceClicked() } + binding.mediaDetailCoordinates.setOnClickListener { onMediaDetailCoordinatesClicked() } + binding.sendThanks.setOnClickListener { sendThanksToAuthor() } + binding.dummyCaptionDescriptionContainer.setOnClickListener { showCaptionAndDescription() } + binding.mediaDetailImageView.setOnClickListener { + launchZoomActivity( + binding.mediaDetailImageView + ) + } + binding.categoryEditButton.setOnClickListener { onCategoryEditButtonClicked() } + binding.depictionsEditButton.setOnClickListener { onDepictionsEditButtonClicked() } + binding.seeMore.setOnClickListener { onSeeMoreClicked() } + binding.mediaDetailAuthor.setOnClickListener { onAuthorViewClicked() } + binding.nominateDeletion.setOnClickListener { onDeleteButtonClicked() } + binding.descriptionEdit.setOnClickListener { onDescriptionEditClicked() } + binding.coordinateEdit.setOnClickListener { onUpdateCoordinatesClicked() } + binding.copyWikicode.setOnClickListener { onCopyWikicodeClicked() } + + binding.fileUsagesComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) darkColorScheme( + primary = colorResource(R.color.primaryDarkColor), + surface = colorResource(R.color.main_background_dark), + background = colorResource(R.color.main_background_dark) + ) else lightColorScheme( + primary = colorResource(R.color.primaryColor), + surface = colorResource(R.color.main_background_light), + background = colorResource(R.color.main_background_light) + ) + ) { + + val commonsContainerState by viewModel.commonsContainerState.collectAsState() + val globalContainerState by viewModel.globalContainerState.collectAsState() + + Surface { + Column { + Text( + text = stringResource(R.string.file_usages_container_heading), + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + FileUsagesContainer( + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + commonsContainerState = commonsContainerState, + globalContainerState = globalContainerState + ) + } + } + + + } + } + } + + /** + * Gets the height of the frame layout as soon as the view is ready and updates aspect ratio + * of the picture. + */ + view.post { + frameLayoutHeight = binding.mediaDetailFrameLayout.measuredHeight + updateAspectRatio(binding.mediaDetailScrollView.width) + } + + return view + } + + fun launchZoomActivity(view: View) { + val hasPermission: Boolean = hasPermission(requireActivity(), PERMISSIONS_STORAGE) + if (hasPermission) { + launchZoomActivityAfterPermissionCheck(view) + } else { + checkPermissionsAndPerformAction( + requireActivity(), + { + launchZoomActivityAfterPermissionCheck(view) + }, + R.string.storage_permission_title, + R.string.read_storage_permission_rationale, + *PERMISSIONS_STORAGE + ) + } + } + + private fun fetchFileUsages(fileName: String) { + if (viewModel.commonsContainerState.value == MediaDetailViewModel.FileUsagesContainerState.Initial) { + viewModel.loadFileUsagesCommons(fileName) + } + + if (viewModel.globalContainerState.value == MediaDetailViewModel.FileUsagesContainerState.Initial) { + viewModel.loadGlobalFileUsages(fileName) + } + } + + /** + * launch zoom acitivity after permission check + * @param view as ImageView + */ + private fun launchZoomActivityAfterPermissionCheck(view: View) { + if (media!!.imageUrl != null) { + val ctx: Context = view.context + val zoomableIntent = Intent(ctx, ZoomableActivity::class.java) + zoomableIntent.setData(Uri.parse(media!!.imageUrl)) + zoomableIntent.putExtra( + ZoomableActivity.ZoomableActivityConstants.ORIGIN, "MediaDetails" + ) + + val backgroundColor: Int = imageBackgroundColor + if (backgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { + zoomableIntent.putExtra( + ZoomableActivity.ZoomableActivityConstants.PHOTO_BACKGROUND_COLOR, + backgroundColor + ) + } + + ctx.startActivity( + zoomableIntent + ) + } + } + + override fun onResume() { + super.onResume() + if (parentFragment != null && requireParentFragment().parentFragment != null) { + // Added a check because, not necessarily, the parent fragment + // will have a parent fragment, say in the case when MediaDetailPagerFragment + // is directly started by the CategoryImagesActivity + if (parentFragment is ContributionsFragment) { + (((parentFragment as ContributionsFragment) + .parentFragment) as ContributionsFragment).binding.cardViewNearby.visibility = + View.GONE + } + } + // detail provider is null when fragment is shown in review activity + media = if (detailProvider != null) { + detailProvider!!.getMediaAtPosition(index) + } else { + requireArguments().getParcelable("media") + } + + if (media != null && applicationKvStore.getBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl + ), false + ) + ) { + enableProgressBar() + } + + if (getUserName(requireContext()) != null && media != null && getUserName( + requireContext() + ) == media!!.author + ) { + binding.sendThanks.visibility = View.GONE + } else { + binding.sendThanks.visibility = View.VISIBLE + } + + binding.mediaDetailScrollView.viewTreeObserver.addOnGlobalLayoutListener( + object : OnGlobalLayoutListener { + override fun onGlobalLayout() { + if (context == null) { + return + } + binding.mediaDetailScrollView.viewTreeObserver.removeOnGlobalLayoutListener( + this + ) + oldWidthOfImageView = binding.mediaDetailScrollView.width + if (media != null) { + displayMediaDetails() + fetchFileUsages(media?.filename!!) + } + } + } + ) + binding.progressBarEdit.visibility = View.GONE + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + binding.mediaDetailScrollView.viewTreeObserver.addOnGlobalLayoutListener( + object : OnGlobalLayoutListener { + override fun onGlobalLayout() { + /** + * We update the height of the frame layout as the configuration changes. + */ + binding.mediaDetailFrameLayout.post { + frameLayoutHeight = binding.mediaDetailFrameLayout.measuredHeight + updateAspectRatio(binding.mediaDetailScrollView.width) + } + if (binding.mediaDetailScrollView.width != oldWidthOfImageView) { + if (newWidthOfImageView == 0) { + newWidthOfImageView = binding.mediaDetailScrollView.width + updateAspectRatio(newWidthOfImageView) + } + binding.mediaDetailScrollView.viewTreeObserver.removeOnGlobalLayoutListener( + this + ) + } + } + } + ) + // Ensuring correct aspect ratio for landscape mode + if (heightVerifyingBoolean) { + updateAspectRatio(newWidthOfImageView) + heightVerifyingBoolean = false + } else { + updateAspectRatio(oldWidthOfImageView) + heightVerifyingBoolean = true + } + } + + private fun displayMediaDetails() { + setTextFields(media!!) + compositeDisposable.addAll( + mediaDataExtractor.refresh(media!!) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { media: Media -> onMediaRefreshed(media) }, + { t: Throwable? -> Timber.e(t) }), + mediaDataExtractor.getCurrentWikiText( + Objects.requireNonNull(media?.filename!!) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { s: String? -> updateCategoryList(s!!) }, + { t: Throwable? -> Timber.e(t) }), + mediaDataExtractor.checkDeletionRequestExists(media!!) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { deletionPageExists: Boolean -> onDeletionPageExists(deletionPageExists) }, + { t: Throwable? -> Timber.e(t) }), + mediaDataExtractor.fetchDiscussion(media!!) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { discussion: String -> onDiscussionLoaded(discussion) }, + { t: Throwable? -> Timber.e(t) }) + ) + } + + private fun onMediaRefreshed(media: Media) { + media.categories = this.media!!.categories + this.media = media + setTextFields(media) + compositeDisposable.addAll( + mediaDataExtractor.fetchDepictionIdsAndLabels(media) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { idAndCaptions: List -> onDepictionsLoaded(idAndCaptions) }, + { t: Throwable? -> Timber.e(t) }) + ) + // compositeDisposable.add(disposable); + } + + private fun onDiscussionLoaded(discussion: String) { + binding.mediaDetailDisc.text = prettyDiscussion(discussion.trim { it <= ' ' }) + } + + private fun onDeletionPageExists(deletionPageExists: Boolean) { + if (getUserName(requireContext()) == null && getUserName(requireContext()) != media!!.author) { + binding.nominateDeletion.visibility = View.GONE + binding.nominatedDeletionBanner.visibility = View.GONE + } else if (deletionPageExists) { + if (applicationKvStore.getBoolean( + String.format(NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl), false + ) + ) { + applicationKvStore.remove( + String.format(NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl) + ) + binding.progressBarDeletion.visibility = View.GONE + } + binding.nominateDeletion.visibility = View.GONE + + binding.nominatedDeletionBanner.visibility = View.VISIBLE + } else if (!isCategoryImage) { + binding.nominateDeletion.visibility = View.VISIBLE + binding.nominatedDeletionBanner.visibility = View.GONE + } + } + + private fun onDepictionsLoaded(idAndCaptions: List) { + binding.depictsLayout.visibility = + if (idAndCaptions.isEmpty()) View.GONE else View.VISIBLE + binding.depictionsEditButton.visibility = + if (idAndCaptions.isEmpty()) View.GONE else View.VISIBLE + buildDepictionList(idAndCaptions) + } + + /** + * By clicking on the edit depictions button, it will send user to depict fragment + */ + fun onDepictionsEditButtonClicked() { + binding.mediaDetailDepictionContainer.removeAllViews() + binding.depictionsEditButton.visibility = View.GONE + val depictsFragment: Fragment = DepictsFragment() + val bundle = Bundle() + bundle.putParcelable("Existing_Depicts", media) + depictsFragment.arguments = bundle + val transaction: FragmentTransaction = childFragmentManager.beginTransaction() + transaction.replace(R.id.mediaDetailFrameLayout, depictsFragment) + transaction.addToBackStack(null) + transaction.commit() + } + + /** + * The imageSpacer is Basically a transparent overlay for the SimpleDraweeView + * which holds the image to be displayed( moreover this image is out of + * the scroll view ) + * + * + * If the image is sufficiently large i.e. the image height extends the view height, we reduce + * the height and change the width to maintain the aspect ratio, otherwise image takes up the + * total possible width and height is adjusted accordingly. + * + * @param scrollWidth the current width of the scrollView + */ + private fun updateAspectRatio(scrollWidth: Int) { + if (imageInfoCache != null) { + var finalHeight: Int = (scrollWidth * imageInfoCache!!.height) / imageInfoCache!!.width + val params: ViewGroup.LayoutParams = binding.mediaDetailImageView.layoutParams + val spacerParams: ViewGroup.LayoutParams = + binding.mediaDetailImageViewSpacer.layoutParams + params.width = scrollWidth + if (finalHeight > frameLayoutHeight - minimumHeightOfMetadata) { + // Adjust the height and width of image. + + val temp: Int = frameLayoutHeight - minimumHeightOfMetadata + params.width = (scrollWidth * temp) / finalHeight + finalHeight = temp + } + params.height = finalHeight + spacerParams.height = finalHeight + binding.mediaDetailImageView.layoutParams = params + binding.mediaDetailImageViewSpacer.layoutParams = spacerParams + } + } + + private val aspectRatioListener: ControllerListener = + object : BaseControllerListener() { + override fun onIntermediateImageSet(id: String, imageInfo: ImageInfo?) { + imageInfoCache = imageInfo + updateAspectRatio(binding.mediaDetailScrollView.width) + } + + override fun onFinalImageSet( + id: String, + imageInfo: ImageInfo?, + animatable: Animatable? + ) { + imageInfoCache = imageInfo + updateAspectRatio(binding.mediaDetailScrollView.width) + } + } + + /** + * Uses two image sources. + * - low resolution thumbnail is shown initially + * - when the high resolution image is available, it replaces the low resolution image + */ + private fun setupImageView() { + val imageBackgroundColor: Int = imageBackgroundColor + if (imageBackgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { + binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor) + } + + binding.mediaDetailImageView.hierarchy.setPlaceholderImage(R.drawable.image_placeholder) + binding.mediaDetailImageView.hierarchy.setFailureImage(R.drawable.image_placeholder) + + val controller: DraweeController = Fresco.newDraweeControllerBuilder() + .setLowResImageRequest(ImageRequest.fromUri(if (media != null) media!!.thumbUrl else null)) + .setRetainImageOnFailure(true) + .setImageRequest(ImageRequest.fromUri(if (media != null) media!!.imageUrl else null)) + .setControllerListener(aspectRatioListener) + .setOldController(binding.mediaDetailImageView.controller) + .build() + binding.mediaDetailImageView.controller = controller + } + + private fun updateToDoWarning() { + var toDoMessage = "" + var toDoNeeded = false + var categoriesPresent: Boolean = + if (media!!.categories == null) false else (media!!.categories!!.isNotEmpty()) + + // Check if the presented category is about need of category + if (categoriesPresent) { + for (category: String in media!!.categories!!) { + if (category.lowercase().contains(CATEGORY_NEEDING_CATEGORIES) || + category.lowercase().contains(CATEGORY_UNCATEGORISED) + ) { + categoriesPresent = false + } + break + } + } + if (!categoriesPresent) { + toDoNeeded = true + toDoMessage += getString(R.string.missing_category) + } + if (isWikipediaButtonDisplayed) { + toDoNeeded = true + toDoMessage += if ((toDoMessage.isEmpty())) "" else "\n" + getString(R.string.missing_article) + } + + if (toDoNeeded) { + toDoMessage = getString(R.string.todo_improve) + "\n" + toDoMessage + binding.toDoLayout.visibility = View.VISIBLE + binding.toDoReason.text = toDoMessage + } else { + binding.toDoLayout.visibility = View.GONE + } + } + + override fun onDestroyView() { + if (layoutListener != null && view != null) { + requireView().viewTreeObserver.removeGlobalOnLayoutListener(layoutListener) // old Android was on crack. CRACK IS WHACK + layoutListener = null + } + + compositeDisposable.clear() + + super.onDestroyView() + } + + private fun setTextFields(media: Media) { + setupImageView() + binding.mediaDetailTitle.text = media.displayTitle + binding.mediaDetailDesc.setHtmlText(prettyDescription(media)) + binding.mediaDetailLicense.text = prettyLicense(media) + binding.mediaDetailCoordinates.text = prettyCoordinates(media) + binding.mediaDetailuploadeddate.text = prettyUploadedDate(media) + if (prettyCaption(media) == requireContext().getString(R.string.detail_caption_empty)) { + binding.captionLayout.visibility = View.GONE + } else { + binding.mediaDetailCaption.text = prettyCaption(media) + } + + categoryNames.clear() + categoryNames.addAll(media.categories!!) + + if (media.author == null || media.author == "") { + binding.authorLinearLayout.visibility = View.GONE + } else { + binding.mediaDetailAuthor.text = media.author + } + } + + /** + * Gets new categories from the WikiText and updates it on the UI + * + * @param s WikiText + */ + private fun updateCategoryList(s: String) { + val allCategories: MutableList = ArrayList() + var i: Int = s.indexOf("[[Category:") + while (i != -1) { + val category: String = s.substring(i + 11, s.indexOf("]]", i)) + allCategories.add(category) + i = s.indexOf("]]", i) + i = s.indexOf("[[Category:", i) + } + media!!.categories = allCategories + if (allCategories.isEmpty()) { + // Stick in a filler element. + allCategories.add(getString(R.string.detail_panel_cats_none)) + } + if (sessionManager.isUserLoggedIn) { + binding.categoryEditButton.visibility = View.VISIBLE + } + rebuildCatList(allCategories) + } + + /** + * Updates the categories + */ + fun updateCategories() { + val allCategories: MutableList = ArrayList( + media?.addedCategories!! + ) + media!!.categories = allCategories + if (allCategories.isEmpty()) { + // Stick in a filler element. + allCategories.add(getString(R.string.detail_panel_cats_none)) + } + + rebuildCatList(allCategories) + } + + /** + * Populates media details fragment with depiction list + * @param idAndCaptions + */ + private fun buildDepictionList(idAndCaptions: List) { + binding.mediaDetailDepictionContainer.removeAllViews() + val locale: String = Locale.getDefault().language + for (idAndCaption: IdAndCaptions in idAndCaptions) { + binding.mediaDetailDepictionContainer.addView( + buildDepictLabel( + getDepictionCaption(idAndCaption, locale), + idAndCaption.id, + binding.mediaDetailDepictionContainer + ) + ) + } + } + + private fun getDepictionCaption(idAndCaption: IdAndCaptions, locale: String): String? { + // Check if the Depiction Caption is available in user's locale + // if not then check for english, else show any available. + if (idAndCaption.captions[locale] != null) { + return idAndCaption.captions[locale] + } + if (idAndCaption.captions["en"] != null) { + return idAndCaption.captions["en"] + } + return idAndCaption.captions.values.iterator().next() + } + + private fun onMediaDetailLicenceClicked() { + val url: String? = media!!.licenseUrl + if (!StringUtils.isBlank(url) && activity != null) { + Utils.handleWebUrl(activity, Uri.parse(url)) + } else { + viewUtil.showShortToast(requireActivity(), getString(R.string.null_url)) + } + } + + private fun onMediaDetailCoordinatesClicked() { + if (media!!.coordinates != null && activity != null) { + Utils.handleGeoCoordinates(activity, media!!.coordinates) + } + } + + private fun onCopyWikicodeClicked() { + val data: String = + "[[" + media!!.filename + "|thumb|" + media!!.fallbackDescription + "]]" + Utils.copy("wikiCode", data, context) + Timber.d("Generated wikidata copy code: %s", data) + + Toast.makeText(context, getString(R.string.wikicode_copied), Toast.LENGTH_SHORT) + .show() + } + + /** + * Sends thanks to author if the author is not the user + */ + private fun sendThanksToAuthor() { + val fileName: String? = media!!.filename + if (TextUtils.isEmpty(fileName)) { + Toast.makeText( + context, getString(R.string.error_sending_thanks), + Toast.LENGTH_SHORT + ).show() + return + } + compositeDisposable.add( + reviewHelper.getFirstRevisionOfFile(fileName) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { revision: Revision? -> + sendThanks( + requireContext(), revision + ) + } + ) + } + + /** + * Api call for sending thanks to the author when the author is not the user + * and display toast depending on the result + * @param context context + * @param firstRevision the revision id of the image + */ + @SuppressLint("CheckResult", "StringFormatInvalid") + fun sendThanks(context: Context, firstRevision: Revision?) { + showShortToast( + context, + context.getString(R.string.send_thank_toast, media!!.displayTitle) + ) + + if (firstRevision == null) { + return + } + + Observable.defer { + thanksClient.thank( + firstRevision.revisionId() + ) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { result: Boolean -> + displayThanksToast( + requireContext(), result + ) + }, + { throwable: Throwable? -> + if (throwable is InvalidLoginTokenException) { + val username: String? = sessionManager.userName + val logoutListener: CommonsApplication.BaseLogoutListener = + CommonsApplication.BaseLogoutListener( + requireActivity(), + requireActivity().getString(R.string.invalid_login_message), + username + ) + + instance.clearApplicationData( + requireActivity(), logoutListener + ) + } else { + Timber.e(throwable) + } + }) + } + + /** + * Method to display toast when api call to thank the author is completed + * @param context context + * @param result true if success, false otherwise + */ + @SuppressLint("StringFormatInvalid") + private fun displayThanksToast(context: Context, result: Boolean) { + val message: String = if (result) { + context.getString( + R.string.send_thank_success_message, + media!!.displayTitle + ) + } else { + context.getString( + R.string.send_thank_failure_message, + media!!.displayTitle + ) + } + + showShortToast(context, message) + } + + fun onCategoryEditButtonClicked() { + binding.progressBarEditCategory.visibility = View.VISIBLE + binding.categoryEditButton.visibility = View.GONE + wikiText + } + + private val wikiText: Unit + /** + * Gets WikiText from the server and send it to catgory editor + */ + get() { + compositeDisposable.add( + mediaDataExtractor.getCurrentWikiText( + Objects.requireNonNull(media?.filename!!) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { s: String? -> gotoCategoryEditor(s!!) }, + { t: Throwable? -> Timber.e(t) }) + ) + } + + /** + * Opens the category editor + * + * @param s WikiText + */ + private fun gotoCategoryEditor(s: String) { + binding.categoryEditButton.visibility = View.VISIBLE + binding.progressBarEditCategory.visibility = View.GONE + val categoriesFragment: Fragment = UploadCategoriesFragment() + val bundle = Bundle() + bundle.putParcelable("Existing_Categories", media) + bundle.putString("WikiText", s) + categoriesFragment.arguments = bundle + val transaction: FragmentTransaction = childFragmentManager.beginTransaction() + transaction.replace(R.id.mediaDetailFrameLayout, categoriesFragment) + transaction.addToBackStack(null) + transaction.commit() + } + + fun onUpdateCoordinatesClicked() { + goToLocationPickerActivity() + } + + /** + * Start location picker activity with a request code and get the coordinates from the activity. + */ + private fun goToLocationPickerActivity() { + /* + If location is not provided in media this coordinates will act as a placeholder in + location picker activity + */ + var defaultLatitude = 37.773972 + var defaultLongitude: Double = -122.431297 + if (media!!.coordinates != null) { + defaultLatitude = media!!.coordinates!!.latitude + defaultLongitude = media!!.coordinates!!.longitude + } else { + if (locationManager.getLastLocation() != null) { + defaultLatitude = locationManager.getLastLocation()!!.latitude + defaultLongitude = locationManager.getLastLocation()!!.longitude + } else { + val lastLocation: Array? = applicationKvStore.getString( + UploadMediaDetailFragment.LAST_LOCATION, + ("$defaultLatitude,$defaultLongitude") + )?.split(",".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() + + if (lastLocation != null) { + defaultLatitude = lastLocation[0].toDouble() + defaultLongitude = lastLocation[1].toDouble() + } + } + } + + + startActivity( + LocationPicker.IntentBuilder() + .defaultLocation(CameraPosition(defaultLatitude, defaultLongitude, 16.0)) + .activityKey("MediaActivity") + .media(media!!) + .build(requireActivity()) + ) + } + + fun onDescriptionEditClicked() { + binding.progressBarEdit.visibility = View.VISIBLE + binding.descriptionEdit.visibility = View.GONE + descriptionList + } + + private val descriptionList: Unit + /** + * Gets descriptions from wikitext + */ + get() { + compositeDisposable.add( + mediaDataExtractor.getCurrentWikiText( + Objects.requireNonNull(media?.filename!!) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { s: String? -> extractCaptionDescription(s!!) }, + { t: Throwable? -> Timber.e(t) }) + ) + } + + /** + * Gets captions and descriptions and merge them according to language code and arranges it in a + * single list. + * Send the list to DescriptionEditActivity + * @param s wikitext + */ + private fun extractCaptionDescription(s: String) { + val descriptions: LinkedHashMap = getDescriptions(s) + val captions: LinkedHashMap = captionsList + + val descriptionAndCaptions: ArrayList = ArrayList() + + if (captions.size >= descriptions.size) { + for (mapElement: Map.Entry<*, *> in captions.entries) { + val language: String = mapElement.key as String + if (descriptions.containsKey(language)) { + descriptionAndCaptions.add( + UploadMediaDetail( + language, + Objects.requireNonNull(descriptions[language]!!), + (mapElement.value as String?)!! + ) + ) + } else { + descriptionAndCaptions.add( + UploadMediaDetail( + language, "", + (mapElement.value as String?)!! + ) + ) + } + } + for (mapElement: Map.Entry<*, *> in descriptions.entries) { + val language: String = mapElement.key as String + if (!captions.containsKey(language)) { + descriptionAndCaptions.add( + UploadMediaDetail( + language, + Objects.requireNonNull(descriptions[language]!!), + "" + ) + ) + } + } + } else { + for (mapElement: Map.Entry<*, *> in descriptions.entries) { + val language: String = mapElement.key as String + if (captions.containsKey(language)) { + descriptionAndCaptions.add( + UploadMediaDetail( + language, (mapElement.value as String?)!!, + Objects.requireNonNull(captions[language]!!) + ) + ) + } else { + descriptionAndCaptions.add( + UploadMediaDetail( + language, (mapElement.value as String?)!!, + "" + ) + ) + } + } + for (mapElement: Map.Entry<*, *> in captions.entries) { + val language: String = mapElement.key as String + if (!descriptions.containsKey(language)) { + descriptionAndCaptions.add( + UploadMediaDetail( + language, + "", + Objects.requireNonNull(descriptions[language]!!) + ) + ) + } + } + } + val intent = Intent(requireContext(), DescriptionEditActivity::class.java) + val bundle = Bundle() + bundle.putParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION, descriptionAndCaptions) + bundle.putString(WIKITEXT, s) + bundle.putString( + Prefs.DESCRIPTION_LANGUAGE, + applicationKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, "") + ) + bundle.putParcelable("media", media) + intent.putExtras(bundle) + startActivity(intent) + } + + /** + * Filters descriptions from current wikiText and arranges it in LinkedHashmap according to the + * language code + * @param s wikitext + * @return LinkedHashMap,Description> + */ + private fun getDescriptions(s: String): LinkedHashMap { + val pattern: Pattern = Pattern.compile("[dD]escription *=(.*?)\n *\\|", Pattern.DOTALL) + val matcher: Matcher = pattern.matcher(s) + var description: String? = null + if (matcher.find()) { + description = matcher.group() + } + if (description == null) { + return LinkedHashMap() + } + + val descriptionList: LinkedHashMap = LinkedHashMap() + + var count = 0 // number of "{{" + var startCode = 0 + var endCode = 0 + var startDescription = 0 + var endDescription: Int + val allLanguageCodes: HashSet = HashSet( + mutableListOf( + "en", + "es", + "de", + "ja", + "fr", + "ru", + "pt", + "it", + "zh-hans", + "zh-hant", + "ar", + "ko", + "id", + "pl", + "nl", + "fa", + "hi", + "th", + "vi", + "sv", + "uk", + "cs", + "simple", + "hu", + "ro", + "fi", + "el", + "he", + "nb", + "da", + "sr", + "hr", + "ms", + "bg", + "ca", + "tr", + "sk", + "sh", + "bn", + "tl", + "mr", + "ta", + "kk", + "lt", + "az", + "bs", + "sl", + "sq", + "arz", + "zh-yue", + "ka", + "te", + "et", + "lv", + "ml", + "hy", + "uz", + "kn", + "af", + "nn", + "mk", + "gl", + "sw", + "eu", + "ur", + "ky", + "gu", + "bh", + "sco", + "ast", + "is", + "mn", + "be", + "an", + "km", + "si", + "ceb", + "jv", + "eo", + "als", + "ig", + "su", + "be-x-old", + "la", + "my", + "cy", + "ne", + "bar", + "azb", + "mzn", + "as", + "am", + "so", + "pa", + "map-bms", + "scn", + "tg", + "ckb", + "ga", + "lb", + "war", + "zh-min-nan", + "nds", + "fy", + "vec", + "pnb", + "zh-classical", + "lmo", + "tt", + "io", + "ia", + "br", + "hif", + "mg", + "wuu", + "gan", + "ang", + "or", + "oc", + "yi", + "ps", + "tk", + "ba", + "sah", + "fo", + "nap", + "vls", + "sa", + "ce", + "qu", + "ku", + "min", + "bcl", + "ilo", + "ht", + "li", + "wa", + "vo", + "nds-nl", + "pam", + "new", + "mai", + "sn", + "pms", + "eml", + "yo", + "ha", + "gn", + "frr", + "gd", + "hsb", + "cv", + "lo", + "os", + "se", + "cdo", + "sd", + "ksh", + "bat-smg", + "bo", + "nah", + "xmf", + "ace", + "roa-tara", + "hak", + "bjn", + "gv", + "mt", + "pfl", + "szl", + "bpy", + "rue", + "co", + "diq", + "sc", + "rw", + "vep", + "lij", + "kw", + "fur", + "pcd", + "lad", + "tpi", + "ext", + "csb", + "rm", + "kab", + "gom", + "udm", + "mhr", + "glk", + "za", + "pdc", + "om", + "iu", + "nv", + "mi", + "nrm", + "tcy", + "frp", + "myv", + "kbp", + "dsb", + "zu", + "ln", + "mwl", + "fiu-vro", + "tum", + "tet", + "tn", + "pnt", + "stq", + "nov", + "ny", + "xh", + "crh", + "lfn", + "st", + "pap", + "ay", + "zea", + "bxr", + "kl", + "sm", + "ak", + "ve", + "pag", + "nso", + "kaa", + "lez", + "gag", + "kv", + "bm", + "to", + "lbe", + "krc", + "jam", + "ss", + "roa-rup", + "dv", + "ie", + "av", + "cbk-zam", + "chy", + "inh", + "ug", + "ch", + "arc", + "pih", + "mrj", + "kg", + "rmy", + "dty", + "na", + "ts", + "xal", + "wo", + "fj", + "tyv", + "olo", + "ltg", + "ff", + "jbo", + "haw", + "ki", + "chr", + "sg", + "atj", + "sat", + "ady", + "ty", + "lrc", + "ti", + "din", + "gor", + "lg", + "rn", + "bi", + "cu", + "kbd", + "pi", + "cr", + "koi", + "ik", + "mdf", + "bug", + "ee", + "shn", + "tw", + "dz", + "srn", + "ks", + "test", + "en-x-piglatin", + "ab" + ) + ) + var i = 0 + while (i < description.length - 1) { + if (description.startsWith("{{", i)) { + if (count == 0) { + startCode = i + endCode = description.indexOf("|", i) + startDescription = endCode + 1 + if (description.startsWith("1=", endCode + 1)) { + startDescription += 2 + i += 2 + } + } + i++ + count++ + } else if (description.startsWith("}}", i)) { + count-- + if (count == 0) { + endDescription = i + val languageCode: String = description.substring(startCode + 2, endCode) + val languageDescription: String = + description.substring(startDescription, endDescription) + if (allLanguageCodes.contains(languageCode)) { + descriptionList[languageCode] = languageDescription + } + } + i++ + } + i++ + } + return descriptionList + } + + private val captionsList: LinkedHashMap + /** + * Gets list of caption and arranges it in a LinkedHashmap according to the language code + * @return LinkedHashMap,Caption> + */ + get() { + val captionList: LinkedHashMap = + LinkedHashMap() + val captions: Map = media!!.captions + for (map: Map.Entry in captions.entries) { + val language: String = map.key + val languageCaption: String = map.value + captionList[language] = languageCaption + } + return captionList + } + + /** + * Adds caption to the map and updates captions + * @param mediaDetail UploadMediaDetail + * @param updatedCaptions updated captionds + */ + private fun updateCaptions( + mediaDetail: UploadMediaDetail, + updatedCaptions: MutableMap + ) { + updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText + media!!.captions = updatedCaptions + } + + @SuppressLint("StringFormatInvalid") + fun onDeleteButtonClicked() { + if (getUserName(requireContext()) != null && getUserName(requireContext()) == media!!.author) { + val languageAdapter: ArrayAdapter = ArrayAdapter( + requireActivity(), + R.layout.simple_spinner_dropdown_list, reasonList + ) + val spinner = Spinner(activity) + spinner.layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + spinner.adapter = languageAdapter + spinner.gravity = 17 + + val dialog: AlertDialog? = showAlertDialog( + requireActivity(), + getString(R.string.nominate_delete), + null, + getString(R.string.about_translate_proceed), + getString(R.string.about_translate_cancel), + { onDeleteClicked(spinner) }, + {}, + spinner, + true + ) + if (isDeleted) { + dialog!!.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + } + } else if (getUserName(requireContext()) != null) { + val input = EditText(activity) + input.requestFocus() + val d: AlertDialog? = showAlertDialog( + requireActivity(), + null, + getString(R.string.dialog_box_text_nomination, media!!.displayTitle), + getString(R.string.ok), + getString(R.string.cancel), + { + val reason: String = input.text.toString() + onDeleteClickeddialogtext(reason) + }, + {}, + input, + true + ) + input.addTextChangedListener(object : TextWatcher { + fun handleText() { + val okButton: Button = d!!.getButton(AlertDialog.BUTTON_POSITIVE) + if (input.text.isEmpty() || isDeleted) { + okButton.isEnabled = false + } else { + okButton.isEnabled = true + } + } + + override fun afterTextChanged(arg0: Editable) { + handleText() + } + + override fun beforeTextChanged( + s: CharSequence, + start: Int, + count: Int, + after: Int + ) { + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + } + }) + d!!.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + } + } + + @SuppressLint("CheckResult") + private fun onDeleteClicked(spinner: Spinner) { + applicationKvStore.putBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, + media!!.imageUrl + ), true + ) + enableProgressBar() + val reason: String = reasonListEnglishMappings[spinner.selectedItemPosition] + val finalReason: String = reason + val resultSingle: Single = reasonBuilder.getReason(media, reason) + .flatMap { + deleteHelper.makeDeletion( + context, media, finalReason + ) + } + resultSingle + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { _ -> + if (applicationKvStore.getBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl + ), false + ) + ) { + applicationKvStore.remove( + String.format( + NOMINATING_FOR_DELETION_MEDIA, + media!!.imageUrl + ) + ) + callback!!.nominatingForDeletion(index) + } + } + } + + @SuppressLint("CheckResult") + private fun onDeleteClickeddialogtext(reason: String) { + applicationKvStore.putBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, + media!!.imageUrl + ), true + ) + enableProgressBar() + val resultSingletext: Single = reasonBuilder.getReason(media, reason) + .flatMap { _ -> + deleteHelper.makeDeletion( + context, media, reason + ) + } + resultSingletext + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { _ -> + if (applicationKvStore.getBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl + ), false + ) + ) { + applicationKvStore.remove( + String.format( + NOMINATING_FOR_DELETION_MEDIA, + media!!.imageUrl + ) + ) + callback!!.nominatingForDeletion(index) + } + } + } + + private fun onSeeMoreClicked() { + if (binding.nominatedDeletionBanner.visibility == View.VISIBLE && activity != null) { + Utils.handleWebUrl(activity, Uri.parse(media!!.pageTitle.mobileUri)) + } + } + + private fun onAuthorViewClicked() { + if (media == null || media!!.user == null) { + return + } + if (sessionManager.userName == null) { + val userProfileLink: String = BuildConfig.COMMONS_URL + "/wiki/User:" + media!!.user + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(userProfileLink)) + startActivity(browserIntent) + return + } + ProfileActivity.startYourself( + activity, + media!!.user, + sessionManager.userName != media!!.user + ) + } + + /** + * Enable Progress Bar and Update delete button text. + */ + private fun enableProgressBar() { + binding.progressBarDeletion.visibility = View.VISIBLE + binding.nominateDeletion.text = requireContext().getString(R.string.nominate_deletion) + isDeleted = true + } + + private fun rebuildCatList(categories: List) { + binding.mediaDetailCategoryContainer.removeAllViews() + for (category: String in categories) { + binding.mediaDetailCategoryContainer.addView( + buildCatLabel( + sanitise(category), + binding.mediaDetailCategoryContainer + ) + ) + } + } + + //As per issue #1826(see https://github.com/commons-app/apps-android-commons/issues/1826), + // some categories come suffixed with strings prefixed with |. As per the discussion + //that was meant for alphabetical sorting of the categories and can be safely removed. + private fun sanitise(category: String): String { + val indexOfPipe: Int = category.indexOf('|') + if (indexOfPipe != -1) { + //Removed everything after '|' + return category.substring(0, indexOfPipe) + } + return category + } + + /** + * Add view to depictions obtained also tapping on depictions should open the url + */ + private fun buildDepictLabel( + depictionName: String?, + entityId: String, + depictionContainer: LinearLayout + ): View { + val item: View = LayoutInflater.from(context) + .inflate(R.layout.detail_category_item, depictionContainer, false) + val textView: TextView = item.findViewById(R.id.mediaDetailCategoryItemText) + textView.text = depictionName + item.setOnClickListener { + val intent = Intent( + context, + WikidataItemDetailsActivity::class.java + ) + intent.putExtra("wikidataItemName", depictionName) + intent.putExtra("entityId", entityId) + intent.putExtra("fragment", "MediaDetailFragment") + requireContext().startActivity(intent) + } + return item + } + + private fun buildCatLabel(catName: String, categoryContainer: ViewGroup): View { + val item: View = LayoutInflater.from(context) + .inflate(R.layout.detail_category_item, categoryContainer, false) + val textView: TextView = item.findViewById(R.id.mediaDetailCategoryItemText) + + textView.text = catName + if (getString(R.string.detail_panel_cats_none) != catName) { + textView.setOnClickListener { + // Open Category Details page + val intent = Intent(context, CategoryDetailsActivity::class.java) + intent.putExtra("categoryName", catName) + requireContext().startActivity(intent) + } + } + return item + } + + /** + * Returns captions for media details + * + * @param media object of class media + * @return caption as string + */ + private fun prettyCaption(media: Media): String { + for (caption: String in media.captions.values) { + return if (caption == "") { + getString(R.string.detail_caption_empty) + } else { + caption + } + } + return getString(R.string.detail_caption_empty) + } + + private fun prettyDescription(media: Media): String { + var description: String? = chooseDescription(media) + if (description!!.isNotEmpty()) { + // Remove img tag that sometimes appears as a blue square in the app, + // see https://github.com/commons-app/apps-android-commons/issues/4345 + description = description.replace("[<](/)?img[^>]*[>]".toRegex(), "") + } + return description.ifEmpty { getString(R.string.detail_description_empty) } + } + + private fun chooseDescription(media: Media): String? { + val descriptions: Map = media.descriptions + val multilingualDesc: String? = descriptions[Locale.getDefault().language] + if (multilingualDesc != null) { + return multilingualDesc + } + for (description: String in descriptions.values) { + return description + } + return media.fallbackDescription + } + + private fun prettyDiscussion(discussion: String): String { + return discussion.ifEmpty { getString(R.string.detail_discussion_empty) } + } + + private fun prettyLicense(media: Media): String { + val licenseKey: String? = media.license + Timber.d("Media license is: %s", licenseKey) + if (licenseKey == null || licenseKey == "") { + return getString(R.string.detail_license_empty) + } + return licenseKey + } + + private fun prettyUploadedDate(media: Media): String { + val date: Date? = media.dateUploaded + if (date?.toString() == null || date.toString().isEmpty()) { + return "Uploaded date not available" + } + return getDateStringWithSkeletonPattern(date, "dd MMM yyyy") + } + + /** + * Returns the coordinates nicely formatted. + * + * @return Coordinates as text. + */ + private fun prettyCoordinates(media: Media): String { + if (media.coordinates == null) { + return getString(R.string.media_detail_coordinates_empty) + } + return media.coordinates!!.getPrettyCoordinateString() + } + + override fun updateCategoryDisplay(categories: List?): Boolean { + if (categories == null) { + return false + } else { + rebuildCatList(categories) + return true + } + } + + fun showCaptionAndDescription() { + if (binding.dummyCaptionDescriptionContainer.visibility == View.GONE) { + binding.dummyCaptionDescriptionContainer.visibility = View.VISIBLE + setUpCaptionAndDescriptionLayout() + } else { + binding.dummyCaptionDescriptionContainer.visibility = View.GONE + } + } + + /** + * setUp Caption And Description Layout + */ + private fun setUpCaptionAndDescriptionLayout() { + val captions: List = captions + + if (descriptionHtmlCode == null) { + binding.showCaptionsBinding.pbCircular.visibility = View.VISIBLE + } + + description + val adapter = CaptionListViewAdapter(captions) + binding.showCaptionsBinding.captionListview.adapter = adapter + } + + private val captions: List + /** + * Generate the caption with language + */ + get() { + val captionList: MutableList = + ArrayList() + val captions: Map = media!!.captions + val appLanguageLookUpTable = + AppLanguageLookUpTable(requireContext()) + for (map: Map.Entry in captions.entries) { + val language: String? = appLanguageLookUpTable.getLocalizedName(map.key) + val languageCaption: String = map.value + captionList.add(Caption(language, languageCaption)) + } + + if (captionList.size == 0) { + captionList.add(Caption("", "No Caption")) + } + return captionList + } + + private val description: Unit + get() { + compositeDisposable.add( + mediaDataExtractor.getHtmlOfPage( + Objects.requireNonNull(media?.filename!!) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { s: String -> extractDescription(s) }, + { t: Throwable? -> Timber.e(t) }) + ) + } + + /** + * extract the description from html of imagepage + */ + private fun extractDescription(s: String) { + val descriptionClassName = "" + val start: Int = s.indexOf(descriptionClassName) + descriptionClassName.length + val end: Int = s.indexOf("", start) + descriptionHtmlCode = "" + for (i in start until end) { + descriptionHtmlCode += s.toCharArray()[i] + } + + binding.showCaptionsBinding.descriptionWebview + .loadDataWithBaseURL(null, descriptionHtmlCode!!, "text/html", "utf-8", null) + binding.showCaptionsBinding.pbCircular.visibility = View.GONE + } + + /** + * Handle back event when fragment when showCaptionAndDescriptionContainer is visible + */ + private fun handleBackEvent(view: View) { + view.isFocusableInTouchMode = true + view.requestFocus() + view.setOnKeyListener(object : View.OnKeyListener { + override fun onKey(view: View, keycode: Int, keyEvent: KeyEvent): Boolean { + if (keycode == KeyEvent.KEYCODE_BACK) { + if (binding.dummyCaptionDescriptionContainer.visibility == View.VISIBLE) { + binding.dummyCaptionDescriptionContainer.visibility = + View.GONE + return true + } + } + return false + } + }) + } + + + interface Callback { + fun nominatingForDeletion(index: Int) + } + + /** + * Called when the image background color is changed. + * You should pass a useable color, not a resource id. + * @param color + */ + fun onImageBackgroundChanged(color: Int) { + val currentColor: Int = imageBackgroundColor + if (currentColor == color) { + return + } + + binding.mediaDetailImageView.setBackgroundColor(color) + imageBackgroundColorPref.edit().putInt(IMAGE_BACKGROUND_COLOR, color).apply() + } + + private val imageBackgroundColorPref: SharedPreferences + get() = requireContext().getSharedPreferences( + IMAGE_BACKGROUND_COLOR + media!!.pageId, + Context.MODE_PRIVATE + ) + + private val imageBackgroundColor: Int + get() { + val imageBackgroundColorPref: SharedPreferences = + imageBackgroundColorPref + return imageBackgroundColorPref.getInt( + IMAGE_BACKGROUND_COLOR, + DEFAULT_IMAGE_BACKGROUND_COLOR + ) + } + + companion object { + private const val IMAGE_BACKGROUND_COLOR: String = "image_background_color" + const val DEFAULT_IMAGE_BACKGROUND_COLOR: Int = 0 + + @JvmStatic + fun forMedia( + index: Int, + editable: Boolean, + isCategoryImage: Boolean, + isWikipediaButtonDisplayed: Boolean + ): MediaDetailFragment { + val mf = MediaDetailFragment() + val state = Bundle() + state.putBoolean("editable", editable) + state.putBoolean("isCategoryImage", isCategoryImage) + state.putInt("index", index) + state.putInt("listIndex", 0) + state.putInt("listTop", 0) + state.putBoolean("isWikipediaButtonDisplayed", isWikipediaButtonDisplayed) + mf.arguments = state + + return mf + } + + const val NOMINATING_FOR_DELETION_MEDIA: String = "Nominating for deletion %s" + } +} + +@Composable +fun FileUsagesContainer( + modifier: Modifier = Modifier, + commonsContainerState: MediaDetailViewModel.FileUsagesContainerState, + globalContainerState: MediaDetailViewModel.FileUsagesContainerState, +) { + var isCommonsListExpanded by rememberSaveable { mutableStateOf(true) } + var isOtherWikisListExpanded by rememberSaveable { mutableStateOf(true) } + + val uriHandle = LocalUriHandler.current + + Column(modifier = modifier) { + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + + Text( + text = stringResource(R.string.usages_on_commons_heading), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleSmall + ) + + IconButton(onClick = { + isCommonsListExpanded = !isCommonsListExpanded + }) { + Icon( + imageVector = if (isCommonsListExpanded) Icons.Default.KeyboardArrowUp + else Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + } + } + + if (isCommonsListExpanded) { + when (commonsContainerState) { + MediaDetailViewModel.FileUsagesContainerState.Loading -> { + LinearProgressIndicator() + } + + is MediaDetailViewModel.FileUsagesContainerState.Success -> { + + val data = commonsContainerState.data + + if (data.isNullOrEmpty()) { + ListItem(headlineContent = { + Text( + text = stringResource(R.string.no_usages_found), + style = MaterialTheme.typography.titleSmall + ) + }) + } else { + data.forEach { usage -> + ListItem( + leadingContent = { + Text( + text = stringResource(R.string.bullet_point), + fontWeight = FontWeight.Bold + ) + }, + headlineContent = { + Text( + modifier = Modifier.clickable { + uriHandle.openUri(usage.link!!) + }, + text = usage.title, + style = MaterialTheme.typography.titleSmall.copy( + color = Color(0xFF5A6AEC), + textDecoration = TextDecoration.Underline + ) + ) + }) + } + } + } + + is MediaDetailViewModel.FileUsagesContainerState.Error -> { + ListItem(headlineContent = { + Text( + text = commonsContainerState.errorMessage, + color = Color.Red, + style = MaterialTheme.typography.titleSmall + ) + }) + } + + MediaDetailViewModel.FileUsagesContainerState.Initial -> {} + } + } + + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.usages_on_other_wikis_heading), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleSmall + ) + + IconButton(onClick = { + isOtherWikisListExpanded = !isOtherWikisListExpanded + }) { + Icon( + imageVector = if (isOtherWikisListExpanded) Icons.Default.KeyboardArrowUp + else Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + } + } + + if (isOtherWikisListExpanded) { + when (globalContainerState) { + MediaDetailViewModel.FileUsagesContainerState.Loading -> { + LinearProgressIndicator() + } + + is MediaDetailViewModel.FileUsagesContainerState.Success -> { + + val data = globalContainerState.data + + if (data.isNullOrEmpty()) { + ListItem(headlineContent = { + Text( + text = stringResource(R.string.no_usages_found), + style = MaterialTheme.typography.titleSmall + ) + }) + } else { + data.forEach { usage -> + ListItem( + leadingContent = { + Text( + text = stringResource(R.string.bullet_point), + fontWeight = FontWeight.Bold + ) + }, + headlineContent = { + Text( + text = usage.title, + style = MaterialTheme.typography.titleSmall.copy( + textDecoration = TextDecoration.Underline + ) + ) + }) + } + } + } + + is MediaDetailViewModel.FileUsagesContainerState.Error -> { + ListItem(headlineContent = { + Text( + text = globalContainerState.errorMessage, + color = Color.Red, + style = MaterialTheme.typography.titleSmall + ) + }) + } + + MediaDetailViewModel.FileUsagesContainerState.Initial -> {} + } + } + + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailViewModel.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailViewModel.kt new file mode 100644 index 0000000000..f02df35c75 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailViewModel.kt @@ -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.Initial) + val commonsContainerState = _commonsContainerState.asStateFlow() + + private val _globalContainerState = + MutableStateFlow(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?) : FileUsagesContainerState() + data class Error(val errorMessage: String) : FileUsagesContainerState() + } + + class MediaDetailViewModelProviderFactory + @Inject constructor( + private val okHttpJsonApiClient: OkHttpJsonApiClient, + private val applicationContext: Context + ) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(MediaDetailViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return MediaDetailViewModel(applicationContext, okHttpJsonApiClient) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.java b/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.java deleted file mode 100644 index f587893c5f..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.java +++ /dev/null @@ -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> 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 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(); - } - -} - - - diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt b/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt new file mode 100644 index 0000000000..1f8c51187b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt @@ -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> = 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() + + val apiResponse = gson.fromJson(body.charStream(), MwQueryResponse::class.java) + val categories: MutableSet = 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(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() +} + + + diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java deleted file mode 100644 index 8ed37a2937..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java +++ /dev/null @@ -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 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 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 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 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 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 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 bindings = nearbyResponse.getResults().getBindings(); - final List 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 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 bindings = nearbyResponse.getResults().getBindings(); - final List 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 getPlaces( - final List 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 bindings = nearbyResponse.getResults().getBindings(); - final List 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 = "\n" + - "\n" + - "\n" + - " "; - List 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 \n" + - " " + formattedItemName + "\n" + - " " + itemUrl + "\n" + - " \n" + - " " + itemLongitude + "," - + itemLatitude - + "\n" + - " \n" + - " "; - kmlString = kmlString + kmlEntry; - } else { - Timber.e("No match found"); - } - } - } - } - kmlString = kmlString + "\n \n" + - "\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 = "\n" + - "" - + "\n"; - - List 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 \n" + - " " + itemName + "\n" + - " " + itemUrl + "\n" + - " "; - gpxString = gpxString + gpxEntry; - - } else { - Timber.e("No match found"); - } - } - } - - } - gpxString = gpxString + "\n"; - return gpxString; - } - - private List 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 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> 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> getParentDepictions(String qid, int startPosition, - int limit) throws IOException { - return depictedItemsFrom(sparqlQuery(qid, startPosition, limit, - "/queries/parentclasses_query.rq")); - } - - private Single> 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 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; - }); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt new file mode 100644 index 0000000000..71ea1d6927 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt @@ -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 { + 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( + 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( + 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( + json, + GlobalFileUsagesResponse::class.java + ) + } else null + } catch (e: Exception) { + Timber.e(e) + null + } + } + } + + fun setAvatar(username: String?, avatar: String?): Single { + val urlTemplate = wikiMediaToolforgeUrl + .toString() + LeaderboardConstants.UPDATE_AVATAR_END_POINT + return Single.fromCallable({ + 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( + json, + UpdateAvatarResponse::class.java + ) + } catch (e: Exception) { + return@fromCallable UpdateAvatarResponse() + } + } + null + }) + } + + fun getUploadCount(userName: String?): Single { + 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({ + 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 { + 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({ + 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 { + val suffix = if (isBetaFlavour) "/feedback.py?labs=commonswiki" else "/feedback.py" + val fetchAchievementUrlTemplate = wikiMediaToolforgeUrl.toString() + suffix + return Single.fromCallable({ + 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( + 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? { + 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 = 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? { + 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 = 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, language: String + ): List? { + 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 = 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 = """ + + + """ + 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 = (""" + + $formattedItemName + $itemUrl + + $itemLongitude,$itemLatitude + + """) + kmlString = kmlString + kmlEntry + } else { + Timber.e("No match found") + } + } + } + } + kmlString = """$kmlString + + +""" + return kmlString + } + + @Throws(Exception::class) + fun getPlacesAsGPX(leftLatLng: LatLng, rightLatLng: LatLng): String? { + var gpxString = (""" + +""") + + 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 = + (""" + + $itemName + $itemUrl + """) + gpxString = gpxString + gpxEntry + } else { + Timber.e("No match found") + } + } + } + } + gpxString = "$gpxString\n" + return gpxString + } + + @Throws(IOException::class) + fun getChildDepictions( + qid: String, startPosition: Int, + limit: Int + ): Single> = + depictedItemsFrom(sparqlQuery(qid, startPosition, limit, "/queries/subclasses_query.rq")) + + @Throws(IOException::class) + fun getParentDepictions( + qid: String, startPosition: Int, + limit: Int + ): Single> = depictedItemsFrom( + sparqlQuery( + qid, + startPosition, + limit, + "/queries/parentclasses_query.rq" + ) + ) + + fun getCampaigns(): Single { + return Single.fromCallable({ + 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( + json, + CampaignResponseDTO::class.java + ) + } + null + }) + } + + private fun depictedItemsFrom(request: Request): Single> { + return depictsClient.toDepictions(Single.fromCallable({ + okHttpClient.newCall(request).execute().body.use { body -> + return@fromCallable gson.fromJson( + 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? { + 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 + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt index 857e18ec31..bb83b56fe4 100644 --- a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt @@ -10,7 +10,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment @@ -25,6 +24,7 @@ import fr.free.nrw.commons.databinding.FragmentMoreBottomSheetBinding import fr.free.nrw.commons.di.ApplicationlessInjection import fr.free.nrw.commons.feedback.FeedbackContentCreator import fr.free.nrw.commons.feedback.FeedbackDialog +import fr.free.nrw.commons.feedback.OnFeedbackSubmitCallback import fr.free.nrw.commons.feedback.model.Feedback import fr.free.nrw.commons.kvstore.BasicKvStore import fr.free.nrw.commons.kvstore.JsonKvStore @@ -111,10 +111,18 @@ class MoreBottomSheetFragment : BottomSheetDialogFragment() { private fun setUserName() { val store = BasicKvStore(requireContext(), getUserName()) val level = store.getString("userAchievementsLevel", "0") - binding?.moreProfile?.text = if (level == "0") { - "${getUserName()} (${getString(R.string.see_your_achievements)})" + if (level == "0"){ + binding?.moreProfile?.text = getString( + R.string.profileLevel, + getUserName(), + getString(R.string.see_your_achievements) // Second argument + ) } else { - "${getUserName()} (${getString(R.string.level)} $level)" + binding?.moreProfile?.text = getString( + R.string.profileLevel, + getUserName(), + level + ) } } @@ -148,7 +156,11 @@ class MoreBottomSheetFragment : BottomSheetDialogFragment() { * Creates and shows a dialog asking feedback from users */ private fun showFeedbackDialog() { - FeedbackDialog(requireContext()) { uploadFeedback(it) }.show() + FeedbackDialog(requireContext(), object : OnFeedbackSubmitCallback{ + override fun onFeedbackSubmit(feedback: Feedback) { + uploadFeedback(feedback) + } + }).show() } /** @@ -160,8 +172,8 @@ class MoreBottomSheetFragment : BottomSheetDialogFragment() { val single = pageEditClient.createNewSection( "Commons:Mobile_app/Feedback", - feedbackContentCreator.sectionTitle, - feedbackContentCreator.sectionText, + feedbackContentCreator.getSectionTitle(), + feedbackContentCreator.getSectionText(), "New feedback on version ${feedback.version} of the app" ) .flatMapSingle { Single.just(it) } diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.kt b/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.kt index a0bf1176a6..b5c4f4a7aa 100644 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.kt @@ -64,8 +64,8 @@ class NotificationClient return Notification( notificationType = notificationType, notificationText = notificationText, - date = DateUtil.getMonthOnlyDateString(timestamp), - link = contents?.links?.primary?.url ?: "", + date = DateUtil.getMonthOnlyDateString(getTimestamp()), + link = contents?.links?.getPrimary()?.url ?: "", iconUrl = "", notificationId = id().toString(), ) diff --git a/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.java b/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.java index 60a0f47a1a..390768416f 100644 --- a/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.java @@ -16,7 +16,6 @@ import androidx.core.content.FileProvider; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; -import com.google.android.material.tabs.TabLayout; import fr.free.nrw.commons.R; import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.ViewPagerAdapter; diff --git a/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.java b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.java deleted file mode 100644 index ef6a323b2e..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.java +++ /dev/null @@ -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 - * 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 - */ - 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; - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.kt b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.kt new file mode 100644 index 0000000000..020a67f24f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.kt @@ -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 + * 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 + */ + + 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(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" + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt index cd2cbc8ad3..40eb24ed02 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt @@ -201,7 +201,7 @@ class ReviewActivity : BaseActivity() { val caption = getString( R.string.review_is_uploaded_by, fileName, - revision.user + revision.user() ) binding.tvImageCaption.text = caption binding.pbReviewImage.visibility = View.GONE diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewController.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewController.kt index 62652bd5bb..2450dd8502 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewController.kt +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewController.kt @@ -12,7 +12,6 @@ import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage import java.util.ArrayList -import java.util.concurrent.Callable import javax.inject.Inject import javax.inject.Named @@ -27,7 +26,6 @@ import fr.free.nrw.commons.delete.DeleteHelper import fr.free.nrw.commons.di.ApplicationlessInjection import fr.free.nrw.commons.utils.ViewUtil import io.reactivex.Observable -import io.reactivex.ObservableSource import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers import timber.log.Timber @@ -175,7 +173,7 @@ class ReviewController @Inject constructor( if (firstRevision == null) return Observable.defer { - thanksClient.thank(firstRevision!!.revisionId) + thanksClient.thank(firstRevision!!.revisionId()) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewHelper.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewHelper.kt index 17296a5c85..3ad15d8bfc 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewHelper.kt +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewHelper.kt @@ -34,7 +34,7 @@ class ReviewHelper reviewInterface .getRecentChanges() .map { it.query()?.pages() } - .map(MutableList::shuffled) + .map { it.shuffled() } .flatMapIterable { changes: List? -> changes } .filter { isChangeReviewable(it) } diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewImageFragment.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewImageFragment.kt index 691c61f569..6c922c99cb 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewImageFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewImageFragment.kt @@ -3,18 +3,15 @@ package fr.free.nrw.commons.review import android.graphics.Color import android.os.Bundle import android.text.Html -import android.text.TextUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import fr.free.nrw.commons.CommonsApplication -import fr.free.nrw.commons.Media import fr.free.nrw.commons.R import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException import fr.free.nrw.commons.databinding.FragmentReviewImageBinding import fr.free.nrw.commons.di.CommonsDaggerSupportFragment -import java.util.ArrayList import javax.inject.Inject @@ -126,7 +123,7 @@ class ReviewImageFragment : CommonsDaggerSupportFragment() { enableButtons() question = getString(R.string.review_thanks) - user = reviewActivity.reviewController.firstRevision?.user + user = reviewActivity.reviewController.firstRevision?.user() ?: savedInstanceState?.getString(SAVED_USER) //if the user is null because of whatsoever reason, review will not be sent anyways diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt index 86ee5c4feb..91146059d4 100644 --- a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt @@ -38,6 +38,7 @@ import fr.free.nrw.commons.campaigns.CampaignView import fr.free.nrw.commons.contributions.ContributionController import fr.free.nrw.commons.contributions.MainActivity import fr.free.nrw.commons.di.ApplicationlessInjection +import fr.free.nrw.commons.filepicker.FilePicker import fr.free.nrw.commons.kvstore.JsonKvStore import fr.free.nrw.commons.location.LocationServiceManager import fr.free.nrw.commons.logging.CommonsLogSender @@ -83,9 +84,17 @@ class SettingsFragment : PreferenceFragmentCompat() { private val cameraPickLauncherForResult: ActivityResultLauncher = registerForActivityResult(StartActivityForResult()) { result -> - contributionController.handleActivityResultWithCallback(requireActivity()) { callbacks -> - contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks) - } + contributionController.handleActivityResultWithCallback( + requireActivity(), + object: FilePicker.HandleActivityResult { + override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) { + contributionController.onPictureReturnedFromCamera( + result, + requireActivity(), + callbacks + ) + } + }) } /** diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt index 68c6f13fbe..d51ab1796e 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt @@ -194,7 +194,7 @@ class FileProcessor requireNotNull(imageCoordinates.decimalCoords) compositeDisposable.add( apiCall - .request(imageCoordinates.decimalCoords) + .request(imageCoordinates.decimalCoords!!) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) .subscribe( @@ -220,7 +220,7 @@ class FileProcessor .concatMap { Observable.fromCallable { okHttpJsonApiClient.getNearbyPlaces( - imageCoordinates.latLng, + imageCoordinates.latLng!!, Locale.getDefault().language, it, ) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.kt index 54f9b112fd..8464c670f7 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.kt @@ -270,7 +270,7 @@ class UploadClient if (uploadResult.upload == null) { val exception = gson.fromJson(uploadResponse, MwException::class.java) Timber.e(exception, "Error in uploading file from stash") - throw Exception(exception.getErrorCode()) + throw Exception(exception.errorCode) } uploadResult.upload } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadThumbnailsAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadThumbnailsAdapterFactory.java deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt index 00cd29a6d9..ae2c461f83 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt @@ -496,14 +496,14 @@ class UploadWorker( withContext(Dispatchers.Main) { wikidataEditService.handleImageClaimResult( - contribution.wikidataPlace, + contribution.wikidataPlace!!, revisionID, ) } } else { withContext(Dispatchers.Main) { wikidataEditService.handleImageClaimResult( - contribution.wikidataPlace, + contribution.wikidataPlace!!, null, ) } diff --git a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt index fc80252fc9..62bd3f1a90 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt +++ b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt @@ -63,7 +63,7 @@ class CustomSelectorUtils { fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) val sha1 = fileUtilsWrapper.getSHA1( - fileUtilsWrapper.getFileInputStream(uploadableFile.filePath), + fileUtilsWrapper.getFileInputStream(uploadableFile.getFilePath()), ) uploadableFile.file.delete() sha1 diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/CommonsServiceFactory.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/CommonsServiceFactory.kt index ca523a21fd..bc0ba24fa1 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/CommonsServiceFactory.kt +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/CommonsServiceFactory.kt @@ -10,11 +10,10 @@ class CommonsServiceFactory( ) { val builder: Retrofit.Builder by lazy { // All instances of retrofit share this configuration, but create it lazily - Retrofit - .Builder() + Retrofit.Builder() .client(okHttpClient) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) - .addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson())) + .addConverterFactory(GsonConverterFactory.create(GsonUtil.defaultGson)) } val retrofitCache: MutableMap = mutableMapOf() diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.java b/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.java deleted file mode 100644 index c9d37eda5c..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.java +++ /dev/null @@ -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() { } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt new file mode 100644 index 0000000000..1a0ae0aebc --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt @@ -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() } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.java deleted file mode 100644 index f89b5aee01..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.java +++ /dev/null @@ -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/"; -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.kt new file mode 100644 index 0000000000..6343342cb3 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.kt @@ -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/" +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.java deleted file mode 100644 index 30fb26ddc7..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.java +++ /dev/null @@ -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(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.kt new file mode 100644 index 0000000000..5e382b4ce8 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.kt @@ -0,0 +1,11 @@ +package fr.free.nrw.commons.wikidata + +abstract class WikidataEditListener { + var authenticationStateListener: WikidataP18EditListener? = null + + abstract fun onSuccessfulWikidataEdit() + + interface WikidataP18EditListener { + fun onWikidataEditSuccessful() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.java deleted file mode 100644 index a97d0ededf..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.java +++ /dev/null @@ -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(); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.kt new file mode 100644 index 0000000000..6827ab30cc --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.kt @@ -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() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java deleted file mode 100644 index 21567f5e44..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java +++ /dev/null @@ -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 addDepictsProperty( - final String fileEntityId, - final List 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 - */ - @SuppressLint("CheckResult") - public Observable updateDepictsProperty( - final String fileEntityId, - final List depictedItems - ) { - final String entityId = PAGE_ID_PREFIX + fileEntityId; - final List 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 getDepictionsClaimIds(final String entityId) { - return wikiBaseClient.getClaimIdsByProperty(entityId, WikidataProperties.DEPICTS.getPropertyName()) - .subscribeOn(Schedulers.io()) - .blockingFirst(); - } - - private EditClaim editClaim(final List entityIds) { - return EditClaim.from(entityIds, WikidataProperties.DEPICTS.getPropertyName()); - } - - private RemoveClaim removeClaim(final List 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 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 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 captions) { - final SnakPartial p18 = new SnakPartial("value", - WikidataProperties.IMAGE.getPropertyName(), - new ValueString(fileName.replace("File:", ""))); - - final List snaks = new ArrayList<>(); - for (final Map.Entry 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 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 captionEdits(Contribution contribution, Long fileEntityId) { - return Observable.fromIterable(contribution.getMedia().getCaptions().entrySet()) - .concatMap(entry -> addCaption(fileEntityId, entry.getKey(), entry.getValue())); - } - - private Observable depictionEdits(Contribution contribution, Long fileEntityId) { - final List depictIDs = new ArrayList<>(); - for (final WikidataItem wikidataItem : - contribution.getDepictedItems()) { - depictIDs.add(wikidataItem.getId()); - } - return addDepictsProperty(fileEntityId.toString(), depictIDs); - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.kt new file mode 100644 index 0000000000..396f928245 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.kt @@ -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 + ): Observable { + 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 + ): Observable { + 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() + } + } + } + + @SuppressLint("CheckResult") + private fun getDepictionsClaimIds(entityId: String): List { + 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 { + 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 + ): 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 + ): Long { + val p18 = SnakPartial( + "value", + IMAGE.propertyName, + ValueString(fileName.replace("File:", "")) + ) + + val snaks: MutableList = 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>( + 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 { + 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( + depictionEdits(contribution, fileEntityId), + captionEdits(contribution, fileEntityId) + ) + } else { + Timber.d("Error acquiring EntityId for image: %s", uploadResult) + return@switchMap Observable.empty() + } + } + } + + private fun captionEdits(contribution: Contribution, fileEntityId: Long): Observable { + return Observable.fromIterable(contribution.media.captions.entries) + .concatMap { addCaption(fileEntityId, it.key, it.value) } + } + + private fun depictionEdits( + contribution: Contribution, + fileEntityId: Long + ): Observable = addDepictsProperty(fileEntityId.toString(), buildList { + for ((_, _, _, _, _, _, id) in contribution.depictedItems) { + add(id) + } + }) + + companion object { + const val COMMONS_APP_TAG: String = "wikimedia-commons-app" + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.java deleted file mode 100644 index cc6dcc9f92..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.java +++ /dev/null @@ -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 { - - @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()); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.kt new file mode 100644 index 0000000000..09f1dc5e85 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.kt @@ -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() { + @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()) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.java deleted file mode 100644 index b6b67d4d22..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.java +++ /dev/null @@ -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 TypeAdapter create(Gson gson, TypeToken type) { - final TypeAdapter delegate = gson.getDelegateAdapter(this, type); - - return new TypeAdapter() { - 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; - } - }; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.kt new file mode 100644 index 0000000000..cf07eabf49 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.kt @@ -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 create(gson: Gson, type: TypeToken): TypeAdapter { + val delegate = gson.getDelegateAdapter(this, type) + + return object : TypeAdapter() { + @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 + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java deleted file mode 100644 index c01b9fe662..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java +++ /dev/null @@ -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 TypeAdapter create(@NonNull Gson gson, @NonNull TypeToken typeToken) { - Class rawType = typeToken.getRawType(); - Set requiredFields = collectRequiredFields(rawType); - - if (requiredFields.isEmpty()) { - return null; - } - - setFieldsAccessible(requiredFields, true); - return new Adapter<>(gson.getDelegateAdapter(this, typeToken), requiredFields); - } - - @NonNull private Set collectRequiredFields(@NonNull Class clazz) { - Field[] fields = clazz.getDeclaredFields(); - Set required = new ArraySet<>(); - for (Field field : fields) { - if (field.isAnnotationPresent(Required.class)) { - required.add(field); - } - } - return Collections.unmodifiableSet(required); - } - - private void setFieldsAccessible(Iterable fields, boolean accessible) { - for (Field field : fields) { - field.setAccessible(accessible); - } - } - - private static final class Adapter extends TypeAdapter { - @NonNull private final TypeAdapter delegate; - @NonNull private final Set requiredFields; - - private Adapter(@NonNull TypeAdapter delegate, @NonNull final Set 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 required) { - for (Field field : required) { - try { - if (field.get(deserialized) == null) { - return false; - } - } catch (IllegalArgumentException | IllegalAccessException e) { - throw new JsonParseException(e); - } - } - return true; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.kt new file mode 100644 index 0000000000..ec26e8345c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.kt @@ -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 create(gson: Gson, typeToken: TypeToken): TypeAdapter? { + 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 = buildSet { + for (field in clazz.declaredFields) { + if (field.isAnnotationPresent(Required::class.java)) add(field) + } + } + + private class Adapter( + private val delegate: TypeAdapter, + private val requiredFields: Set + ) : TypeAdapter() { + + @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): 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 + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.java deleted file mode 100644 index 828dfbd681..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.java +++ /dev/null @@ -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: - *
   {@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;
- *   }
- * }
- *

Without additional type information, the serialized JSON is ambiguous. Is - * the bottom shape in this drawing a rectangle or a diamond?

   {@code
- *   {
- *     "bottomShape": {
- *       "width": 10,
- *       "height": 5,
- *       "x": 0,
- *       "y": 0
- *     },
- *     "topShape": {
- *       "radius": 2,
- *       "x": 4,
- *       "y": 1
- *     }
- *   }}
- * This class addresses this problem by adding type information to the - * serialized JSON and honoring that type information when the JSON is - * deserialized:
   {@code
- *   {
- *     "bottomShape": {
- *       "type": "Diamond",
- *       "width": 10,
- *       "height": 5,
- *       "x": 0,
- *       "y": 0
- *     },
- *     "topShape": {
- *       "type": "Circle",
- *       "radius": 2,
- *       "x": 4,
- *       "y": 1
- *     }
- *   }}
- * Both the type field name ({@code "type"}) and the type labels ({@code - * "Rectangle"}) are configurable. - * - *

Registering Types

- * 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.
   {@code
- *   RuntimeTypeAdapterFactory shapeAdapterFactory
- *       = RuntimeTypeAdapterFactory.of(Shape.class, "type");
- * }
- * 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. - *
   {@code
- *   shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
- *   shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
- *   shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
- * }
- * Finally, register the type adapter factory in your application's GSON builder: - *
   {@code
- *   Gson gson = new GsonBuilder()
- *       .registerTypeAdapterFactory(shapeAdapterFactory)
- *       .create();
- * }
- * Like {@code GsonBuilder}, this API supports chaining:
   {@code
- *   RuntimeTypeAdapterFactory shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
- *       .registerSubtype(Rectangle.class)
- *       .registerSubtype(Circle.class)
- *       .registerSubtype(Diamond.class);
- * }
- * - *

Serialization and deserialization

- * In order to serialize and deserialize a polymorphic object, - * you must specify the base type explicitly. - *
   {@code
- *   Diamond diamond = new Diamond();
- *   String json = gson.toJson(diamond, Shape.class);
- * }
- * And then: - *
   {@code
- *   Shape shape = gson.fromJson(json, Shape.class);
- * }
- */ -public final class RuntimeTypeAdapterFactory implements TypeAdapterFactory { - private final Class baseType; - private final String typeFieldName; - private final Map> labelToSubtype = new LinkedHashMap>(); - private final Map, String> subtypeToLabel = new LinkedHashMap, 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 RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName, boolean maintainType) { - return new RuntimeTypeAdapterFactory(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 RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName) { - return new RuntimeTypeAdapterFactory(baseType, typeFieldName, false); - } - - /** - * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as - * the type field name. - */ - public static RuntimeTypeAdapterFactory of(Class baseType) { - return new RuntimeTypeAdapterFactory(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 registerSubtype(Class 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 registerSubtype(Class type) { - return registerSubtype(type, type.getSimpleName()); - } - - public TypeAdapter create(Gson gson, TypeToken type) { - if (type.getRawType() != baseType) { - return null; - } - - final Map> labelToDelegate - = new LinkedHashMap>(); - final Map, TypeAdapter> subtypeToDelegate - = new LinkedHashMap, TypeAdapter>(); - for (Map.Entry> 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() { - @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 delegate = (TypeAdapter) 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 delegate = (TypeAdapter) 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 e : jsonObject.entrySet()) { - clone.add(e.getKey(), e.getValue()); - } - Streams.write(clone, out); - } - }.nullSafe(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.kt new file mode 100644 index 0000000000..87acc939f3 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.kt @@ -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: + *
   `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;
+ * }
+`
* + * + * Without additional type information, the serialized JSON is ambiguous. Is + * the bottom shape in this drawing a rectangle or a diamond?
   `{
+ * "bottomShape": {
+ * "width": 10,
+ * "height": 5,
+ * "x": 0,
+ * "y": 0
+ * },
+ * "topShape": {
+ * "radius": 2,
+ * "x": 4,
+ * "y": 1
+ * }
+ * }`
+ * This class addresses this problem by adding type information to the + * serialized JSON and honoring that type information when the JSON is + * deserialized:
   `{
+ * "bottomShape": {
+ * "type": "Diamond",
+ * "width": 10,
+ * "height": 5,
+ * "x": 0,
+ * "y": 0
+ * },
+ * "topShape": {
+ * "type": "Circle",
+ * "radius": 2,
+ * "x": 4,
+ * "y": 1
+ * }
+ * }`
+ * Both the type field name (`"type"`) and the type labels (`"Rectangle"`) are configurable. + * + *

Registering Types

+ * 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.
   `RuntimeTypeAdapterFactory shapeAdapterFactory
+ * = RuntimeTypeAdapterFactory.of(Shape.class, "type");
+`
* + * 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. + *
   `shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
+ * shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
+ * shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
+`
* + * Finally, register the type adapter factory in your application's GSON builder: + *
   `Gson gson = new GsonBuilder()
+ * .registerTypeAdapterFactory(shapeAdapterFactory)
+ * .create();
+`
* + * Like `GsonBuilder`, this API supports chaining:
   `RuntimeTypeAdapterFactory shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
+ * .registerSubtype(Rectangle.class)
+ * .registerSubtype(Circle.class)
+ * .registerSubtype(Diamond.class);
+`
* + * + *

Serialization and deserialization

+ * In order to serialize and deserialize a polymorphic object, + * you must specify the base type explicitly. + *
   `Diamond diamond = new Diamond();
+ * String json = gson.toJson(diamond, Shape.class);
+`
* + * And then: + *
   `Shape shape = gson.fromJson(json, Shape.class);
+`
* + */ +class RuntimeTypeAdapterFactory( + baseType: Class<*>?, + typeFieldName: String?, + maintainType: Boolean +) : TypeAdapterFactory { + + private val baseType: Class<*> + private val typeFieldName: String + private val labelToSubtype = mutableMapOf>() + private val subtypeToLabel = mutableMapOf, 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?, label: String?): RuntimeTypeAdapterFactory { + 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): RuntimeTypeAdapterFactory { + return registerSubtype(type, type.simpleName) + } + + override fun create(gson: Gson, type: TypeToken): TypeAdapter? { + if (type.rawType != baseType) { + return null + } + + val labelToDelegate = mutableMapOf>() + val subtypeToDelegate = mutableMapOf, TypeAdapter<*>>() + for ((key, value) in labelToSubtype) { + val delegate = gson.getDelegateAdapter( + this, TypeToken.get( + value + ) + ) + labelToDelegate[key] = delegate + subtypeToDelegate[value] = delegate + } + + return object : TypeAdapter() { + @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? + 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? ?: 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 of( + baseType: Class, + typeFieldName: String, + maintainType: Boolean + ): RuntimeTypeAdapterFactory = + 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 of(baseType: Class, typeFieldName: String): RuntimeTypeAdapterFactory = + RuntimeTypeAdapterFactory(baseType, typeFieldName, false) + + /** + * Creates a new runtime type adapter for `baseType` using `"type"` as + * the type field name. + */ + fun of(baseType: Class): RuntimeTypeAdapterFactory = + RuntimeTypeAdapterFactory(baseType, "type", false) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.java deleted file mode 100644 index 069e02f321..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.java +++ /dev/null @@ -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 { - @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); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.kt new file mode 100644 index 0000000000..305cfa28a5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.kt @@ -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() { + @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()) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.java deleted file mode 100644 index c268d1e738..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.java +++ /dev/null @@ -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 { - 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); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.kt new file mode 100644 index 0000000000..da5cb08024 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.kt @@ -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() { + @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" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.java deleted file mode 100644 index 98e12745b8..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.java +++ /dev/null @@ -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 { -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.kt new file mode 100644 index 0000000000..189a3a42cd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.kt @@ -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 diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java deleted file mode 100644 index 8ea2fa1eda..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java +++ /dev/null @@ -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 search; - - /** - * Constructor to initialise value of the search object - */ - public DepictSearchResponse(List search) { - this.search = search; - } - - /** - * @return List for the DepictSearchResponse - */ - public List getSearch() { - return search; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.kt new file mode 100644 index 0000000000..5a0ed8c49f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.kt @@ -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 for the DepictSearchResponse + + */ + val search: List +) diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.java deleted file mode 100644 index 9dab836cf8..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.java +++ /dev/null @@ -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 entities; - private int success; - - @NotNull - public Map 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 labels; - @Nullable private Map descriptions; - @Nullable private Map sitelinks; - @Nullable @SerializedName(value = "statements", alternate = "claims") private Map> statements; - @Nullable private String missing; - - @NonNull public String id() { - return StringUtils.defaultString(id); - } - - @NonNull public Map labels() { - return labels != null ? labels : Collections.emptyMap(); - } - - @NonNull public Map descriptions() { - return descriptions != null ? descriptions : Collections.emptyMap(); - } - - @NonNull public Map sitelinks() { - return sitelinks != null ? sitelinks : Collections.emptyMap(); - } - - @Nullable - public Map> 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); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.kt new file mode 100644 index 0000000000..588dbd262b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.kt @@ -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? = null + val success: Int = 0 + + fun entities(): Map = 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? = null + private val descriptions: Map? = null + private val sitelinks: Map? = null + + @SerializedName(value = "statements", alternate = ["claims"]) + val statements: Map>? = null + private val missing: String? = null + + fun id(): String = + StringUtils.defaultString(id) + + fun labels(): Map = + labels ?: emptyMap() + + fun descriptions(): Map = + descriptions ?: emptyMap() + + fun sitelinks(): Map = + 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) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.java deleted file mode 100644 index 204ea0ab46..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.java +++ /dev/null @@ -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: - * - *
    - * Name: scheme / authority / language code - *
  • 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: - *
    - * Validity: authority / language code - *
  • Correct: "test.wikipedia.org" / "test"
  • - *
  • Correct: "wikipedia.org", ""
  • - *
  • Correct: "no.wikipedia.org", "nb"
  • - *
  • Incorrect: "wikipedia.org", "test"
  • - *
- */ -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 CREATOR = new Parcelable.Creator() { - @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 URL syntax - */ - @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: - * - *
    - *
  • 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
  • - *
- */ - @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."); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt new file mode 100644 index 0000000000..1cd0bb8585 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt @@ -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: + * + * + * Name: scheme / authority / language code + * * 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:** + * + * Validity: authority / language code + * * 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 = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): WikiSite { + return WikiSite(parcel) + } + + override fun newArray(size: Int): Array { + 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 + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/Edit.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/Edit.java deleted file mode 100644 index b79612ecc7..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/Edit.java +++ /dev/null @@ -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; - } - - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/Edit.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/Edit.kt new file mode 100644 index 0000000000..897b057bd4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/Edit.kt @@ -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 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/EditResult.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/EditResult.java deleted file mode 100644 index 1733f374ea..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/EditResult.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ExtMetadata.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ExtMetadata.java deleted file mode 100644 index 2bd63400f4..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ExtMetadata.java +++ /dev/null @@ -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; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ExtMetadata.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ExtMetadata.kt new file mode 100644 index 0000000000..63c0182520 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ExtMetadata.kt @@ -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 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ImageInfo.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ImageInfo.java deleted file mode 100644 index 2e1349ae97..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ImageInfo.java +++ /dev/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(); - } - } - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ImageInfo.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ImageInfo.kt new file mode 100644 index 0000000000..492e2e1f82 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ImageInfo.kt @@ -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 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/VideoInfo.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/VideoInfo.java deleted file mode 100644 index 32388d5cf4..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/VideoInfo.java +++ /dev/null @@ -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 codecs; - @SuppressWarnings("unused,NullableProblems") @Nullable private String name; - @SuppressWarnings("unused,NullableProblems") @Nullable @SerializedName("short_name") private String shortName; -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.java deleted file mode 100644 index 2d1dbdf28a..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.java +++ /dev/null @@ -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; - } - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.kt new file mode 100644 index 0000000000..dd73d97239 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.kt @@ -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 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/GeoMarshaller.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/GeoMarshaller.java deleted file mode 100644 index 4278335126..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/GeoMarshaller.java +++ /dev/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() { } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/GeoUnmarshaller.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/GeoUnmarshaller.java deleted file mode 100644 index aa59529648..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/GeoUnmarshaller.java +++ /dev/null @@ -1,39 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.page; - -import android.location.Location; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.json.JSONException; -import org.json.JSONObject; - -public final class GeoUnmarshaller { - static final String LATITUDE = "latitude"; - static final String LONGITUDE = "longitude"; - - @Nullable - public static Location unmarshal(@Nullable String json) { - if (json == null) { - return null; - } - - JSONObject jsonObj; - try { - jsonObj = new JSONObject(json); - } catch (JSONException e) { - return null; - } - return unmarshal(jsonObj); - } - - @Nullable - public static Location unmarshal(@NonNull JSONObject jsonObj) { - Location ret = new Location((String) null); - ret.setLatitude(jsonObj.optDouble(LATITUDE)); - ret.setLongitude(jsonObj.optDouble(LONGITUDE)); - return ret; - } - - private GeoUnmarshaller() { } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/Namespace.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/Namespace.kt similarity index 56% rename from app/src/main/java/fr/free/nrw/commons/wikidata/model/page/Namespace.java rename to app/src/main/java/fr/free/nrw/commons/wikidata/model/page/Namespace.kt index 47aff28c20..a52fd09547 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/Namespace.java +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/Namespace.kt @@ -1,34 +1,28 @@ -package fr.free.nrw.commons.wikidata.model.page; +package fr.free.nrw.commons.wikidata.model.page -import androidx.annotation.NonNull; - -import fr.free.nrw.commons.wikidata.model.EnumCode; -import fr.free.nrw.commons.wikidata.model.EnumCodeMap; +import fr.free.nrw.commons.wikidata.model.EnumCode +import fr.free.nrw.commons.wikidata.model.EnumCodeMap /** An enumeration describing the different possible namespace codes. Do not attempt to use this - * class to preserve URL path information such as Talk: or User: or localization. - * @see Wikipedia:Namespace - * @see Extension default namespaces - * @see NSNumber+MWKTitleNamespace.h (iOS implementation) - * @see Manual:Namespace - * @see Namespaces reported by API + * class to preserve URL path information such as Talk: or User: or localization. + * + * @see [Wikipedia:Namespace](https://en.wikipedia.org/wiki/Wikipedia:Namespace) + * @see [Extension default namespaces](https://www.mediawiki.org/wiki/Extension_default_namespaces) + * @see [NSNumber+MWKTitleNamespace.h + * @see [Manual:Namespace](https://www.mediawiki.org/wiki/Manual:Namespace.Built-in_namespaces) + * @see [Namespaces reported by API](https://en.wikipedia.org/w/api.php?action=query&meta=siteinfo&siprop=namespaces|namespacealiases)](https://github.com/wikimedia/wikipedia-ios/blob/master/Wikipedia/Code/NSNumber+MWKTitleNamespace.h) */ -public enum Namespace implements EnumCode { +enum class Namespace(private val code: Int) : EnumCode { MEDIA(-2), - SPECIAL(-1) { - @Override - public boolean talk() { - return false; - } - }, - MAIN(0), // Main or Article + SPECIAL(-1) { override fun talk(): Boolean = false }, + MAIN(0), // Main or Article TALK(1), USER(2), USER_TALK(3), - PROJECT(4), // WP alias - PROJECT_TALK(5), // WT alias - FILE(6), // Image alias - FILE_TALK(7), // Image talk alias + PROJECT(4), // WP alias + PROJECT_TALK(5), // WT alias + FILE(6), // Image alias + FILE_TALK(7), // Image talk alias MEDIAWIKI(8), MEDIAWIKI_TALK(9), TEMPLATE(10), @@ -137,38 +131,20 @@ public boolean talk() { GADGET_DEFINITION_TALK(2303), TOPIC(2600); - private static final int TALK_MASK = 0x1; - private static final EnumCodeMap MAP = new EnumCodeMap<>(Namespace.class); + override fun code(): Int = code - private final int code; + fun special(): Boolean = this === SPECIAL - @NonNull - public static Namespace of(int code) { - return MAP.get(code); - } + fun main(): Boolean = this === MAIN - @Override - public int code() { - return code; - } + fun file(): Boolean = this === FILE - public boolean special() { - return this == SPECIAL; - } + open fun talk(): Boolean = (code and TALK_MASK) == TALK_MASK - public boolean main() { - return this == MAIN; - } - - public boolean file() { - return this == FILE; - } - - public boolean talk() { - return (code & TALK_MASK) == TALK_MASK; - } + companion object { + private const val TALK_MASK = 0x1 + private val MAP = EnumCodeMap(Namespace::class.java) - Namespace(int code) { - this.code = code; + fun of(code: Int): Namespace = MAP[code] } } diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageProperties.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageProperties.java deleted file mode 100644 index 8b32252d83..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageProperties.java +++ /dev/null @@ -1,156 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.page; - -import android.location.Location; -import android.os.Parcel; -import android.os.Parcelable; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Date; - -/** - * Immutable class that contains metadata associated with a PageTitle. - */ -public class PageProperties implements Parcelable { - private final int pageId; - @NonNull private final Namespace namespace; - private final long revisionId; - private final Date lastModified; - private final String displayTitleText; - private final String editProtectionStatus; - private final int languageCount; - private final boolean isMainPage; - private final boolean isDisambiguationPage; - /** Nullable URL with no scheme. For example, foo.bar.com/ instead of http://foo.bar.com/. */ - @Nullable private final String leadImageUrl; - @Nullable private final String leadImageName; - @Nullable private final String titlePronunciationUrl; - @Nullable private final Location geo; - @Nullable private final String wikiBaseItem; - @Nullable private final String descriptionSource; - - /** - * True if the user who first requested this page can edit this page - * FIXME: This is not a true page property, since it depends on current user. - */ - private final boolean canEdit; - - public int getPageId() { - return pageId; - } - - public boolean isMainPage() { - return isMainPage; - } - - public boolean isDisambiguationPage() { - return isDisambiguationPage; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel parcel, int flags) { - parcel.writeInt(pageId); - parcel.writeInt(namespace.code()); - parcel.writeLong(revisionId); - parcel.writeLong(lastModified.getTime()); - parcel.writeString(displayTitleText); - parcel.writeString(titlePronunciationUrl); - parcel.writeString(GeoMarshaller.marshal(geo)); - parcel.writeString(editProtectionStatus); - parcel.writeInt(languageCount); - parcel.writeInt(canEdit ? 1 : 0); - parcel.writeInt(isMainPage ? 1 : 0); - parcel.writeInt(isDisambiguationPage ? 1 : 0); - parcel.writeString(leadImageUrl); - parcel.writeString(leadImageName); - parcel.writeString(wikiBaseItem); - parcel.writeString(descriptionSource); - } - - private PageProperties(Parcel in) { - pageId = in.readInt(); - namespace = Namespace.of(in.readInt()); - revisionId = in.readLong(); - lastModified = new Date(in.readLong()); - displayTitleText = in.readString(); - titlePronunciationUrl = in.readString(); - geo = GeoUnmarshaller.unmarshal(in.readString()); - editProtectionStatus = in.readString(); - languageCount = in.readInt(); - canEdit = in.readInt() == 1; - isMainPage = in.readInt() == 1; - isDisambiguationPage = in.readInt() == 1; - leadImageUrl = in.readString(); - leadImageName = in.readString(); - wikiBaseItem = in.readString(); - descriptionSource = in.readString(); - } - - public static final Parcelable.Creator CREATOR - = new Parcelable.Creator() { - @Override - public PageProperties createFromParcel(Parcel in) { - return new PageProperties(in); - } - - @Override - public PageProperties[] newArray(int size) { - return new PageProperties[size]; - } - }; - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - - PageProperties that = (PageProperties) o; - - return pageId == that.pageId - && namespace == that.namespace - && revisionId == that.revisionId - && lastModified.equals(that.lastModified) - && displayTitleText.equals(that.displayTitleText) - && TextUtils.equals(titlePronunciationUrl, that.titlePronunciationUrl) - && (geo == that.geo || geo != null && geo.equals(that.geo)) - && languageCount == that.languageCount - && canEdit == that.canEdit - && isMainPage == that.isMainPage - && isDisambiguationPage == that.isDisambiguationPage - && TextUtils.equals(editProtectionStatus, that.editProtectionStatus) - && TextUtils.equals(leadImageUrl, that.leadImageUrl) - && TextUtils.equals(leadImageName, that.leadImageName) - && TextUtils.equals(wikiBaseItem, that.wikiBaseItem); - } - - @Override - public int hashCode() { - int result = lastModified.hashCode(); - result = 31 * result + displayTitleText.hashCode(); - result = 31 * result + (titlePronunciationUrl != null ? titlePronunciationUrl.hashCode() : 0); - result = 31 * result + (geo != null ? geo.hashCode() : 0); - result = 31 * result + (editProtectionStatus != null ? editProtectionStatus.hashCode() : 0); - result = 31 * result + languageCount; - result = 31 * result + (isMainPage ? 1 : 0); - result = 31 * result + (isDisambiguationPage ? 1 : 0); - result = 31 * result + (leadImageUrl != null ? leadImageUrl.hashCode() : 0); - result = 31 * result + (leadImageName != null ? leadImageName.hashCode() : 0); - result = 31 * result + (wikiBaseItem != null ? wikiBaseItem.hashCode() : 0); - result = 31 * result + (canEdit ? 1 : 0); - result = 31 * result + pageId; - result = 31 * result + namespace.code(); - result = 31 * result + (int) revisionId; - return result; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageProperties.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageProperties.kt new file mode 100644 index 0000000000..ce8873e8c4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageProperties.kt @@ -0,0 +1,156 @@ +package fr.free.nrw.commons.wikidata.model.page + +import android.location.Location +import android.os.Parcel +import android.os.Parcelable +import android.text.TextUtils +import org.json.JSONException +import org.json.JSONObject +import java.util.Date + +/** + * Immutable class that contains metadata associated with a PageTitle. + */ +class PageProperties private constructor(parcel: Parcel) : Parcelable { + val pageId: Int = parcel.readInt() + private val namespace = Namespace.of(parcel.readInt()) + private val revisionId = parcel.readLong() + private val lastModified = Date(parcel.readLong()) + private val displayTitleText = parcel.readString() + private val editProtectionStatus = parcel.readString() + private val languageCount = parcel.readInt() + val isMainPage: Boolean = parcel.readInt() == 1 + val isDisambiguationPage: Boolean = parcel.readInt() == 1 + + /** Nullable URL with no scheme. For example, foo.bar.com/ instead of http://foo.bar.com/. */ + private val leadImageUrl = parcel.readString() + private val leadImageName = parcel.readString() + private val titlePronunciationUrl = parcel.readString() + private val geo = unmarshal(parcel.readString()) + private val wikiBaseItem = parcel.readString() + private val descriptionSource = parcel.readString() + + /** + * True if the user who first requested this page can edit this page + * FIXME: This is not a true page property, since it depends on current user. + */ + private val canEdit = parcel.readInt() == 1 + + override fun describeContents(): Int { + return 0 + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeInt(pageId) + parcel.writeInt(namespace.code()) + parcel.writeLong(revisionId) + parcel.writeLong(lastModified.time) + parcel.writeString(displayTitleText) + parcel.writeString(titlePronunciationUrl) + parcel.writeString(marshal(geo)) + parcel.writeString(editProtectionStatus) + parcel.writeInt(languageCount) + parcel.writeInt(if (canEdit) 1 else 0) + parcel.writeInt(if (isMainPage) 1 else 0) + parcel.writeInt(if (isDisambiguationPage) 1 else 0) + parcel.writeString(leadImageUrl) + parcel.writeString(leadImageName) + parcel.writeString(wikiBaseItem) + parcel.writeString(descriptionSource) + } + + override fun equals(o: Any?): Boolean { + if (this === o) { + return true + } + if (o == null || javaClass != o.javaClass) { + return false + } + + val that = o as PageProperties + + return pageId == that.pageId && + namespace === that.namespace && + revisionId == that.revisionId && + lastModified == that.lastModified && + displayTitleText == that.displayTitleText && + TextUtils.equals(titlePronunciationUrl, that.titlePronunciationUrl) && + (geo === that.geo || geo != null && geo == that.geo) && + languageCount == that.languageCount && + canEdit == that.canEdit && + isMainPage == that.isMainPage && + isDisambiguationPage == that.isDisambiguationPage && + TextUtils.equals(editProtectionStatus, that.editProtectionStatus) && + TextUtils.equals(leadImageUrl, that.leadImageUrl) && + TextUtils.equals(leadImageName, that.leadImageName) && + TextUtils.equals(wikiBaseItem, that.wikiBaseItem) + } + + override fun hashCode(): Int { + var result = lastModified.hashCode() + result = 31 * result + displayTitleText.hashCode() + result = 31 * result + (titlePronunciationUrl?.hashCode() ?: 0) + result = 31 * result + (geo?.hashCode() ?: 0) + result = 31 * result + (editProtectionStatus?.hashCode() ?: 0) + result = 31 * result + languageCount + result = 31 * result + (if (isMainPage) 1 else 0) + result = 31 * result + (if (isDisambiguationPage) 1 else 0) + result = 31 * result + (leadImageUrl?.hashCode() ?: 0) + result = 31 * result + (leadImageName?.hashCode() ?: 0) + result = 31 * result + (wikiBaseItem?.hashCode() ?: 0) + result = 31 * result + (if (canEdit) 1 else 0) + result = 31 * result + pageId + result = 31 * result + namespace.code() + result = 31 * result + revisionId.toInt() + return result + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): PageProperties { + return PageProperties(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } +} + +private const val LATITUDE: String = "latitude" +private const val LONGITUDE: String = "longitude" + +private fun marshal(location: Location?): String? { + if (location == null) { + return null + } + + val jsonObj = JSONObject().apply { + try { + put(LATITUDE, location.latitude) + put(LONGITUDE, location.longitude) + } catch (e: JSONException) { + throw RuntimeException(e) + } + } + + return jsonObj.toString() +} + +private fun unmarshal(json: String?): Location? { + if (json == null) { + return null + } + + return try { + val jsonObject = JSONObject(json) + Location(null as String?).apply { + latitude = jsonObject.optDouble(LATITUDE) + longitude = jsonObject.optDouble(LONGITUDE) + } + } catch (e: JSONException) { + null + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageTitle.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageTitle.java deleted file mode 100644 index f22ff76097..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageTitle.java +++ /dev/null @@ -1,339 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.page; - -import android.os.Parcel; -import android.os.Parcelable; -import android.text.TextUtils; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.gson.annotations.SerializedName; -import fr.free.nrw.commons.wikidata.model.WikiSite; -import java.io.UnsupportedEncodingException; -import java.net.URLDecoder; -import java.net.URLEncoder; -import java.text.Normalizer; -import java.util.Arrays; -import java.util.Locale; -import timber.log.Timber; - -/** - * Represents certain vital information about a page, including the title, namespace, - * and fragment (section anchor target). It can also contain a thumbnail URL for the - * page, and a short description retrieved from Wikidata. - * - * WARNING: This class is not immutable! Specifically, the thumbnail URL and the Wikidata - * description can be altered after construction. Therefore do NOT rely on all the fields - * of a PageTitle to remain constant for the lifetime of the object. - */ -public class PageTitle implements Parcelable { - - public static final Parcelable.Creator CREATOR - = new Parcelable.Creator() { - @Override - public PageTitle createFromParcel(Parcel in) { - return new PageTitle(in); - } - - @Override - public PageTitle[] newArray(int size) { - return new PageTitle[size]; - } - }; - - /** - * The localised namespace of the page as a string, or null if the page is in mainspace. - * - * This field contains the prefix of the page's title, as opposed to the namespace ID used by - * MediaWiki. Therefore, mainspace pages always have a null namespace, as they have no prefix, - * and the namespace of a page will depend on the language of the wiki the user is currently - * looking at. - * - * Examples: - * * [[Manchester]] on enwiki will have a namespace of null - * * [[Deutschland]] on dewiki will have a namespace of null - * * [[User:Deskana]] on enwiki will have a namespace of "User" - * * [[Utilisateur:Deskana]] on frwiki will have a namespace of "Utilisateur", even if you got - * to the page by going to [[User:Deskana]] and having MediaWiki automatically redirect you. - */ - // TODO: remove. This legacy code is the localized namespace name (File, Special, Talk, etc) but - // isn't consistent across titles. e.g., articles with colons, such as RTÉ News: Six One, - // are broken. - @Nullable private final String namespace; - @NonNull private final String text; - @Nullable private final String fragment; - @Nullable private String thumbUrl; - @SerializedName("site") @NonNull private final WikiSite wiki; - @Nullable private String description; - @Nullable private final PageProperties properties; - // TODO: remove after the restbase endpoint supports ZH variants. - @Nullable private String convertedText; - - /** - * Creates a new PageTitle object. - * Use this if you want to pass in a fragment portion separately from the title. - * - * @param prefixedText title of the page with optional namespace prefix - * @param fragment optional fragment portion - * @param wiki the wiki site the page belongs to - * @return a new PageTitle object matching the given input parameters - */ - public static PageTitle withSeparateFragment(@NonNull String prefixedText, - @Nullable String fragment, @NonNull WikiSite wiki) { - if (TextUtils.isEmpty(fragment)) { - return new PageTitle(prefixedText, wiki, null, (PageProperties) null); - } else { - // TODO: this class needs some refactoring to allow passing in a fragment - // without having to do string manipulations. - return new PageTitle(prefixedText + "#" + fragment, wiki, null, (PageProperties) null); - } - } - - public PageTitle(@Nullable final String namespace, @NonNull String text, @Nullable String fragment, @Nullable String thumbUrl, @NonNull WikiSite wiki) { - this.namespace = namespace; - this.text = text; - this.fragment = fragment; - this.wiki = wiki; - this.thumbUrl = thumbUrl; - properties = null; - } - - public PageTitle(@Nullable String text, @NonNull WikiSite wiki, @Nullable String thumbUrl, @Nullable String description, @Nullable PageProperties properties) { - this(text, wiki, thumbUrl, properties); - this.description = description; - } - - public PageTitle(@Nullable String text, @NonNull WikiSite wiki, @Nullable String thumbUrl, @Nullable String description) { - this(text, wiki, thumbUrl); - this.description = description; - } - - public PageTitle(@Nullable String namespace, @NonNull String text, @NonNull WikiSite wiki) { - this(namespace, text, null, null, wiki); - } - - public PageTitle(@Nullable String text, @NonNull WikiSite wiki, @Nullable String thumbUrl) { - this(text, wiki, thumbUrl, (PageProperties) null); - } - - public PageTitle(@Nullable String text, @NonNull WikiSite wiki) { - this(text, wiki, null); - } - - private PageTitle(@Nullable String text, @NonNull WikiSite wiki, @Nullable String thumbUrl, - @Nullable PageProperties properties) { - if (text == null) { - text = ""; - } - // FIXME: Does not handle mainspace articles with a colon in the title well at all - String[] fragParts = text.split("#", -1); - text = fragParts[0]; - if (fragParts.length > 1) { - this.fragment = decodeURL(fragParts[1]).replace(" ", "_"); - } else { - this.fragment = null; - } - - String[] parts = text.split(":", -1); - if (parts.length > 1) { - String namespaceOrLanguage = parts[0]; - if (Arrays.asList(Locale.getISOLanguages()).contains(namespaceOrLanguage)) { - this.namespace = null; - this.wiki = new WikiSite(wiki.authority(), namespaceOrLanguage); - } else { - this.wiki = wiki; - this.namespace = namespaceOrLanguage; - } - this.text = TextUtils.join(":", Arrays.copyOfRange(parts, 1, parts.length)); - } else { - this.wiki = wiki; - this.namespace = null; - this.text = parts[0]; - } - - this.thumbUrl = thumbUrl; - this.properties = properties; - } - - /** - * Decodes a URL-encoded string into its UTF-8 equivalent. If the string cannot be decoded, the - * original string is returned. - * @param url The URL-encoded string that you wish to decode. - * @return The decoded string, or the input string if the decoding failed. - */ - @NonNull private String decodeURL(@NonNull String url) { - try { - return URLDecoder.decode(url, "UTF-8"); - } catch (IllegalArgumentException e) { - // Swallow IllegalArgumentException (can happen with malformed encoding), and just - // return the original string. - Timber.d("URL decoding failed. String was: %s", url); - return url; - } catch (UnsupportedEncodingException e) { - throw new RuntimeException(e); - } - } - - @NonNull public WikiSite getWikiSite() { - return wiki; - } - - @NonNull public String getText() { - return text.replace(" ", "_"); - } - - @Nullable public String getFragment() { - return fragment; - } - - @Nullable public String getThumbUrl() { - return thumbUrl; - } - - public void setThumbUrl(@Nullable String thumbUrl) { - this.thumbUrl = thumbUrl; - } - - @Nullable public String getDescription() { - return description; - } - - public void setDescription(@Nullable String description) { - this.description = description; - } - - @NonNull - public String getConvertedText() { - return convertedText == null ? getPrefixedText() : convertedText; - } - - public void setConvertedText(@Nullable String convertedText) { - this.convertedText = convertedText; - } - - @NonNull public String getDisplayText() { - return getPrefixedText().replace("_", " "); - } - - @NonNull public String getDisplayTextWithoutNamespace() { - return text.replace("_", " "); - } - - public boolean hasProperties() { - return properties != null; - } - - @Nullable public PageProperties getProperties() { - return properties; - } - - public boolean isMainPage() { - return properties != null && properties.isMainPage(); - } - - public boolean isDisambiguationPage() { - return properties != null && properties.isDisambiguationPage(); - } - - public String getCanonicalUri() { - return getUriForDomain(getWikiSite().authority()); - } - - public String getMobileUri() { - return getUriForDomain(getWikiSite().mobileAuthority()); - } - - public String getUriForAction(String action) { - try { - return String.format( - "%1$s://%2$s/w/index.php?title=%3$s&action=%4$s", - getWikiSite().scheme(), - getWikiSite().authority(), - URLEncoder.encode(getPrefixedText(), "utf-8"), - action - ); - } catch (UnsupportedEncodingException e) { - throw new RuntimeException(e); - } - } - - public String getPrefixedText() { - - // TODO: find a better way to check if the namespace is a ISO Alpha2 Code (two digits country code) - return namespace == null ? getText() : addUnderscores(namespace) + ":" + getText(); - } - - private String addUnderscores(@NonNull String text) { - return text.replace(" ", "_"); - } - - @Override public void writeToParcel(Parcel parcel, int flags) { - parcel.writeString(namespace); - parcel.writeString(text); - parcel.writeString(fragment); - parcel.writeParcelable(wiki, flags); - parcel.writeParcelable(properties, flags); - parcel.writeString(thumbUrl); - parcel.writeString(description); - parcel.writeString(convertedText); - } - - @Override public boolean equals(Object o) { - if (!(o instanceof PageTitle)) { - return false; - } - - PageTitle other = (PageTitle)o; - // Not using namespace directly since that can be null - return normalizedEquals(other.getPrefixedText(), getPrefixedText()) && other.wiki.equals(wiki); - } - - // Compare two strings based on their normalized form, using the Unicode Normalization Form C. - // This should be used when comparing or verifying strings that will be exchanged between - // different platforms (iOS, desktop, etc) that may encode strings using inconsistent - // composition, especially for accents, diacritics, etc. - private boolean normalizedEquals(@Nullable String str1, @Nullable String str2) { - if (str1 == null || str2 == null) { - return (str1 == null && str2 == null); - } - return Normalizer.normalize(str1, Normalizer.Form.NFC) - .equals(Normalizer.normalize(str2, Normalizer.Form.NFC)); - } - - @Override public int hashCode() { - int result = getPrefixedText().hashCode(); - result = 31 * result + wiki.hashCode(); - return result; - } - - @Override public String toString() { - return getPrefixedText(); - } - - @Override public int describeContents() { - return 0; - } - - private String getUriForDomain(String domain) { - try { - return String.format( - "%1$s://%2$s/wiki/%3$s%4$s", - getWikiSite().scheme(), - domain, - URLEncoder.encode(getPrefixedText(), "utf-8"), - (this.fragment != null && this.fragment.length() > 0) ? ("#" + this.fragment) : "" - ); - } catch (UnsupportedEncodingException e) { - throw new RuntimeException(e); - } - } - - private PageTitle(Parcel in) { - namespace = in.readString(); - text = in.readString(); - fragment = in.readString(); - wiki = in.readParcelable(WikiSite.class.getClassLoader()); - properties = in.readParcelable(PageProperties.class.getClassLoader()); - thumbUrl = in.readString(); - description = in.readString(); - convertedText = in.readString(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageTitle.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageTitle.kt new file mode 100644 index 0000000000..b039f55d6d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageTitle.kt @@ -0,0 +1,284 @@ +package fr.free.nrw.commons.wikidata.model.page + +import android.os.Parcel +import android.os.Parcelable +import android.text.TextUtils +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.wikidata.model.WikiSite +import timber.log.Timber +import java.io.UnsupportedEncodingException +import java.net.URLDecoder +import java.net.URLEncoder +import java.text.Normalizer +import java.util.Arrays +import java.util.Locale + +/** + * Represents certain vital information about a page, including the title, namespace, + * and fragment (section anchor target). It can also contain a thumbnail URL for the + * page, and a short description retrieved from Wikidata. + * + * WARNING: This class is not immutable! Specifically, the thumbnail URL and the Wikidata + * description can be altered after construction. Therefore do NOT rely on all the fields + * of a PageTitle to remain constant for the lifetime of the object. + */ +class PageTitle : Parcelable { + /** + * The localised namespace of the page as a string, or null if the page is in mainspace. + * + * This field contains the prefix of the page's title, as opposed to the namespace ID used by + * MediaWiki. Therefore, mainspace pages always have a null namespace, as they have no prefix, + * and the namespace of a page will depend on the language of the wiki the user is currently + * looking at. + * + * Examples: + * * [[Manchester]] on enwiki will have a namespace of null + * * [[Deutschland]] on dewiki will have a namespace of null + * * [[User:Deskana]] on enwiki will have a namespace of "User" + * * [[Utilisateur:Deskana]] on frwiki will have a namespace of "Utilisateur", even if you got + * to the page by going to [[User:Deskana]] and having MediaWiki automatically redirect you. + */ + // TODO: remove. This legacy code is the localized namespace name (File, Special, Talk, etc) but + // isn't consistent across titles. e.g., articles with colons, such as RTÉ News: Six One, + // are broken. + private val namespace: String? + private val text: String + val fragment: String? + var thumbUrl: String? + + @SerializedName("site") + val wikiSite: WikiSite + var description: String? = null + private val properties: PageProperties? + + // TODO: remove after the restbase endpoint supports ZH variants. + private var convertedText: String? = null + + constructor(namespace: String?, text: String, fragment: String?, thumbUrl: String?, wiki: WikiSite) { + this.namespace = namespace + this.text = text + this.fragment = fragment + this.thumbUrl = thumbUrl + wikiSite = wiki + properties = null + } + + constructor(text: String?, wiki: WikiSite, thumbUrl: String?, description: String?, properties: PageProperties?) : this(text, wiki, thumbUrl, properties) { + this.description = description + } + + constructor(text: String?, wiki: WikiSite, thumbUrl: String?, description: String?) : this(text, wiki, thumbUrl) { + this.description = description + } + + constructor(namespace: String?, text: String, wiki: WikiSite) : this(namespace, text, null, null, wiki) + + @JvmOverloads + constructor(text: String?, wiki: WikiSite, thumbUrl: String? = null) : this(text, wiki, thumbUrl, null as PageProperties?) + + private constructor(input: String?, wiki: WikiSite, thumbUrl: String?, properties: PageProperties?) { + var text = input ?: "" + // FIXME: Does not handle mainspace articles with a colon in the title well at all + val fragParts = text.split("#".toRegex()).toTypedArray() + text = fragParts[0] + fragment = if (fragParts.size > 1) { + decodeURL(fragParts[1]).replace(" ", "_") + } else { + null + } + + val parts = text.split(":".toRegex()).toTypedArray() + if (parts.size > 1) { + val namespaceOrLanguage = parts[0] + if (Arrays.asList(*Locale.getISOLanguages()).contains(namespaceOrLanguage)) { + namespace = null + wikiSite = WikiSite(wiki.authority(), namespaceOrLanguage) + } else { + wikiSite = wiki + namespace = namespaceOrLanguage + } + this.text = TextUtils.join(":", Arrays.copyOfRange(parts, 1, parts.size)) + } else { + wikiSite = wiki + namespace = null + this.text = parts[0] + } + + this.thumbUrl = thumbUrl + this.properties = properties + } + + /** + * Decodes a URL-encoded string into its UTF-8 equivalent. If the string cannot be decoded, the + * original string is returned. + * @param url The URL-encoded string that you wish to decode. + * @return The decoded string, or the input string if the decoding failed. + */ + private fun decodeURL(url: String): String { + try { + return URLDecoder.decode(url, "UTF-8") + } catch (e: IllegalArgumentException) { + // Swallow IllegalArgumentException (can happen with malformed encoding), and just + // return the original string. + Timber.d("URL decoding failed. String was: %s", url) + return url + } catch (e: UnsupportedEncodingException) { + throw RuntimeException(e) + } + } + + private fun getTextWithoutSpaces(): String = + text.replace(" ", "_") + + fun getConvertedText(): String = + if (convertedText == null) prefixedText else convertedText!! + + fun setConvertedText(convertedText: String?) { + this.convertedText = convertedText + } + + val displayText: String + get() = prefixedText.replace("_", " ") + + val displayTextWithoutNamespace: String + get() = text.replace("_", " ") + + fun hasProperties(): Boolean = + properties != null + + val isMainPage: Boolean + get() = properties != null && properties.isMainPage + + val isDisambiguationPage: Boolean + get() = properties != null && properties.isDisambiguationPage + + val canonicalUri: String + get() = getUriForDomain(wikiSite.authority()) + + val mobileUri: String + get() = getUriForDomain(wikiSite.mobileAuthority()) + + fun getUriForAction(action: String?): String { + try { + return String.format( + "%1\$s://%2\$s/w/index.php?title=%3\$s&action=%4\$s", + wikiSite.scheme(), + wikiSite.authority(), + URLEncoder.encode(prefixedText, "utf-8"), + action + ) + } catch (e: UnsupportedEncodingException) { + throw RuntimeException(e) + } + } + + // TODO: find a better way to check if the namespace is a ISO Alpha2 Code (two digits country code) + val prefixedText: String + get() = namespace?.let { addUnderscores(it) + ":" + getTextWithoutSpaces() } + ?: getTextWithoutSpaces() + + private fun addUnderscores(text: String): String = + text.replace(" ", "_") + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(namespace) + parcel.writeString(text) + parcel.writeString(fragment) + parcel.writeParcelable(wikiSite, flags) + parcel.writeParcelable(properties, flags) + parcel.writeString(thumbUrl) + parcel.writeString(description) + parcel.writeString(convertedText) + } + + override fun equals(o: Any?): Boolean { + if (o !is PageTitle) { + return false + } + + val other = o + // Not using namespace directly since that can be null + return normalizedEquals(other.prefixedText, prefixedText) && other.wikiSite.equals(wikiSite) + } + + // Compare two strings based on their normalized form, using the Unicode Normalization Form C. + // This should be used when comparing or verifying strings that will be exchanged between + // different platforms (iOS, desktop, etc) that may encode strings using inconsistent + // composition, especially for accents, diacritics, etc. + private fun normalizedEquals(str1: String?, str2: String?): Boolean { + if (str1 == null || str2 == null) { + return (str1 == null && str2 == null) + } + return (Normalizer.normalize(str1, Normalizer.Form.NFC) + == Normalizer.normalize(str2, Normalizer.Form.NFC)) + } + + override fun hashCode(): Int { + var result = prefixedText.hashCode() + result = 31 * result + wikiSite.hashCode() + return result + } + + override fun toString(): String = + prefixedText + + override fun describeContents(): Int = 0 + + private fun getUriForDomain(domain: String): String = try { + String.format( + "%1\$s://%2\$s/wiki/%3\$s%4\$s", + wikiSite.scheme(), + domain, + URLEncoder.encode(prefixedText, "utf-8"), + if ((fragment != null && fragment.length > 0)) ("#$fragment") else "" + ) + } catch (e: UnsupportedEncodingException) { + throw RuntimeException(e) + } + + private constructor(parcel: Parcel) { + namespace = parcel.readString() + text = parcel.readString()!! + fragment = parcel.readString() + wikiSite = parcel.readParcelable(WikiSite::class.java.classLoader)!! + properties = parcel.readParcelable(PageProperties::class.java.classLoader) + thumbUrl = parcel.readString() + description = parcel.readString() + convertedText = parcel.readString() + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): PageTitle { + return PageTitle(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + /** + * Creates a new PageTitle object. + * Use this if you want to pass in a fragment portion separately from the title. + * + * @param prefixedText title of the page with optional namespace prefix + * @param fragment optional fragment portion + * @param wiki the wiki site the page belongs to + * @return a new PageTitle object matching the given input parameters + */ + fun withSeparateFragment( + prefixedText: String, + fragment: String?, wiki: WikiSite + ): PageTitle { + return if (TextUtils.isEmpty(fragment)) { + PageTitle(prefixedText, wiki, null, null as PageProperties?) + } else { + // TODO: this class needs some refactoring to allow passing in a fragment + // without having to do string manipulations. + PageTitle("$prefixedText#$fragment", wiki, null, null as PageProperties?) + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ImageDetails.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ImageDetails.java deleted file mode 100644 index 3a4ac97026..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ImageDetails.java +++ /dev/null @@ -1,17 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; - -public class ImageDetails { - - private String name; - private String title; - - @NonNull public String getName() { - return name; - } - - @NonNull public String getTitle() { - return title; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ImageDetails.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ImageDetails.kt new file mode 100644 index 0000000000..d6e2f57ddc --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ImageDetails.kt @@ -0,0 +1,6 @@ +package fr.free.nrw.commons.wikidata.mwapi + +class ImageDetails { + val name: String? = null + val title: String? = null +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ListUserResponse.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ListUserResponse.java deleted file mode 100644 index a71e910c30..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ListUserResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.collection.ArraySet; - -import com.google.gson.annotations.SerializedName; - -import java.util.Collections; -import java.util.List; -import java.util.Set; - - -public class ListUserResponse { - @SerializedName("name") @Nullable private String name; - private long userid; - @Nullable private List groups; - - @Nullable public String name() { - return name; - } - - @NonNull public Set getGroups() { - return groups != null ? new ArraySet<>(groups) : Collections.emptySet(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ListUserResponse.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ListUserResponse.kt new file mode 100644 index 0000000000..f5a04fc8e6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ListUserResponse.kt @@ -0,0 +1,12 @@ +package fr.free.nrw.commons.wikidata.mwapi + +class ListUserResponse { + private val name: String? = null + private val userid: Long = 0 + private val groups: List? = null + + fun name(): String? = name + + fun getGroups(): Set = + groups?.toSet() ?: emptySet() +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwException.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwException.java deleted file mode 100644 index f526815767..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwException.java +++ /dev/null @@ -1,44 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.Nullable; -import java.util.List; - -public class MwException extends RuntimeException { - @Nullable private final MwServiceError error; - - @Nullable private final List errors; - - public MwException(@Nullable MwServiceError error, - @Nullable final List errors) { - this.error = error; - this.errors = errors; - } - - public String getErrorCode() { - if(error!=null) { - return error.getCode(); - } - return errors != null ? errors.get(0).getCode() : null; - } - - @Nullable public MwServiceError getError() { - return error; - } - - @Nullable - public String getTitle() { - if (error != null) { - return error.getTitle(); - } - return errors != null ? errors.get(0).getTitle() : null; - } - - @Override - @Nullable - public String getMessage() { - if (error != null) { - return error.getDetails(); - } - return errors != null ? errors.get(0).getDetails() : null; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwException.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwException.kt new file mode 100644 index 0000000000..9b003ecfa4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwException.kt @@ -0,0 +1,15 @@ +package fr.free.nrw.commons.wikidata.mwapi + +class MwException( + val error: MwServiceError?, + private val errors: List? +) : RuntimeException() { + val errorCode: String? + get() = error?.code ?: errors?.get(0)?.code + + val title: String? + get() = error?.title ?: errors?.get(0)?.title + + override val message: String? + get() = error?.details ?: errors?.get(0)?.details +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwPostResponse.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwPostResponse.java deleted file mode 100644 index 294fdad0a7..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwPostResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.Nullable; - -public class MwPostResponse extends MwResponse { - private int success; - - public boolean success(@Nullable String result) { - return "success".equals(result); - } - - public int getSuccessVal() { - return success; - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwPostResponse.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwPostResponse.kt new file mode 100644 index 0000000000..a4ed55b7b2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwPostResponse.kt @@ -0,0 +1,9 @@ +package fr.free.nrw.commons.wikidata.mwapi + +open class MwPostResponse : MwResponse() { + val successVal: Int = 0 + + fun success(result: String?): Boolean = + "success" == result +} + diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryPage.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryPage.java deleted file mode 100644 index be886b2059..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryPage.java +++ /dev/null @@ -1,229 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.annotations.SerializedName; - -import org.apache.commons.lang3.StringUtils; -import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo; -import fr.free.nrw.commons.wikidata.model.BaseModel; - -import java.util.Collections; -import java.util.List; - -/** - * A class representing a standard page object as returned by the MediaWiki API. - */ -public class MwQueryPage extends BaseModel { - private int pageid; - private int index; - @NonNull private String title; - @NonNull private CategoryInfo categoryinfo; - @Nullable private List revisions; - @SerializedName("fileusage") @Nullable private List fileUsages; - @SerializedName("globalusage") @Nullable private List globalUsages; - @Nullable private List coordinates; - @Nullable private List categories; - @Nullable private Thumbnail thumbnail; - @Nullable private String description; - @SerializedName("imageinfo") @Nullable private List imageInfo; - @Nullable private String redirectFrom; - @Nullable private String convertedFrom; - @Nullable private String convertedTo; - - @NonNull public String title() { - return title; - } - - @NonNull public CategoryInfo categoryInfo() { - return categoryinfo; - } - - public int index() { - return index; - } - - @Nullable public List revisions() { - return revisions; - } - - @Nullable public List categories() { - return categories; - } - - @Nullable public List coordinates() { - // TODO: Handle null values in lists during deserialization, perhaps with a new - // @RequiredElements annotation and corresponding TypeAdapter - if (coordinates != null) { - coordinates.removeAll(Collections.singleton(null)); - } - return coordinates; - } - - public int pageId() { - return pageid; - } - - @Nullable public String thumbUrl() { - return thumbnail != null ? thumbnail.source() : null; - } - - @Nullable public String description() { - return description; - } - - @Nullable public ImageInfo imageInfo() { - return imageInfo != null ? imageInfo.get(0) : null; - } - - public void redirectFrom(@Nullable String from) { - redirectFrom = from; - } - - public void convertedFrom(@Nullable String from) { - convertedFrom = from; - } - - public void convertedTo(@Nullable String to) { - convertedTo = to; - } - - public void appendTitleFragment(@Nullable String fragment) { - title += "#" + fragment; - } - - public boolean checkWhetherFileIsUsedInWikis() { - if (globalUsages != null && globalUsages.size() > 0) { - return true; - } - - if (fileUsages == null || fileUsages.size() == 0) { - return false; - } - - final int totalCount = fileUsages.size(); - - /* Ignore usage under https://commons.wikimedia.org/wiki/User:Didym/Mobile_upload/ - which has been a gallery of all of our uploads since 2014 */ - for (final FileUsage fileUsage : fileUsages) { - if ( ! fileUsage.title().contains("User:Didym/Mobile upload")) { - return true; - } - } - - return false; - } - - public static class Revision { - @SerializedName("revid") private long revisionId; - private String user; - @SerializedName("contentformat") @NonNull private String contentFormat; - @SerializedName("contentmodel") @NonNull private String contentModel; - @SerializedName("timestamp") @NonNull private String timeStamp; - @NonNull private String content; - - @NonNull public String content() { - return content; - } - - @NonNull public String timeStamp() { - return StringUtils.defaultString(timeStamp); - } - - public long getRevisionId() { - return revisionId; - } - - @NonNull - public String getUser() { - return StringUtils.defaultString(user); - } - } - - public static class Coordinates { - @Nullable private Double lat; - @Nullable private Double lon; - - @Nullable public Double lat() { - return lat; - } - @Nullable public Double lon() { - return lon; - } - } - - public static class CategoryInfo { - private boolean hidden; - private int size; - private int pages; - private int files; - private int subcats; - public boolean isHidden() { - return hidden; - } - } - - static class Thumbnail { - private String source; - private int width; - private int height; - String source() { - return source; - } - } - - public static class GlobalUsage { - @SerializedName("title") private String title; - @SerializedName("wiki")private String wiki; - @SerializedName("url") private String url; - - public String getTitle() { - return title; - } - - public String getWiki() { - return wiki; - } - - public String getUrl() { - return url; - } - } - - public static class FileUsage { - @SerializedName("pageid") private int pageid; - @SerializedName("ns") private int ns; - @SerializedName("title") private String title; - - public int pageId() { - return pageid; - } - - public int ns() { - return ns; - } - - public String title() { - return title; - } - } - - public static class Category { - private int ns; - @SuppressWarnings("unused,NullableProblems") @Nullable private String title; - private boolean hidden; - - public int ns() { - return ns; - } - - @NonNull public String title() { - return StringUtils.defaultString(title); - } - - public boolean hidden() { - return hidden; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryPage.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryPage.kt new file mode 100644 index 0000000000..74dfa511ee --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryPage.kt @@ -0,0 +1,186 @@ +package fr.free.nrw.commons.wikidata.mwapi + +import androidx.annotation.VisibleForTesting +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.wikidata.model.BaseModel +import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo +import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage.GlobalUsage + +/** + * A class representing a standard page object as returned by the MediaWiki API. + */ +class MwQueryPage : BaseModel() { + private val pageid = 0 + private val index = 0 + private var title: String? = null + private val categoryinfo: CategoryInfo? = null + private val revisions: List? = null + + @SerializedName("fileusage") + private val fileUsages: List? = null + + @SerializedName("globalusage") + private val globalUsages: List? = null + private val coordinates: MutableList? = null + private val categories: List? = null + private val thumbnail: Thumbnail? = null + private val description: String? = null + + @SerializedName("imageinfo") + private val imageInfo: List? = null + private var redirectFrom: String? = null + private var convertedFrom: String? = null + private var convertedTo: String? = null + + fun title(): String = title!! + + fun categoryInfo(): CategoryInfo = categoryinfo!! + + fun index(): Int = index + + fun revisions(): List? = revisions + + fun categories(): List? = categories + + // TODO: Handle null values in lists during deserialization, perhaps with a new + // @RequiredElements annotation and corresponding TypeAdapter + fun coordinates(): List? = coordinates?.filterNotNull() + + fun pageId(): Int = pageid + + fun thumbUrl(): String? = thumbnail?.source() + + fun description(): String? = description + + fun imageInfo(): ImageInfo? = imageInfo?.get(0) + + fun redirectFrom(from: String?) { + redirectFrom = from + } + + fun convertedFrom(from: String?) { + convertedFrom = from + } + + fun convertedTo(to: String?) { + convertedTo = to + } + + fun appendTitleFragment(fragment: String?) { + title += "#$fragment" + } + + fun checkWhetherFileIsUsedInWikis(): Boolean { + return checkWhetherFileIsUsedInWikis(globalUsages, fileUsages) + } + + class Revision { + @SerializedName("revid") + private val revisionId: Long = 0 + private val user: String? = null + @SerializedName("contentformat") + private val contentFormat: String? = null + @SerializedName("contentmodel") + private val contentModel: String? = null + @SerializedName("timestamp") + private val timeStamp: String? = null + private val content: String? = null + + fun revisionId(): Long = revisionId + + fun user(): String = user ?: "" + + fun content(): String = content!! + + fun timeStamp(): String = timeStamp ?: "" + } + + class Coordinates { + private val lat: Double? = null + private val lon: Double? = null + + fun lat(): Double? = lat + + fun lon(): Double? = lon + } + + class CategoryInfo { + val isHidden: Boolean = false + private val size = 0 + private val pages = 0 + private val files = 0 + private val subcats = 0 + } + + internal class Thumbnail { + private val source: String? = null + private val width = 0 + private val height = 0 + + fun source(): String? = source + } + + class GlobalUsage { + @SerializedName("title") + val title: String? = null + + @SerializedName("wiki") + val wiki: String? = null + + @SerializedName("url") + val url: String? = null + } + + class FileUsage { + @SerializedName("pageid") + private val pageid = 0 + + @SerializedName("ns") + private val ns = 0 + + @SerializedName("title") + private var title: String? = null + + fun pageId(): Int = pageid + + fun ns(): Int = ns + + fun title(): String = title ?: "" + + fun setTitle(value: String) { + title = value + } + } + + class Category { + private val ns = 0 + private val title: String? = null + private val hidden = false + + fun ns(): Int = ns + + fun title(): String = title ?: "" + + fun hidden(): Boolean = hidden + } +} + +@VisibleForTesting +fun checkWhetherFileIsUsedInWikis( + globalUsages: List?, + fileUsages: List? +): Boolean { + if (!globalUsages.isNullOrEmpty()) { + return true + } + + if (fileUsages.isNullOrEmpty()) { + return false + } + + /* Ignore usage under https://commons.wikimedia.org/wiki/User:Didym/Mobile_upload/ + which has been a gallery of all of our uploads since 2014 */ + return fileUsages.filterNot { + it.title().contains("User:Didym/Mobile upload") + }.isNotEmpty() +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResponse.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResponse.java deleted file mode 100644 index 871a2c31b0..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.Nullable; - -import com.google.gson.annotations.SerializedName; - -import java.util.Map; - -public class MwQueryResponse extends MwResponse { - - @SerializedName("continue") @Nullable private Map continuation; - - @SerializedName("query") @Nullable private MwQueryResult query; - - @Nullable public Map continuation() { - return continuation; - } - - @Nullable public MwQueryResult query() { - return query; - } - - public boolean success() { - return query != null; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResponse.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResponse.kt new file mode 100644 index 0000000000..875a3cc2ea --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResponse.kt @@ -0,0 +1,15 @@ +package fr.free.nrw.commons.wikidata.mwapi + +import com.google.gson.annotations.SerializedName + +class MwQueryResponse : MwResponse() { + @SerializedName("continue") + private val continuation: Map? = null + private val query: MwQueryResult? = null + + fun continuation(): Map? = continuation + + fun query(): MwQueryResult? = query + + fun success(): Boolean = query != null +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResult.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResult.java deleted file mode 100644 index 303400160f..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResult.java +++ /dev/null @@ -1,187 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.annotations.SerializedName; - -import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter; -import org.apache.commons.lang3.StringUtils; -import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo; -import fr.free.nrw.commons.wikidata.model.BaseModel; -import fr.free.nrw.commons.wikidata.model.notifications.Notification; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - - -public class MwQueryResult extends BaseModel implements PostProcessingTypeAdapter.PostProcessable { - @SerializedName("pages") @Nullable private List pages; - @Nullable private List redirects; - @Nullable private List converted; - @SerializedName("userinfo") private UserInfo userInfo; - @Nullable private List users; - @Nullable private Tokens tokens; - @Nullable private NotificationList notifications; - @SerializedName("allimages") @Nullable private List allImages; - - @Nullable public List pages() { - return pages; - } - - @Nullable public MwQueryPage firstPage() { - if (pages != null && pages.size() > 0) { - return pages.get(0); - } - return null; - } - - @NonNull - public List allImages() { - return allImages == null ? Collections.emptyList() : allImages; - } - - @Nullable public UserInfo userInfo() { - return userInfo; - } - - @Nullable public String csrfToken() { - return tokens != null ? tokens.csrf() : null; - } - - @Nullable public String loginToken() { - return tokens != null ? tokens.login() : null; - } - - @Nullable public NotificationList notifications() { - return notifications; - } - - @Nullable public ListUserResponse getUserResponse(@NonNull String userName) { - if (users != null) { - for (ListUserResponse user : users) { - // MediaWiki user names are case sensitive, but the first letter is always capitalized. - if (StringUtils.capitalize(userName).equals(user.name())) { - return user; - } - } - } - return null; - } - - @NonNull public Map images() { - Map result = new HashMap<>(); - if (pages != null) { - for (MwQueryPage page : pages) { - if (page.imageInfo() != null) { - result.put(page.title(), page.imageInfo()); - } - } - } - return result; - } - - @Override - public void postProcess() { - resolveConvertedTitles(); - resolveRedirectedTitles(); - } - - private void resolveRedirectedTitles() { - if (redirects == null || pages == null) { - return; - } - for (MwQueryPage page : pages) { - for (MwQueryResult.Redirect redirect : redirects) { - // TODO: Looks like result pages and redirects can also be matched on the "index" - // property. Confirm in the API docs and consider updating. - if (page.title().equals(redirect.to())) { - page.redirectFrom(redirect.from()); - if (redirect.toFragment() != null) { - page.appendTitleFragment(redirect.toFragment()); - } - } - } - } - } - - private void resolveConvertedTitles() { - if (converted == null || pages == null) { - return; - } - // noinspection ConstantConditions - for (MwQueryResult.ConvertedTitle convertedTitle : converted) { - // noinspection ConstantConditions - for (MwQueryPage page : pages) { - if (page.title().equals(convertedTitle.to())) { - page.convertedFrom(convertedTitle.from()); - page.convertedTo(convertedTitle.to()); - } - } - } - } - - private static class Redirect { - private int index; - @Nullable private String from; - @Nullable private String to; - @SerializedName("tofragment") @Nullable private String toFragment; - - @Nullable public String to() { - return to; - } - - @Nullable public String from() { - return from; - } - - @Nullable public String toFragment() { - return toFragment; - } - } - - public static class ConvertedTitle { - @Nullable private String from; - @Nullable private String to; - - @Nullable public String to() { - return to; - } - - @Nullable public String from() { - return from; - } - } - - private static class Tokens { - @SuppressWarnings("unused,NullableProblems") @SerializedName("csrftoken") - @Nullable private String csrf; - @SuppressWarnings("unused,NullableProblems") @SerializedName("createaccounttoken") - @Nullable private String createAccount; - @SuppressWarnings("unused,NullableProblems") @SerializedName("logintoken") - @Nullable private String login; - - @Nullable private String csrf() { - return csrf; - } - - @Nullable private String createAccount() { - return createAccount; - } - - @Nullable private String login() { - return login; - } - } - - public static class NotificationList { - @Nullable - private List list; - @Nullable - public List list() { - return list; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResult.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResult.kt new file mode 100644 index 0000000000..8c898d848f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResult.kt @@ -0,0 +1,134 @@ +package fr.free.nrw.commons.wikidata.mwapi + +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter.PostProcessable +import fr.free.nrw.commons.wikidata.model.BaseModel +import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo +import fr.free.nrw.commons.wikidata.model.notifications.Notification +import org.apache.commons.lang3.StringUtils + +class MwQueryResult : BaseModel(), PostProcessable { + private val pages: List? = null + private val redirects: List? = null + private val converted: List? = null + + @SerializedName("userinfo") + private val userInfo: UserInfo? = null + private val users: List? = null + private val tokens: Tokens? = null + private val notifications: NotificationList? = null + + @SerializedName("allimages") + private val allImages: List? = null + + fun pages(): List? = pages + + fun firstPage(): MwQueryPage? = pages?.firstOrNull() + + fun allImages(): List = allImages ?: emptyList() + + fun userInfo(): UserInfo? = userInfo + + fun csrfToken(): String? = tokens?.csrf() + + fun loginToken(): String? = tokens?.login() + + fun notifications(): NotificationList? = notifications + + fun getUserResponse(userName: String): ListUserResponse? = + users?.find { StringUtils.capitalize(userName) == it.name() } + + fun images() = buildMap { + pages?.forEach { page -> + page.imageInfo()?.let { + put(page.title(), it) + } + } + } + + override fun postProcess() { + resolveConvertedTitles() + resolveRedirectedTitles() + } + + private fun resolveRedirectedTitles() { + if (redirects == null || pages == null) { + return + } + + pages.forEach { page -> + redirects.forEach { redirect -> + // TODO: Looks like result pages and redirects can also be matched on the "index" + // property. Confirm in the API docs and consider updating. + if (page.title() == redirect.to()) { + page.redirectFrom(redirect.from()) + if (redirect.toFragment() != null) { + page.appendTitleFragment(redirect.toFragment()) + } + } + } + } + } + + private fun resolveConvertedTitles() { + if (converted == null || pages == null) { + return + } + + converted.forEach { convertedTitle -> + pages.forEach { page -> + if (page.title() == convertedTitle.to()) { + page.convertedFrom(convertedTitle.from()) + page.convertedTo(convertedTitle.to()) + } + } + } + } + + private class Redirect { + private val index = 0 + private val from: String? = null + private val to: String? = null + + @SerializedName("tofragment") + private val toFragment: String? = null + + fun to(): String? = to + + fun from(): String? = from + + fun toFragment(): String? = toFragment + } + + class ConvertedTitle { + private val from: String? = null + private val to: String? = null + + fun to(): String? = to + + fun from(): String? = from + } + + private class Tokens { + @SerializedName("csrftoken") + private val csrf: String? = null + + @SerializedName("createaccounttoken") + private val createAccount: String? = null + + @SerializedName("logintoken") + private val login: String? = null + + fun csrf(): String? = csrf + + fun createAccount(): String? = createAccount + + fun login(): String? = login + } + + class NotificationList { + private val list: List? = null + + fun list(): List? = list + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwResponse.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwResponse.java deleted file mode 100644 index 52662cd12c..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.annotations.SerializedName; - -import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter; -import fr.free.nrw.commons.wikidata.model.BaseModel; - -import java.util.List; - -public abstract class MwResponse extends BaseModel implements PostProcessingTypeAdapter.PostProcessable { - @SuppressWarnings({"unused"}) @Nullable private List errors; - @SuppressWarnings("unused,NullableProblems") @SerializedName("servedby") @NonNull private String servedBy; - - @Override - public void postProcess() { - if (errors != null && !errors.isEmpty()) { - throw new MwException(errors.get(0), errors); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwResponse.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwResponse.kt new file mode 100644 index 0000000000..02a637b142 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwResponse.kt @@ -0,0 +1,18 @@ +package fr.free.nrw.commons.wikidata.mwapi + +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter.PostProcessable +import fr.free.nrw.commons.wikidata.model.BaseModel + +abstract class MwResponse : BaseModel(), PostProcessable { + private val errors: List? = null + + @SerializedName("servedby") + private val servedBy: String? = null + + override fun postProcess() { + if (!errors.isNullOrEmpty()) { + throw MwException(errors[0], errors) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwServiceError.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwServiceError.java deleted file mode 100644 index 4798a19b4b..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwServiceError.java +++ /dev/null @@ -1,29 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.apache.commons.lang3.StringUtils; -import fr.free.nrw.commons.wikidata.model.BaseModel; - -/** - * Gson POJO for a MediaWiki API error. - */ -public class MwServiceError extends BaseModel { - - @Nullable private String code; - @Nullable private String text; - - @NonNull public String getTitle() { - return StringUtils.defaultString(code); - } - - @NonNull public String getDetails() { - return StringUtils.defaultString(text); - } - - @Nullable - public String getCode() { - return code; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwServiceError.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwServiceError.kt new file mode 100644 index 0000000000..1408efbef5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwServiceError.kt @@ -0,0 +1,18 @@ +package fr.free.nrw.commons.wikidata.mwapi + +import fr.free.nrw.commons.wikidata.model.BaseModel +import org.apache.commons.lang3.StringUtils + +/** + * Gson POJO for a MediaWiki API error. + */ +class MwServiceError : BaseModel() { + val code: String? = null + private val text: String? = null + + val title: String + get() = code ?: "" + + val details: String + get() = text ?: "" +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.java deleted file mode 100644 index 3ac9e39159..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.java +++ /dev/null @@ -1,34 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import java.util.Map; - - -public class UserInfo { - @NonNull private String name; - @NonNull private int id; - - //Block information - private int blockid; - private String blockedby; - private int blockedbyid; - private String blockreason; - private String blocktimestamp; - private String blockexpiry; - - // Object type is any JSON type. - @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") - @Nullable private Map options; - - public int id() { - return id; - } - - @NonNull - public String blockexpiry() { - if (blockexpiry != null) - return blockexpiry; - else return ""; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.kt new file mode 100644 index 0000000000..c9182a821f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.wikidata.mwapi + +data class UserInfo( + val name: String = "", + val id: Int = 0, + + //Block information + val blockid: Int = 0, + val blockedby: String? = null, + val blockedbyid: Int = 0, + val blockreason: String? = null, + val blocktimestamp: String? = null, + val blockexpiry: String? = null, + + // Object type is any JSON type. + val options: Map? = null +) { + fun id(): Int = id + + fun blockexpiry(): String = blockexpiry ?: "" +} diff --git a/app/src/main/res/layout/fragment_achievements.xml b/app/src/main/res/layout/fragment_achievements.xml index e0dddcf5bf..00c18b3232 100644 --- a/app/src/main/res/layout/fragment_achievements.xml +++ b/app/src/main/res/layout/fragment_achievements.xml @@ -1,640 +1,368 @@ - - - + + + + + + + + + - - + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_centerInParent="true" + android:progressDrawable="@android:drawable/progress_horizontal" + android:progressBackgroundTintMode="multiply" + android:progressTint="#5ce65c" + tools:progress="50" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_media_detail.xml b/app/src/main/res/layout/fragment_media_detail.xml index 3c063945dc..7ce90d19e6 100644 --- a/app/src/main/res/layout/fragment_media_detail.xml +++ b/app/src/main/res/layout/fragment_media_detail.xml @@ -456,6 +456,11 @@ android:layout_height="match_parent" /> + +