diff --git a/CHANGELOG.md b/CHANGELOG.md index e7accf82b..df55b1124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Wikimedia Commons for Android +## v5.3.0 + +### What's changed +* Enable EmailAuth support +* Explore map images no longer show "Unknown" +* Fix crash when removing last two images of multiupload +* Mark ❌ for closed locations (P3999) in Nearby +* Fix two pin labels staying visible at the same time in Explore map +* Refactoring and minor UI improvements + +## v5.2.0 + +v5.2.0 boasts several new functionalities like: + +* A new refresh button lets you quickly reload the Nearby map +* Bookmarks now support categories +* Improved feedback and consistency in the user interface +* Bug fixes and performance improvements + +### What's changed +* Implement "Refresh" button to clear the cache and reload the Nearby map. +* `CommonsApplication` migrate to kotlin & some lint fixes. +* Revert back to MainScope for database and UI updates and make database operations thread safe. +* Hide edit options for logged-out users in Explore screen. +* Introduced a button to delete the current folder in custom selector. +* Improve Unique File Name Search. +* Migration of several modules from Java to Kotlin. +* Fix modification on bottom sheet's data when coming from Nearby Banner and clicked on other pins. +* Bug fixes and enhancement of Achievements screen. +* Show where file is being used on Commons and other wikis. +* Migrate android.media.ExifInterface to androidx.exifinterface.media.ExifInterface as android.media.ExifInterface had security flaws on older devices. +* Make dialogs modal and always show the upload icon. +* Fix unintentional deletion of subfolders and non-images by custom selector. +* Bookmark categories. +* Add pull down to refresh in the Contributions screen. +* Fix race condition and lag when loading pin details, faster overlay management. +* Show cached pins in Nearby even when internet is unavailable + + Full changelog with the list of contributors: [`v5.1.2...v5.2.0`](https://github.com/commons-app/apps-android-commons/compare/v5.1.2...v5.2.0). + + ## v5.1.2 ### What's changed diff --git a/app/build.gradle b/app/build.gradle index 6890177e8..11dd6defc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -53,11 +53,26 @@ dependencies { implementation 'androidx.compose.ui:ui-tooling-preview' androidTestImplementation 'androidx.compose.ui:ui-test-junit4' + def lifecycle_version = "2.8.7" + // ViewModel + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" + // ViewModel utilities for Compose + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version" + + // Lifecycles only (without ViewModel or LiveData) + implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" + // Lifecycle utilities for Compose + implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version" + + // Saved state module for ViewModel + implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version" + + // Annotation processor + kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version" // Jetpack Compose def composeBom = platform('androidx.compose:compose-bom:2024.11.00') 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" @@ -212,8 +227,8 @@ android { defaultConfig { //applicationId 'fr.free.nrw.commons' - versionCode 1046 - versionName '5.1.3' + versionCode 1050 + versionName '5.3.0' setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName()) minSdkVersion 21 @@ -318,7 +333,7 @@ android { buildConfigField "String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"" 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", "PRIVACY_POLICY_URL", "\"https://commons-app.github.io/privacy-policy\"" 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\"" @@ -333,6 +348,7 @@ android { buildConfigField "String", "TEST_USERNAME", "\"" + getTestUserName() + "\"" buildConfigField "String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"" buildConfigField "String", "DEPICTS_PROPERTY", "\"P180\"" + buildConfigField "String", "CREATOR_PROPERTY", "\"P170\"" dimension 'tier' } @@ -355,7 +371,7 @@ android { buildConfigField "String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\"" 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", "PRIVACY_POLICY_URL", "\"https://commons-app.github.io/privacy-policy\"" 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\"" @@ -370,6 +386,7 @@ android { buildConfigField "String", "TEST_USERNAME", "\"" + getTestUserName() + "\"" buildConfigField "String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"" buildConfigField "String", "DEPICTS_PROPERTY", "\"P245962\"" + buildConfigField "String", "CREATOR_PROPERTY", "\"P253075\"" dimension 'tier' } } diff --git a/app/src/main/java/fr/free/nrw/commons/Media.kt b/app/src/main/java/fr/free/nrw/commons/Media.kt index 1222cd8b0..d07bc0265 100644 --- a/app/src/main/java/fr/free/nrw/commons/Media.kt +++ b/app/src/main/java/fr/free/nrw/commons/Media.kt @@ -53,6 +53,7 @@ class Media constructor( */ var author: String? = null, var user: String? = null, + var creatorName: String? = null, /** * Gets the categories the file falls under. * @return file categories as an ArrayList of Strings @@ -66,6 +67,7 @@ class Media constructor( var captions: Map = emptyMap(), var descriptions: Map = emptyMap(), var depictionIds: List = emptyList(), + var creatorIds: List = emptyList(), /** * This field was added to find non-hidden categories * Stores the mapping of category title to hidden attribute @@ -130,6 +132,7 @@ class Media constructor( * returns user * @return Author or User */ + @Deprecated("Use user for uploader username. Use attributedAuthor() for attribution. Note that the uploader may not be the creator/author.") fun getAuthorOrUser(): String? { return if (!author.isNullOrEmpty()) { author @@ -138,6 +141,19 @@ class Media constructor( } } + /** + * Returns author if it's not null or empty, otherwise + * returns creator name + * @return name of author or creator + */ + fun getAttributedAuthor(): String? { + return if (!author.isNullOrEmpty()) { + author + } else{ + creatorName + } + } + /** * Gets media display title * @return Media title diff --git a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt index 2ff54959d..970413283 100644 --- a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt +++ b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.kt @@ -1,7 +1,7 @@ package fr.free.nrw.commons import androidx.core.text.HtmlCompat -import fr.free.nrw.commons.media.IdAndCaptions +import fr.free.nrw.commons.media.IdAndLabels import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.media.PAGE_ID_PREFIX import io.reactivex.Single @@ -23,13 +23,23 @@ class MediaDataExtractor private val mediaClient: MediaClient, ) { fun fetchDepictionIdsAndLabels(media: Media) = - mediaClient + mediaClient .getEntities(media.depictionIds) .map { it .entities() .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } } - }.map { it.map { (key, value) -> IdAndCaptions(key, value) } } + }.map { it.map { (key, value) -> IdAndLabels(key, value) } } + .onErrorReturn { emptyList() } + + fun fetchCreatorIdsAndLabels(media: Media) = + mediaClient + .getEntities(media.creatorIds) + .map { + it + .entities() + .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } } + }.map { it.map { (key, value) -> IdAndLabels(key, value) } } .onErrorReturn { emptyList() } fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename) diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt index 75c4ac26d..840bc7ca3 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt @@ -65,6 +65,7 @@ class LoginActivity : AccountAuthenticatorActivity() { private val delegate: AppCompatDelegate by lazy { AppCompatDelegate.create(this, null) } + private var lastLoginResult: LoginResult? = null public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -271,6 +272,7 @@ class LoginActivity : AccountAuthenticatorActivity() { showLoggingProgressBar() loginClient.doLogin(username, password, + lastLoginResult, twoFactorCode, Locale.getDefault().language, object : LoginCallback { @@ -280,9 +282,17 @@ class LoginActivity : AccountAuthenticatorActivity() { onLoginSuccess(loginResult) } - override fun twoFactorPrompt(caught: Throwable, token: String?) = runOnUiThread { + override fun twoFactorPrompt(loginResult: LoginResult, caught: Throwable, token: String?) = runOnUiThread { Timber.d("Requesting 2FA prompt") progressDialog!!.dismiss() + lastLoginResult = loginResult + askUserForTwoFactorAuth() + } + + override fun emailAuthPrompt(loginResult: LoginResult, caught: Throwable, token: String?) { + Timber.d("Requesting email auth prompt") + progressDialog!!.dismiss() + lastLoginResult = loginResult askUserForTwoFactorAuth() } @@ -341,12 +351,13 @@ class LoginActivity : AccountAuthenticatorActivity() { progressDialog!!.dismiss() with(binding!!) { twoFactorContainer.visibility = View.VISIBLE + twoFactorContainer.hint = getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.email_auth_code else R.string._2fa_code) loginTwoFactor.visibility = View.VISIBLE loginTwoFactor.requestFocus() } val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY) - showMessageAndCancelDialog(R.string.login_failed_2fa_needed) + showMessageAndCancelDialog(getString(if (lastLoginResult is LoginResult.EmailAuthResult) R.string.login_failed_email_auth_needed else R.string.login_failed_2fa_needed)) } @VisibleForTesting diff --git a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt index f35e5f003..6353e54ac 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/csrf/CsrfTokenClient.kt @@ -32,7 +32,7 @@ class CsrfTokenClient( try { if (retry > 0) { // Log in explicitly - loginClient.loginBlocking(userName, password, "") + loginClient.loginBlocking(userName, password) } // Get CSRFToken response off the main thread. @@ -92,6 +92,8 @@ class CsrfTokenClient( override fun failure(caught: Throwable?) = retryWithLogin(cb) { caught } override fun twoFactorPrompt() = cb.twoFactorPrompt() + + override fun emailAuthPrompt() = cb.emailAuthPrompt() }, ) @@ -165,10 +167,17 @@ class CsrfTokenClient( } override fun twoFactorPrompt( + loginResult: LoginResult, caught: Throwable, token: String?, ) = callback.twoFactorPrompt() + override fun emailAuthPrompt( + loginResult: LoginResult, + caught: Throwable, + token: String?, + ) = callback.emailAuthPrompt() + // Should not happen here, but call the callback just in case. override fun passwordResetPrompt(token: String?) = callback.failure(LoginFailedException("Logged in with temporary password.")) @@ -190,6 +199,8 @@ class CsrfTokenClient( fun failure(caught: Throwable?) fun twoFactorPrompt() + + fun emailAuthPrompt() } companion object { diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt index 8092f73ae..8aa3d17a0 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginCallback.kt @@ -4,6 +4,13 @@ interface LoginCallback { fun success(loginResult: LoginResult) fun twoFactorPrompt( + loginResult: LoginResult, + caught: Throwable, + token: String?, + ) + + fun emailAuthPrompt( + loginResult: LoginResult, caught: Throwable, token: String?, ) 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 2a799c847..a653b8b55 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 @@ -1,6 +1,7 @@ package fr.free.nrw.commons.auth.login import android.text.TextUtils +import fr.free.nrw.commons.auth.login.LoginResult.EmailAuthResult import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult import fr.free.nrw.commons.wikidata.WikidataConstants.WIKIPEDIA_URL @@ -51,6 +52,7 @@ class LoginClient( password, null, null, + null, response.body()!!.query()!!.loginToken(), userLanguage, cb, @@ -75,6 +77,7 @@ class LoginClient( password: String, retypedPassword: String?, twoFactorCode: String?, + emailAuthCode: String?, loginToken: String?, userLanguage: String, cb: LoginCallback, @@ -82,7 +85,7 @@ class LoginClient( this.userLanguage = userLanguage loginCall = - if (twoFactorCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) { + if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) { loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) } else { loginInterface.postLogIn( @@ -90,6 +93,7 @@ class LoginClient( password, retypedPassword, twoFactorCode, + emailAuthCode, loginToken, userLanguage, true, @@ -112,10 +116,18 @@ class LoginClient( when (loginResult) { is OAuthResult -> cb.twoFactorPrompt( + loginResult, LoginFailedException(loginResult.message), loginToken, ) + is EmailAuthResult -> + cb.emailAuthPrompt( + loginResult, + LoginFailedException(loginResult.message), + loginToken + ) + is ResetPasswordResult -> cb.passwordResetPrompt(loginToken) is LoginResult.Result -> @@ -147,6 +159,7 @@ class LoginClient( fun doLogin( username: String, password: String, + lastLoginResult: LoginResult?, twoFactorCode: String, userLanguage: String, loginCallback: LoginCallback, @@ -159,7 +172,10 @@ class LoginClient( ) = if (response.isSuccessful) { val loginToken = response.body()?.query()?.loginToken() loginToken?.let { - login(username, password, null, twoFactorCode, it, userLanguage, loginCallback) + login(username, password, null, + if (lastLoginResult is OAuthResult) twoFactorCode else null, + if (lastLoginResult is EmailAuthResult) twoFactorCode else null, + it, userLanguage, loginCallback) } ?: run { loginCallback.error(IOException("Failed to retrieve login token")) } @@ -181,7 +197,8 @@ class LoginClient( fun loginBlocking( userName: String, password: String, - twoFactorCode: String?, + twoFactorCode: String? = null, + emailAuthCode: String? = null ) { val tokenResponse = getLoginToken().execute() if (tokenResponse @@ -195,7 +212,7 @@ class LoginClient( val loginToken = tokenResponse.body()?.query()?.loginToken() val tempLoginCall = - if (twoFactorCode.isNullOrEmpty()) { + if (twoFactorCode.isNullOrEmpty() && emailAuthCode.isNullOrEmpty()) { loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) } else { loginInterface.postLogIn( @@ -203,6 +220,7 @@ class LoginClient( password, null, twoFactorCode, + emailAuthCode, loginToken, userLanguage, true, @@ -214,7 +232,7 @@ class LoginClient( val loginResult = loginResponse.toLoginResult(password) ?: throw IOException("Unexpected response when logging in.") if ("UI" == loginResult.status) { - if (loginResult is OAuthResult) { + if (loginResult is OAuthResult || loginResult is EmailAuthResult) { // TODO: Find a better way to boil up the warning about 2FA throw LoginFailedException(loginResult.message) } diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt index 07e1cd45c..39cbf7c9f 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginInterface.kt @@ -35,7 +35,8 @@ interface LoginInterface { @Field("password") pass: String?, @Field("retype") retypedPass: String?, @Field("OATHToken") twoFactorCode: String?, - @Field("logintoken") token: String?, + @Field("token") emailAuthToken: String?, + @Field("logintoken") loginToken: String?, @Field("uselang") userLanguage: String?, @Field("logincontinue") loginContinue: Boolean, ): Call diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt index a96778e38..0fb035eea 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResponse.kt @@ -2,6 +2,7 @@ package fr.free.nrw.commons.auth.login import com.google.gson.annotations.SerializedName import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult +import fr.free.nrw.commons.auth.login.LoginResult.EmailAuthResult import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult import fr.free.nrw.commons.auth.login.LoginResult.Result import fr.free.nrw.commons.wikidata.mwapi.MwServiceError @@ -27,11 +28,13 @@ internal class ClientLogin { fun toLoginResult(password: String): LoginResult { var userMessage = message if ("UI" == status) { - if (requests != null) { - for (req in requests) { - if ("MediaWiki\\Extension\\OATHAuth\\Auth\\TOTPAuthenticationRequest" == req.id()) { + requests?.forEach { request -> + request.id()?.let { + if (it.endsWith("TOTPAuthenticationRequest")) { return OAuthResult(status, userName, password, message) - } else if ("MediaWiki\\Auth\\PasswordAuthenticationRequest" == req.id()) { + } else if (it.endsWith("EmailAuthAuthenticationRequest")) { + return EmailAuthResult(status, userName, password, message) + } else if (it.endsWith("PasswordAuthenticationRequest")) { return ResetPasswordResult(status, userName, password, message) } } @@ -49,7 +52,7 @@ internal class Request { private val required: String? = null private val provider: String? = null private val account: String? = null - private val fields: Map? = null + internal val fields: Map? = null fun id(): String? = id } @@ -57,5 +60,5 @@ internal class Request { internal class RequestField { private val type: String? = null private val label: String? = null - private val help: String? = null + internal val help: String? = null } diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt index 6a7594ec0..99abaeeec 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginResult.kt @@ -24,6 +24,13 @@ sealed class LoginResult( message: String?, ) : LoginResult(status, userName, password, message) + class EmailAuthResult( + status: String, + userName: String?, + password: String?, + message: String?, + ) : LoginResult(status, userName, password, message) + class ResetPasswordResult( status: String, userName: String?, diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt index 4ab21462c..f10e02ebc 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt @@ -1,7 +1,6 @@ package fr.free.nrw.commons.bookmarks.locations import android.Manifest.permission -import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -9,15 +8,12 @@ import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import dagger.android.support.DaggerFragment import fr.free.nrw.commons.R import fr.free.nrw.commons.contributions.ContributionController import fr.free.nrw.commons.databinding.FragmentBookmarksLocationsBinding -import fr.free.nrw.commons.filepicker.FilePicker import fr.free.nrw.commons.nearby.Place import fr.free.nrw.commons.nearby.fragments.CommonPlaceClickActions import fr.free.nrw.commons.nearby.fragments.PlaceAdapter @@ -41,33 +37,27 @@ class BookmarkLocationsFragment : DaggerFragment() { private val cameraPickLauncherForResult = registerForActivityResult(StartActivityForResult()) { result -> contributionController.handleActivityResultWithCallback( - requireActivity(), - object: FilePicker.HandleActivityResult { - override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) { - contributionController.onPictureReturnedFromCamera( - result, - requireActivity(), - callbacks - ) - } - } - ) + requireActivity() + ) { callbacks -> + contributionController.onPictureReturnedFromCamera( + result, + requireActivity(), + callbacks + ) + } } private val galleryPickLauncherForResult = registerForActivityResult(StartActivityForResult()) { result -> contributionController.handleActivityResultWithCallback( - requireActivity(), - object: FilePicker.HandleActivityResult { - override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) { - contributionController.onPictureReturnedFromGallery( - result, - requireActivity(), - callbacks - ) - } - } - ) + requireActivity() + ) { callbacks -> + contributionController.onPictureReturnedFromGallery( + result, + requireActivity(), + callbacks + ) + } } companion object { diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.kt index 32028cfd2..899ef458f 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.kt @@ -8,23 +8,29 @@ import androidx.appcompat.app.AlertDialog import androidx.recyclerview.widget.RecyclerView import com.facebook.imagepipeline.request.ImageRequest import com.facebook.imagepipeline.request.ImageRequestBuilder +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.utils.MediaAttributionUtil +import fr.free.nrw.commons.MediaDataExtractor import fr.free.nrw.commons.R import fr.free.nrw.commons.databinding.LayoutContributionBinding import fr.free.nrw.commons.media.MediaClient import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.CompositeDisposable import io.reactivex.schedulers.Schedulers +import timber.log.Timber import java.io.File class ContributionViewHolder internal constructor( - private val parent: View, private val callback: ContributionsListAdapter.Callback, - private val mediaClient: MediaClient + parent: View, + private val callback: ContributionsListAdapter.Callback, + private val compositeDisposable: CompositeDisposable, + private val mediaClient: MediaClient, + private val mediaDataExtractor: MediaDataExtractor ) : RecyclerView.ViewHolder(parent) { var binding: LayoutContributionBinding = LayoutContributionBinding.bind(parent) private var position = 0 private var contribution: Contribution? = null - private val compositeDisposable = CompositeDisposable() private var isWikipediaButtonDisplayed = false private val pausingPopUp: AlertDialog var imageRequest: ImageRequest? = null @@ -54,7 +60,7 @@ an upload might take a dozen seconds. */ this.contribution = contribution this.position = position binding.contributionTitle.text = contribution.media.mostRelevantCaption - binding.authorView.text = contribution.media.getAuthorOrUser() + setAuthorText(contribution.media) //Removes flicker of loading image. binding.contributionImage.hierarchy.fadeDuration = 0 @@ -93,6 +99,30 @@ an upload might take a dozen seconds. */ checkIfMediaExistsOnWikipediaPage(contribution) } + fun updateAttribution() { + if (contribution != null) { + val media = contribution!!.media + if (!media.getAttributedAuthor().isNullOrEmpty()) { + return + } + compositeDisposable.addAll( + mediaDataExtractor.fetchCreatorIdsAndLabels(media) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { idAndLabels -> + media.creatorName = MediaAttributionUtil.getCreatorName(idAndLabels) + setAuthorText(media) + }, + { t: Throwable? -> Timber.e(t) }) + ) + } + } + + private fun setAuthorText(media: Media) { + binding.authorView.text = MediaAttributionUtil.getTagLine(media, itemView.context) + } + /** * Checks if a media exists on the corresponding Wikipedia article Currently the check is made * for the device's current language Wikipedia diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.kt index b41de1c6e..e5f721721 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.kt @@ -4,21 +4,26 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil +import fr.free.nrw.commons.MediaDataExtractor import fr.free.nrw.commons.R import fr.free.nrw.commons.media.MediaClient +import io.reactivex.disposables.CompositeDisposable /** * Represents The View Adapter for the List of Contributions */ class ContributionsListAdapter internal constructor( private val callback: Callback, - private val mediaClient: MediaClient + private val mediaClient: MediaClient, + private val mediaDataExtractor: MediaDataExtractor, + private val compositeDisposable: CompositeDisposable ) : PagedListAdapter(DIFF_CALLBACK) { /** * Initializes the view holder with contribution data */ override fun onBindViewHolder(holder: ContributionViewHolder, position: Int) { holder.init(position, getItem(position)) + holder.updateAttribution() } fun getContributionForPosition(position: Int): Contribution? { @@ -36,7 +41,7 @@ class ContributionsListAdapter internal constructor( val viewHolder = ContributionViewHolder( LayoutInflater.from(parent.context) .inflate(R.layout.layout_contribution, parent, false), - callback, mediaClient + callback, compositeDisposable, mediaClient, mediaDataExtractor ) return viewHolder } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.kt index bfe1161c7..9ecb35b24 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.kt @@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener import androidx.recyclerview.widget.SimpleItemAnimator 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.auth.SessionManager @@ -63,6 +64,10 @@ class ContributionsListFragment : CommonsDaggerSupportFragment(), ContributionsL @Inject var mediaClient: MediaClient? = null + @JvmField + @Inject + var mediaDataExtractor: MediaDataExtractor? = null + @JvmField @Named(NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) @Inject @@ -231,7 +236,7 @@ class ContributionsListFragment : CommonsDaggerSupportFragment(), ContributionsL } private fun initAdapter() { - adapter = ContributionsListAdapter(this, mediaClient!!) + adapter = ContributionsListAdapter(this, mediaClient!!, mediaDataExtractor!!, compositeDisposable) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt index a61567393..a83532bdb 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt @@ -387,35 +387,40 @@ after opening the app. } override fun onBackPressed() { - if (contributionsFragment != null && activeFragment == ActiveFragment.CONTRIBUTIONS) { + when (activeFragment) { + ActiveFragment.CONTRIBUTIONS -> { // Means that contribution fragment is visible - if (!contributionsFragment!!.backButtonClicked()) { //If this one does not wan't to handle + if (contributionsFragment?.backButtonClicked() != true) { //If this one does not want to handle // the back press, let the activity do so super.onBackPressed() + } } - } else if (nearbyParentFragment != null && activeFragment == ActiveFragment.NEARBY) { + ActiveFragment.NEARBY -> { // Means that nearby fragment is visible - /* If function nearbyParentFragment.backButtonClick() returns false, it means that the bottomsheet is - not expanded. So if the back button is pressed, then go back to the Contributions tab */ - if (!nearbyParentFragment!!.backButtonClicked()) { - supportFragmentManager.beginTransaction().remove(nearbyParentFragment!!) - .commit() + if (nearbyParentFragment?.backButtonClicked() != true) { + nearbyParentFragment?.let { + supportFragmentManager.beginTransaction().remove(it).commit() + } setSelectedItemId(NavTab.CONTRIBUTIONS.code()) + } } - } else if (exploreFragment != null && activeFragment == ActiveFragment.EXPLORE) { - // Means that explore fragment is visible - if (!exploreFragment!!.onBackPressed()) { - if (applicationKvStore!!.getBoolean("login_skipped")) { + ActiveFragment.EXPLORE -> { + // Explore Fragment is visible + if (exploreFragment?.onBackPressed() != true) { + if (applicationKvStore?.getBoolean("login_skipped") == true) { super.onBackPressed() } else { setSelectedItemId(NavTab.CONTRIBUTIONS.code()) + } } } - } else if (bookmarkFragment != null && activeFragment == ActiveFragment.BOOKMARK) { + ActiveFragment.BOOKMARK -> { // Means that bookmark fragment is visible - bookmarkFragment!!.onBackPressed() - } else { + bookmarkFragment?.onBackPressed() + } + else -> { super.onBackPressed() + } } } diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt index ff623d496..20a2fe70a 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.TreeMap import kotlin.collections.ArrayList @@ -342,45 +343,36 @@ class ImageAdapter( numberOfSelectedImagesMarkedAsNotForUpload-- } notifyItemChanged(position, ImageUnselected()) - - // Getting index from all images index when switch is on - val indexes = - if (showAlreadyActionedImages) { - ImageHelper.getIndexList(selectedImages, images) - - // Getting index from actionable images when switch is off - } else { - ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) - } - for (index in indexes) { - notifyItemChanged(index, ImageSelectedOrUpdated()) - } } else { - if (holder.isItemUploaded()) { - Toast.makeText(context, R.string.custom_selector_already_uploaded_image_text, Toast.LENGTH_SHORT).show() - } else { - if (holder.isItemNotForUpload()) { - numberOfSelectedImagesMarkedAsNotForUpload++ - } - - // Getting index from all images index when switch is on - val indexes: ArrayList = - if (showAlreadyActionedImages) { - selectedImages.add(images[position]) - ImageHelper.getIndexList(selectedImages, images) - - // Getting index from actionable images when switch is off - } else { - selectedImages.add(ArrayList(actionableImagesMap.values)[position]) - ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) + val image = images[position] + scope.launch(ioDispatcher) { + val imageSHA1 = imageLoader.getSHA1(image, defaultDispatcher) + withContext(Dispatchers.Main) { + if (holder.isItemUploaded()) { + Toast.makeText(context, R.string.custom_selector_already_uploaded_image_text, Toast.LENGTH_SHORT).show() + return@withContext } - for (index in indexes) { - notifyItemChanged(index, ImageSelectedOrUpdated()) + if (imageSHA1.isNotEmpty() && imageLoader.getFromUploaded(imageSHA1) != null) { + holder.itemUploaded() + Toast.makeText(context, R.string.custom_selector_already_uploaded_image_text, Toast.LENGTH_SHORT).show() + return@withContext + } + + if (!holder.isItemUploaded() && imageSHA1.isNotEmpty() && imageLoader.getFromUploaded(imageSHA1) != null) { + Toast.makeText(context, R.string.custom_selector_already_uploaded_image_text, Toast.LENGTH_SHORT).show() + } + + if (holder.isItemNotForUpload()) { + numberOfSelectedImagesMarkedAsNotForUpload++ + } + selectedImages.add(image) + notifyItemChanged(position, ImageSelectedOrUpdated()) + + imageSelectListener.onSelectedImagesChanged(selectedImages, numberOfSelectedImagesMarkedAsNotForUpload) } } } - imageSelectListener.onSelectedImagesChanged(selectedImages, numberOfSelectedImagesMarkedAsNotForUpload) } /** diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index 4e2d58bab..6b78dfd41 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -638,17 +638,20 @@ class CustomSelectorActivity : finishPickImages(arrayListOf()) return } - var i = 0 - while (i < selectedImages.size) { - val path = selectedImages[i].path - val file = File(path) - if (!file.exists()) { - selectedImages.removeAt(i) - i-- + scope.launch(ioDispatcher) { + val uniqueImages = selectedImages.distinctBy { image -> + CustomSelectorUtils.getImageSHA1( + image.uri, + ioDispatcher, + fileUtilsWrapper, + contentResolver + ) + } + + withContext(Dispatchers.Main) { + finishPickImages(ArrayList(uniqueImages)) } - i++ } - finishPickImages(selectedImages) } /** 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 0e9d83478..8a074f67a 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 @@ -25,6 +25,7 @@ import fr.free.nrw.commons.media.PageMediaInterface import fr.free.nrw.commons.media.WikidataMediaInterface import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient import fr.free.nrw.commons.mwapi.UserInterface +import fr.free.nrw.commons.network.APIService import fr.free.nrw.commons.notification.NotificationInterface import fr.free.nrw.commons.review.ReviewInterface import fr.free.nrw.commons.upload.UploadInterface @@ -42,6 +43,8 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor.Level +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory import timber.log.Timber import java.io.File import java.util.concurrent.TimeUnit @@ -295,6 +298,32 @@ class NetworkingModule { fun provideLanguageWikipediaSite(): WikiSite = WikiSite.forDefaultLocaleLanguageCode() + @Provides + @Named("tool_wmflabs_base_url") + fun provideToolWmflabsBaseUrl() : HttpUrl = + "https://tools.wmflabs.org/commons-android-app/tool-commons-android-app/".toHttpUrlOrNull()!! + + @Singleton + @Provides + @Named("tool_wmflabs_retrofit") + fun provideToolWmflabsBaseUrlRetrofit( + @Named("tool_wmflabs_base_url") baseUrl: HttpUrl, + okHttpClient: OkHttpClient + ) : Retrofit { + return Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) +// .addCallAdapterFactory(CoroutineCallAdapterFactory()) + .build() + } + + @Provides + @Singleton + fun provideAPIService( + @Named("tool_wmflabs_retrofit") retrofit: Retrofit + ): APIService = retrofit.create(APIService::class.java) + companion object { private const val WIKIDATA_SPARQL_QUERY_URL = "https://query.wikidata.org/sparql" private const val TOOLS_FORGE_URL = diff --git a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java index 1b1659182..a222a98ac 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java @@ -708,8 +708,29 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment GeoPoint point = new GeoPoint( nearbyBaseMarker.getPlace().location.getLatitude(), nearbyBaseMarker.getPlace().location.getLongitude()); - OverlayItem item = new OverlayItem(nearbyBaseMarker.getPlace().name, null, - point); + + Media markerMedia = this.getMediaFromImageURL(nearbyBaseMarker.getPlace().pic); + String authorUser = null; + if (markerMedia != null) { + authorUser = markerMedia.getAuthorOrUser(); + // HTML text is sometimes part of the author string and needs to be removed + authorUser = Html.fromHtml(authorUser, Html.FROM_HTML_MODE_LEGACY).toString(); + } + + String title = nearbyBaseMarker.getPlace().name; + // Remove "File:" if present at start + if (title.startsWith("File:")) { + title = title.substring(5); + } + // Remove extensions like .jpg, .jpeg, .png, .svg (case insensitive) + title = title.replaceAll("(?i)\\.(jpg|jpeg|png|svg)$", ""); + title = title.replace("_", " "); + //Truncate if too long because it doesn't fit the screen + if (title.length() > 43) { + title = title.substring(0, 40) + "…"; + } + + OverlayItem item = new OverlayItem(title, authorUser, point); item.setMarker(d); items.add(item); ItemizedOverlayWithFocus overlay = new ItemizedOverlayWithFocus(items, @@ -740,13 +761,37 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment } } + /** + * Retrieves the specific Media object from the mediaList field. + * @param url The specific Media's image URL. + * @return The Media object that matches the URL or null if it could not be found. + */ + private Media getMediaFromImageURL(String url) { + if (mediaList == null || url == null) { + return null; + } + + for (int i = 0; i < mediaList.size(); i++) { + if (mediaList.get(i) != null && mediaList.get(i).getImageUrl() != null + && mediaList.get(i).getImageUrl().equals(url)) { + return mediaList.get(i); + } + } + + return null; + } + /** * Removes a marker from the map based on the specified NearbyBaseMarker. * * @param nearbyBaseMarker The NearbyBaseMarker object representing the marker to be removed. */ private void removeMarker(BaseMarker nearbyBaseMarker) { - Place place = nearbyBaseMarker.getPlace(); + if (nearbyBaseMarker == null || nearbyBaseMarker.getPlace().getName() == null) { + return; + } + + String target = nearbyBaseMarker.getPlace().getName(); List overlays = binding.mapView.getOverlays(); ItemizedOverlayWithFocus item; @@ -755,8 +800,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment item = (ItemizedOverlayWithFocus) overlays.get(i); OverlayItem overlayItem = item.getItem(0); - if (place.location.getLatitude() == overlayItem.getPoint().getLatitude() - && place.location.getLongitude() == overlayItem.getPoint().getLongitude()) { + if (overlayItem.getTitle().equals(target)) { binding.mapView.getOverlays().remove(i); binding.mapView.invalidate(); break; 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 0cfb270a3..fe5c21a7e 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 @@ -18,6 +18,12 @@ import javax.inject.Inject class MediaConverter @Inject constructor() { + /** + * Creating Media object from MWQueryPage. + * + * @param page response from the API + * @return Media object + */ fun convert( page: MwQueryPage, entity: Entities.Entity, @@ -40,24 +46,17 @@ class MediaConverter metadata.prefixedLicenseUrl, getAuthor(metadata), imageInfo.getUser(), + null, MediaDataExtractorUtil.extractCategoriesFromList(metadata.categories()), metadata.latLng, entity.labels().mapValues { it.value.value() }, entity.descriptions().mapValues { it.value.value() }, entity.depictionIds(), + entity.creatorIds(), myMap, ) } - /** - * Creating Media object from MWQueryPage. - * Earlier only basic details were set for the media object but going forward, - * a full media object(with categories, descriptions, coordinates etc) can be constructed using this method - * - * @param page response from the API - * @return Media object - */ - private fun safeParseDate(dateStr: String): Date? = try { CommonsDateUtil.getMediaSimpleDateFormat().parse(dateStr) @@ -66,24 +65,32 @@ class MediaConverter } /** - * This method extracts the Commons Username from the artist HTML information + * This method extracts the Commons Username from the artist HTML information. + * When the HTML is in customized formatting, it may fail to parse and return null. * @param metadata * @return */ private fun getAuthor(metadata: ExtMetadata): String? { - return try { - val authorHtml = metadata.artist() - val anchorStartTagTerminalChars = "\">" - val anchorCloseTag = "" + val authorHtml = metadata.artist() + val anchorStartTagTerminalString = "\">" + val anchorCloseTag = "" - return authorHtml.substring( - authorHtml.indexOf(anchorStartTagTerminalChars) + - anchorStartTagTerminalChars - .length, + return if (!authorHtml.contains("<") && !authorHtml.contains(">") ) { + authorHtml.trim() + } else if (!authorHtml.contains(anchorStartTagTerminalString) || !authorHtml.endsWith(anchorCloseTag)) { + null + } else { + + val authorText = authorHtml.substring( + authorHtml.indexOf(anchorStartTagTerminalString) + + anchorStartTagTerminalString.length, authorHtml.indexOf(anchorCloseTag), ) - } catch (ex: java.lang.Exception) { - "" + if (authorText.contains("<") || authorText.contains(">")) { + null + } else { + authorText + } } } } @@ -92,6 +99,10 @@ private fun Entities.Entity.depictionIds() = this[WikidataProperties.DEPICTS]?.mapNotNull { (it.mainSnak.dataValue as? DataValue.EntityId)?.value?.id } ?: emptyList() +private fun Entities.Entity.creatorIds() = + this[WikidataProperties.CREATOR]?.mapNotNull { (it.mainSnak.dataValue as? DataValue.EntityId)?.value?.id } + ?: emptyList() + private val ExtMetadata.prefixedLicenseUrl: String get() = licenseUrl().let { diff --git a/app/src/main/java/fr/free/nrw/commons/explore/media/PageableMediaFragment.kt b/app/src/main/java/fr/free/nrw/commons/explore/media/PageableMediaFragment.kt index 987f4ca00..e19b1b056 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/media/PageableMediaFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/media/PageableMediaFragment.kt @@ -4,16 +4,18 @@ import android.content.Context import android.os.Bundle import android.view.View import fr.free.nrw.commons.Media +import fr.free.nrw.commons.MediaDataExtractor import fr.free.nrw.commons.R import fr.free.nrw.commons.category.CategoryImagesCallback import fr.free.nrw.commons.explore.paging.BasePagingFragment import fr.free.nrw.commons.media.MediaDetailPagerFragment.MediaDetailProvider +import javax.inject.Inject abstract class PageableMediaFragment : BasePagingFragment(), MediaDetailProvider { override val pagedListAdapter by lazy { - PagedMediaAdapter(categoryImagesCallback::onMediaClicked) + PagedMediaAdapter(categoryImagesCallback::onMediaClicked, mediaDataExtractor) } override val errorTextId: Int = R.string.error_loading_images @@ -22,6 +24,9 @@ abstract class PageableMediaFragment : lateinit var categoryImagesCallback: CategoryImagesCallback + @Inject + lateinit var mediaDataExtractor: MediaDataExtractor + override fun onAttach(context: Context) { super.onAttach(context) if (parentFragment != null) { diff --git a/app/src/main/java/fr/free/nrw/commons/explore/media/PagedMediaAdapter.kt b/app/src/main/java/fr/free/nrw/commons/explore/media/PagedMediaAdapter.kt index 364b5d363..521ba77c6 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/media/PagedMediaAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/media/PagedMediaAdapter.kt @@ -5,13 +5,22 @@ import android.view.ViewGroup import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import fr.free.nrw.commons.Media +import fr.free.nrw.commons.MediaDataExtractor +import fr.free.nrw.commons.utils.MediaAttributionUtil import fr.free.nrw.commons.R import fr.free.nrw.commons.databinding.LayoutCategoryImagesBinding import fr.free.nrw.commons.explore.paging.BaseViewHolder import fr.free.nrw.commons.explore.paging.inflate +import fr.free.nrw.commons.media.IdAndLabels +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.schedulers.Schedulers +import timber.log.Timber class PagedMediaAdapter( private val onImageClicked: (Int) -> Unit, + private val mediaDataExtractor: MediaDataExtractor, + private val compositeDisposable: CompositeDisposable = CompositeDisposable() ) : PagedListAdapter( object : DiffUtil.ItemCallback() { override fun areItemsTheSame( @@ -25,6 +34,7 @@ class PagedMediaAdapter( ) = oldItem.pageId == newItem.pageId }, ) { + override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, @@ -37,7 +47,24 @@ class PagedMediaAdapter( holder: SearchImagesViewHolder, position: Int, ) { - holder.bind(getItem(position)!! to position) + val media = getItem(position) ?: return + holder.bind(media to position) + + if (!media.getAttributedAuthor().isNullOrEmpty()) { + return + } + + compositeDisposable.addAll( + mediaDataExtractor.fetchCreatorIdsAndLabels(media) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { idAndLabels -> + media.creatorName = MediaAttributionUtil.getCreatorName(idAndLabels); + holder.setAuthorText(media) + }, + { t: Throwable? -> Timber.e(t) }) + ) } } @@ -52,7 +79,10 @@ class SearchImagesViewHolder( binding.categoryImageView.setOnClickListener { onImageClicked(item.second) } binding.categoryImageTitle.text = media.mostRelevantCaption binding.categoryImageView.setImageURI(media.thumbUrl) - binding.categoryImageAuthor.text = - containerView.context.getString(R.string.image_uploaded_by, media.getAuthorOrUser()) + setAuthorText(media) + } + + fun setAuthorText(media: Media) { + binding.categoryImageAuthor.text = MediaAttributionUtil.getTagLine(media, containerView.context) } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/paging/FooterAdapter.kt b/app/src/main/java/fr/free/nrw/commons/explore/paging/FooterAdapter.kt index fc5f529b2..d739b35ee 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/paging/FooterAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/paging/FooterAdapter.kt @@ -32,7 +32,7 @@ class FooterAdapter( override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, - ) = when (FooterItem.values()[viewType]) { + ) = when (FooterItem.entries[viewType]) { FooterItem.LoadingItem -> LoadingViewHolder( parent.inflate(R.layout.list_item_progress), diff --git a/app/src/main/java/fr/free/nrw/commons/media/IdAndCaptions.kt b/app/src/main/java/fr/free/nrw/commons/media/IdAndCaptions.kt deleted file mode 100644 index fe96eb8cb..000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/IdAndCaptions.kt +++ /dev/null @@ -1,6 +0,0 @@ -package fr.free.nrw.commons.media - -data class IdAndCaptions( - val id: String, - val captions: Map, -) diff --git a/app/src/main/java/fr/free/nrw/commons/media/IdAndLabels.kt b/app/src/main/java/fr/free/nrw/commons/media/IdAndLabels.kt new file mode 100644 index 000000000..c989ee7e3 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/IdAndLabels.kt @@ -0,0 +1,18 @@ +package fr.free.nrw.commons.media + +data class IdAndLabels( + val id: String, + val labels: Map, +) { + // if a label is available in user's locale, return it + // if not then check for english, else show any available. + fun getLocalizedLabel(locale: String): String? { + if (labels[locale] != null) { + return labels[locale] + } + if (labels["en"] != null) { + return labels["en"] + } + return labels.values.firstOrNull() ?: id + } +} \ No newline at end of file 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 index 77ff1df0c..8a4d530c4 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt @@ -16,7 +16,6 @@ import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.ViewTreeObserver import android.view.ViewTreeObserver.OnGlobalLayoutListener import android.widget.ArrayAdapter import android.widget.Button @@ -622,10 +621,9 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( - { idAndCaptions: List -> onDepictionsLoaded(idAndCaptions) }, + { idAndCaptions: List -> onDepictionsLoaded(idAndCaptions) }, { t: Throwable? -> Timber.e(t) }) ) - // compositeDisposable.add(disposable); } private fun onDiscussionLoaded(discussion: String) { @@ -655,7 +653,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } } - private fun onDepictionsLoaded(idAndCaptions: List) { + private fun onDepictionsLoaded(idAndCaptions: List) { binding.depictsLayout.visibility = View.VISIBLE binding.depictionsEditButton.visibility = View.VISIBLE buildDepictionList(idAndCaptions) @@ -865,24 +863,24 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C * Populates media details fragment with depiction list * @param idAndCaptions */ - private fun buildDepictionList(idAndCaptions: List) { + private fun buildDepictionList(idAndCaptions: List) { binding.mediaDetailDepictionContainer.removeAllViews() // Create a mutable list from the original list val mutableIdAndCaptions = idAndCaptions.toMutableList() if (mutableIdAndCaptions.isEmpty()) { - // Create a placeholder IdAndCaptions object and add it to the list + // Create a placeholder IdAndLabels object and add it to the list mutableIdAndCaptions.add( - IdAndCaptions( + IdAndLabels( id = media?.pageId ?: "", // Use an empty string if media?.pageId is null - captions = mapOf(Locale.getDefault().language to getString(R.string.detail_panel_cats_none)) // Create a Map with the language as the key and the message as the value + labels = mapOf(Locale.getDefault().language to getString(R.string.detail_panel_cats_none)) // Create a Map with the language as the key and the message as the value ) ) } val locale: String = Locale.getDefault().language - for (idAndCaption: IdAndCaptions in mutableIdAndCaptions) { + for (idAndCaption in mutableIdAndCaptions) { binding.mediaDetailDepictionContainer.addView( buildDepictLabel( getDepictionCaption(idAndCaption, locale), @@ -894,16 +892,16 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } - private fun getDepictionCaption(idAndCaption: IdAndCaptions, locale: String): String? { + private fun getDepictionCaption(idAndCaption: IdAndLabels, 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.labels[locale] != null) { + return idAndCaption.labels[locale] } - if (idAndCaption.captions["en"] != null) { - return idAndCaption.captions["en"] + if (idAndCaption.labels["en"] != null) { + return idAndCaption.labels["en"] } - return idAndCaption.captions.values.iterator().next() + return idAndCaption.labels.values.iterator().next() } private fun onMediaDetailLicenceClicked() { diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/Place.java b/app/src/main/java/fr/free/nrw/commons/nearby/Place.java index 3b3b798eb..cff2ed4de 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/Place.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/Place.java @@ -153,7 +153,10 @@ public class Place implements Parcelable { .build(), item.getPic().getValue(), // Checking if the place exists or not - (item.getDestroyed().getValue() == "") && (item.getEndTime().getValue() == ""), entityId); + (item.getDestroyed().getValue() == "") && (item.getEndTime().getValue() == "") + && (item.getDateOfOfficialClosure().getValue() == "") + && (item.getPointInTime().getValue()==""), + entityId); } /** diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/WikidataFeedback.kt b/app/src/main/java/fr/free/nrw/commons/nearby/WikidataFeedback.kt index e5196bee8..a1bad1f26 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/WikidataFeedback.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/WikidataFeedback.kt @@ -103,4 +103,4 @@ class WikidataFeedback : BaseActivity() { onBackPressed() return true } -} +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/model/NearbyResultItem.kt b/app/src/main/java/fr/free/nrw/commons/nearby/model/NearbyResultItem.kt index f28cc833e..c39d8901d 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/model/NearbyResultItem.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/model/NearbyResultItem.kt @@ -18,6 +18,8 @@ class NearbyResultItem( @field:SerializedName("description") private val description: ResultTuple?, @field:SerializedName("endTime") private val endTime: ResultTuple?, @field:SerializedName("monument") private val monument: ResultTuple?, + @field:SerializedName("dateOfOfficialClosure") private val dateOfOfficialClosure: ResultTuple?, + @field:SerializedName("pointInTime") private val pointInTime: ResultTuple?, ) { fun getItem(): ResultTuple = item ?: ResultTuple() @@ -41,6 +43,8 @@ class NearbyResultItem( fun getDestroyed(): ResultTuple = destroyed ?: ResultTuple() + fun getDateOfOfficialClosure(): ResultTuple = dateOfOfficialClosure ?: ResultTuple() + fun getDescription(): ResultTuple = description ?: ResultTuple() fun getEndTime(): ResultTuple = endTime ?: ResultTuple() @@ -48,4 +52,7 @@ class NearbyResultItem( fun getAddress(): String = address?.value ?: "" fun getMonument(): ResultTuple? = monument + + fun getPointInTime(): ResultTuple = pointInTime ?: ResultTuple() + } diff --git a/app/src/main/java/fr/free/nrw/commons/network/APIService.kt b/app/src/main/java/fr/free/nrw/commons/network/APIService.kt new file mode 100644 index 000000000..ba66855fd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/network/APIService.kt @@ -0,0 +1,23 @@ +package fr.free.nrw.commons.network + +import fr.free.nrw.commons.profile.model.AchievementResponse +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Query + + +interface APIService { + + // https://tools.wmflabs.org/commons-android-app/tool-commons-android-app/uploadsbyuser.py?user=Devanonymous + @GET("uploadsbyuser.py") + suspend fun getImageUploadCount( + @Query("user") username : String + ) : Response + + + // https://tools.wmflabs.org/commons-android-app/tool-commons-android-app//feedback.py?user=Devanonymous + @GET("feedback.py") + suspend fun getUserAchievements( + @Query("user") username: String + ) : Response +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementViewModel.kt b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementViewModel.kt new file mode 100644 index 000000000..4d26fc2c4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementViewModel.kt @@ -0,0 +1,41 @@ +package fr.free.nrw.commons.profile.achievements + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import fr.free.nrw.commons.profile.model.UserAchievements +import fr.free.nrw.commons.repository.ProfileRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +class AchievementViewModel @Inject constructor( + private val repository: ProfileRepository +) : ViewModel() { + + private val _achievements = MutableStateFlow(UserAchievements( + LevelController.LevelInfo.LEVEL_1, + articlesUsingImagesCount = 0, + thanksReceivedCount = 0, + featuredImagesCount = 0, + qualityImagesCount = 0, + imagesUploadedCount = 0, + revertedCount = 0, + uniqueImagesCount = 0, + imagesEditedBySomeoneElseCount = 0 + ) + ) + val achievements : StateFlow = _achievements + + private val _loading = MutableStateFlow(true) + val loading : StateFlow = _loading + + fun getUserAchievements(username: String){ + viewModelScope.launch { + repository.getUserLevel(username = username).collect { + _loading.value = false + _achievements.value = it + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementViewModelFactory.kt b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementViewModelFactory.kt new file mode 100644 index 000000000..d9c936a09 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementViewModelFactory.kt @@ -0,0 +1,23 @@ +package fr.free.nrw.commons.profile.achievements + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import javax.inject.Inject +import javax.inject.Provider + +/** + * This class extends the ViewModelProvider.Factory and creates a ViewModelFactory class + * for AchievementViewModel + */ +class AchievementViewModelFactory @Inject constructor( + private val viewModelProvider: Provider +): ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(AchievementViewModel::class.java)) { + (@Suppress("UNCHECKED_CAST") + return viewModelProvider.get() as T) + } else { + throw IllegalArgumentException("Unknown class name") + } + } +} \ 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 index af07423eb..f0369799a 100644 --- 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 @@ -1,15 +1,16 @@ package fr.free.nrw.commons.profile.achievements +import android.annotation.SuppressLint import android.net.Uri import android.os.Bundle -import android.util.DisplayMetrics +import android.view.ContextThemeWrapper 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.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeUtils @@ -22,21 +23,20 @@ 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 kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import timber.log.Timber -import java.util.Objects import javax.inject.Inject class AchievementsFragment : CommonsDaggerSupportFragment(){ - private lateinit var levelInfo: LevelController.LevelInfo + @Inject + lateinit var viewModelFactory: AchievementViewModelFactory + lateinit var viewModel: AchievementViewModel @Inject lateinit var sessionManager: SessionManager @@ -45,11 +45,8 @@ class AchievementsFragment : CommonsDaggerSupportFragment(){ 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 { @@ -64,6 +61,8 @@ class AchievementsFragment : CommonsDaggerSupportFragment(){ ): View { _binding = FragmentAchievementsBinding.inflate(inflater, container, false) + viewModel = ViewModelProvider( + this@AchievementsFragment, viewModelFactory)[AchievementViewModel::class.java] binding.achievementInfo.setOnClickListener { showInfoDialog() } binding.imagesUploadInfoIcon.setOnClickListener { showUploadInfo() } binding.imagesRevertedInfoIcon.setOnClickListener { showRevertedInfo() } @@ -73,19 +72,15 @@ class AchievementsFragment : CommonsDaggerSupportFragment(){ 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 + lifecycleScope.launch { + viewModel.loading.collectLatest { + if (it){ + binding.progressBar.visibility = View.VISIBLE + } else { + binding.progressBar.visibility = View.GONE + } + } + } setHasOptionsMenu(true) if (sessionManager.userName == null || sessionManager.userName == userName) { @@ -100,8 +95,6 @@ class AchievementsFragment : CommonsDaggerSupportFragment(){ return binding.root } - - setWikidataEditCount() setAchievements() return binding.root @@ -145,74 +138,58 @@ class AchievementsFragment : CommonsDaggerSupportFragment(){ * which then calls parseJson when results are fetched */ + @SuppressLint("SetTextI18n") 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) - } - } + viewModel.getUserAchievements(username = userName.toString()) + + lifecycleScope.launch { + viewModel.achievements.collect{ + + binding.achievementLevel.text = getString(R.string.level,it.level.levelNumber) + val store = BasicKvStore(requireContext(), userName) + store.putString("userAchievementsLevel", it.level.levelNumber.toString()) + + binding.achievementBadgeImage.setImageDrawable( + VectorDrawableCompat.create( + resources, R.drawable.badge, + ContextThemeWrapper(activity, it.level.levelStyle).theme ) - ) - } catch (e: Exception) { - Timber.d("Exception: ${e.message}") + ) + binding.achievementBadgeText.text = it.level.levelNumber.toString() + + // TODO(use String Format) + binding.imageUploadedTVCount.text = + it.imagesUploadedCount.toString() + "/" + it.level.maxUploadCount + binding.imagesUploadedProgressbar.progress = + 100 * it.imagesUploadedCount / it.level.maxUploadCount + + // Revert + binding.imageRevertTVCount.text = it.revertedCount.toString() + "%" + binding.imageRevertsProgressbar.progress = it.revertedCount + binding.imagesRevertLimitText.text = + resources.getString(R.string.achievements_revert_limit_message) + it.level.minNonRevertPercentage + "%" + + // Images Used + binding.imagesUsedProgressbar.progress = (100 * it.uniqueImagesCount) / it.level.maxUniqueImages + binding.imagesUsedCount.text = (it.uniqueImagesCount.toString() + "/" + + it.level.maxUniqueImages) + + // Thanks Received Badge + showBadgesWithCount(view = binding.thanksImageIcon, count = it.thanksReceivedCount) + + // Featured Images Badge + showBadgesWithCount(view = binding.featuredImageIcon, count = it.featuredImagesCount) + + // Quality Images Badge + showBadgesWithCount(view = binding.qualityImageIcon, count = it.qualityImagesCount) + + showBadgesWithCount(view = binding.wikidataEditsIcon, count = it.imagesEditedBySomeoneElseCount) + } } } } - /** - * 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 @@ -253,49 +230,6 @@ class AchievementsFragment : CommonsDaggerSupportFragment(){ 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 @@ -306,9 +240,6 @@ class AchievementsFragment : CommonsDaggerSupportFragment(){ setZeroAchievements() } else { binding.imagesUploadedProgressbar.visibility = View.VISIBLE - binding.imagesUploadedProgressbar.progress = - 100 * uploadCount / levelInfo.maxUploadCount - binding.imageUploadedTVCount.text = uploadCount.toString() + "/" + levelInfo.maxUploadCount } } @@ -325,7 +256,7 @@ class AchievementsFragment : CommonsDaggerSupportFragment(){ getString(R.string.ok), {} ) - + binding.layout.visibility = View.INVISIBLE // binding.imagesUploadedProgressbar.setVisibility(View.INVISIBLE); // binding.imageRevertsProgressbar.setVisibility(View.INVISIBLE); // binding.imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE); @@ -335,52 +266,6 @@ class AchievementsFragment : CommonsDaggerSupportFragment(){ 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) @@ -425,22 +310,6 @@ class AchievementsFragment : CommonsDaggerSupportFragment(){ }) } - /** - * 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( @@ -546,9 +415,6 @@ class AchievementsFragment : CommonsDaggerSupportFragment(){ companion object{ - private const val BADGE_IMAGE_WIDTH_RATIO = 0.4 - private const val BADGE_IMAGE_HEIGHT_RATIO = 0.3 - /** * Help link URLs */ diff --git a/app/src/main/java/fr/free/nrw/commons/profile/model/AchievementResponse.kt b/app/src/main/java/fr/free/nrw/commons/profile/model/AchievementResponse.kt new file mode 100644 index 000000000..b44b7d16c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/model/AchievementResponse.kt @@ -0,0 +1,34 @@ +package fr.free.nrw.commons.profile.model + + +import com.google.gson.annotations.SerializedName + +data class AchievementResponse( + @SerializedName("articlesUsingImages") + val articlesUsingImages: Int, + @SerializedName("database") + val database: String, + @SerializedName("deletedUploads") + val deletedUploads: Int, + @SerializedName("featuredImages") + val featuredImages: FeaturedImages, + @SerializedName("imagesEditedBySomeoneElse") + val imagesEditedBySomeoneElse: Int, + @SerializedName("labs") + val labs: Boolean, + @SerializedName("status") + val status: String, + @SerializedName("thanksReceived") + val thanksReceived: Int, + @SerializedName("uniqueUsedImages") + val uniqueUsedImages: Int, + @SerializedName("user") + val user: String +) + +data class FeaturedImages( + @SerializedName("Featured_pictures_on_Wikimedia_Commons") + val featuredPicturesOnWikimediaCommons: Int, + @SerializedName("Quality_images") + val qualityImages: Int +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/model/UserAchievements.kt b/app/src/main/java/fr/free/nrw/commons/profile/model/UserAchievements.kt new file mode 100644 index 000000000..b13b4d382 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/model/UserAchievements.kt @@ -0,0 +1,15 @@ +package fr.free.nrw.commons.profile.model + +import fr.free.nrw.commons.profile.achievements.LevelController + +data class UserAchievements( + val level: LevelController.LevelInfo, + val articlesUsingImagesCount: Int = 0, + val thanksReceivedCount: Int = 0, + val featuredImagesCount: Int = 0, + val qualityImagesCount: Int = 0, + val imagesUploadedCount: Int = 0, + val revertedCount: Int = 0, + val uniqueImagesCount: Int = 0, + val imagesEditedBySomeoneElseCount: Int = 0 +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/repository/ProfileRepository.kt b/app/src/main/java/fr/free/nrw/commons/repository/ProfileRepository.kt new file mode 100644 index 000000000..a441464fa --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/repository/ProfileRepository.kt @@ -0,0 +1,53 @@ +package fr.free.nrw.commons.repository + +import fr.free.nrw.commons.network.APIService +import fr.free.nrw.commons.profile.achievements.LevelController +import fr.free.nrw.commons.profile.model.UserAchievements +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import timber.log.Timber +import javax.inject.Inject + +class ProfileRepository @Inject constructor(private val apiService: APIService) { + + fun getUserLevel(username: String) : Flow = flow { + try { + val uploadCountResponse = apiService.getImageUploadCount(username) + val imagesUploaded = uploadCountResponse.body() ?:0 + + val achievementResponse = apiService.getUserAchievements(username) + + val uniqueImages = achievementResponse.body()?.uniqueUsedImages ?: 0 + val articlesUsingImages = achievementResponse.body()?.articlesUsingImages ?: 0 + val thanksReceived = achievementResponse.body()?.thanksReceived ?: 0 + val featuredImages = achievementResponse.body()?.featuredImages?.featuredPicturesOnWikimediaCommons ?:0 + val qualityImages = achievementResponse.body()?.featuredImages?.qualityImages ?: 0 + val deletedUploads = achievementResponse.body()?.deletedUploads ?:0 + val revertCount = (imagesUploaded - deletedUploads) * 100 / imagesUploaded + val imagesEditedBySomeoneElse = achievementResponse.body()?.imagesEditedBySomeoneElse ?:0 + + val level = LevelController.LevelInfo.from( + imagesUploaded = imagesUploaded, + uniqueImagesUsed = uniqueImages, + nonRevertRate = revertCount) + + emit( + UserAchievements( + level = level, + articlesUsingImagesCount = articlesUsingImages, + featuredImagesCount = featuredImages, + imagesUploadedCount = imagesUploaded, + qualityImagesCount = qualityImages, + revertedCount = revertCount, + thanksReceivedCount = thanksReceived, + uniqueImagesCount = uniqueImages, + imagesEditedBySomeoneElseCount = imagesEditedBySomeoneElse + ) + ) + + } + catch(e : Exception) { + Timber.e(e.printStackTrace().toString()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt index 020284934..ee0b21210 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt @@ -14,6 +14,7 @@ import android.os.Bundle import android.provider.Settings import android.view.View import android.widget.CheckBox +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -122,7 +123,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C /** * Set the value of the showPermissionDialog variable. * - * @param showPermissionsDialog `true` to indicate to show + * @property isShowPermissionsDialog `true` to indicate to show * Permissions Dialog if permissions are missing, `false` otherwise. */ /** @@ -166,6 +167,8 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C private var _binding: ActivityUploadBinding? = null private val binding: ActivityUploadBinding get() = _binding!! + private lateinit var onBackPressedCallback: OnBackPressedCallback + @SuppressLint("CheckResult") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -173,6 +176,23 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C _binding = ActivityUploadBinding.inflate(layoutInflater) setContentView(binding.root) + // Overrides the back button to make sure the user is prepared to lose their progress + onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + showAlertDialog( + this@UploadActivity, + getString(R.string.back_button_warning), + getString(R.string.back_button_warning_desc), + getString(R.string.back_button_continue), + getString(R.string.back_button_warning), + null + ) { + finish() + } + } + } + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + /* If Configuration of device is changed then get the new fragments created by the system and populate the fragments ArrayList @@ -187,7 +207,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C } init() - binding.rlContainerTitle.setOnClickListener { v: View? -> onRlContainerTitleClicked() } + binding.rlContainerTitle.setOnClickListener { _: View? -> onRlContainerTitleClicked() } nearbyPopupAnswers = mutableMapOf() //getting the current dpi of the device and if it is less than 320dp i.e. overlapping //threshold, thumbnails automatically minimizes @@ -201,7 +221,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C } locationManager!!.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER) locationManager!!.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER) - store = BasicKvStore(this, storeNameForCurrentUploadImagesSize).apply { + store = BasicKvStore(this, STORE_NAME_FOR_CURRENT_UPLOAD_IMAGE_SIZE).apply { clearAll() } checkStoragePermissions() @@ -241,7 +261,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C override fun onPageSelected(position: Int) { currentSelectedPosition = position - if (position >= uploadableFiles!!.size) { + if (position >= uploadableFiles.size) { binding.cvContainerTopCard.visibility = View.GONE } else { thumbnailsAdapter!!.notifyDataSetChanged() @@ -274,7 +294,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .filter { result: Boolean? -> result!! } - .subscribe { result: Boolean? -> + .subscribe { _: Boolean? -> showAlertDialog( this, getString(R.string.block_notification_title), @@ -284,7 +304,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C }) } - fun checkStoragePermissions() { + private fun checkStoragePermissions() { // Check if all required permissions are granted val hasAllPermissions = hasPermission(this, PERMISSIONS_STORAGE) val hasPartialAccess = hasPartialAccess(this) @@ -355,7 +375,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C showLongToast(this, messageResourceId) } - override fun getUploadableFiles(): List? { + override fun getUploadableFiles(): List { return uploadableFiles } @@ -367,6 +387,14 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C override fun onUploadMediaDeleted(index: Int) { fragments!!.removeAt(index) //Remove the corresponding fragment uploadableFiles.removeAt(index) //Remove the files from the list + + val isMediaDetailFragment = fragments!!.getOrNull(currentSelectedPosition)?.let { + it is UploadMediaDetailFragment + } ?: false + if(!isMediaDetailFragment) { + // Should hide the top card current fragment is not the media detail fragment + showHideTopCard(false) + } thumbnailsAdapter!!.notifyItemRemoved(index) //Notify the thumbnails adapter uploadImagesAdapter!!.notifyDataSetChanged() //Notify the ViewPager } @@ -375,8 +403,8 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C binding.tvTopCardTitle.text = resources .getQuantityString( R.plurals.upload_count_title, - uploadableFiles!!.size, - uploadableFiles!!.size + uploadableFiles.size, + uploadableFiles.size ) } @@ -444,15 +472,16 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C receiveInternalSharedItems() } - if (uploadableFiles == null || uploadableFiles!!.isEmpty()) { + if (uploadableFiles.isEmpty()) { handleNullMedia() } else { //Show thumbnails - if (uploadableFiles!!.size > 1) { - if (!defaultKvStore.getBoolean("hasAlreadyLaunchedCategoriesDialog")) { //If there is only file, no need to show the image thumbnails + if (uploadableFiles.size > 1) { + if (!defaultKvStore.getBoolean("hasAlreadyLaunchedCategoriesDialog")) { + // If there is only file, no need to show the image thumbnails showAlertDialogForCategories() } - if (uploadableFiles!!.size > 3 && + if (uploadableFiles.size > 3 && !defaultKvStore.getBoolean("hasAlreadyLaunchedBigMultiupload") ) { showAlertForBattery() @@ -464,8 +493,8 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C binding.tvTopCardTitle.text = resources .getQuantityString( R.plurals.upload_count_title, - uploadableFiles!!.size, - uploadableFiles!!.size + uploadableFiles.size, + uploadableFiles.size ) @@ -474,7 +503,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C } - for (uploadableFile in uploadableFiles!!) { + for (uploadableFile in uploadableFiles) { val uploadMediaDetailFragment = UploadMediaDetailFragment() if (!uploadIsOfAPlace) { @@ -497,8 +526,8 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C object : UploadMediaDetailFragmentCallback { override fun deletePictureAtIndex(index: Int) { store!!.putInt( - keyForCurrentUploadImagesSize, - (store!!.getInt(keyForCurrentUploadImagesSize) - 1) + KEY_FOR_CURRENT_UPLOAD_IMAGE_SIZE, + (store!!.getInt(KEY_FOR_CURRENT_UPLOAD_IMAGE_SIZE) - 1) ) presenter!!.deletePictureAtIndex(index) } @@ -576,11 +605,11 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C fragments!!.add(mediaLicenseFragment!!) } else { for (i in 1 until fragments!!.size) { - fragments!![i]!!.callback = object : UploadBaseFragment.Callback { + fragments!![i].callback = object : UploadBaseFragment.Callback { override fun onNextButtonClicked(index: Int) { if (index < fragments!!.size - 1) { binding.vpUpload.setCurrentItem(index + 1, false) - fragments!![index + 1]!!.onBecameVisible() + fragments!![index + 1].onBecameVisible() (binding.rvThumbnails.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset( if ((index > 0)) index - 1 else 0, @@ -594,7 +623,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C override fun onPreviousButtonClicked(index: Int) { if (index != 0) { binding.vpUpload.setCurrentItem(index - 1, true) - fragments!![index - 1]!!.onBecameVisible() + fragments!![index - 1].onBecameVisible() (binding.rvThumbnails.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset( if ((index > 3)) index - 2 else 0, @@ -632,11 +661,12 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C binding.vpUpload.offscreenPageLimit = fragments!!.size } // Saving size of uploadableFiles - store!!.putInt(keyForCurrentUploadImagesSize, uploadableFiles!!.size) + store!!.putInt(KEY_FOR_CURRENT_UPLOAD_IMAGE_SIZE, uploadableFiles.size) } /** - * Changes current image when one image upload is cancelled, to highlight next image in the top thumbnail. + * Changes current image when one image upload is cancelled, to highlight next image in the top + * thumbnail. * Fixes: [Issue](https://github.com/commons-app/apps-android-commons/issues/5511) * * @param index Index of image to be removed @@ -771,7 +801,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C override fun onNextButtonClicked(index: Int) { if (index < fragments!!.size - 1) { binding.vpUpload.setCurrentItem(index + 1, false) - fragments!![index + 1]!!.onBecameVisible() + fragments!![index + 1].onBecameVisible() (binding.rvThumbnails.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset(if ((index > 0)) index - 1 else 0, 0) if (index < fragments!!.size - 4) { @@ -786,10 +816,10 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C override fun onPreviousButtonClicked(index: Int) { if (index != 0) { binding.vpUpload.setCurrentItem(index - 1, true) - fragments!![index - 1]!!.onBecameVisible() + fragments!![index - 1].onBecameVisible() (binding.rvThumbnails.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset(if ((index > 3)) index - 2 else 0, 0) - if ((index != 1) && ((index - 1) < uploadableFiles!!.size)) { + if ((index != 1) && ((index - 1) < uploadableFiles.size)) { // Shows the top card if it was hidden because of the last image being deleted and // now the user has hit previous button to go back to the media details showHideTopCard(true) @@ -797,7 +827,10 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C } } - override fun onThumbnailDeleted(position: Int) = presenter!!.deletePictureAtIndex(position) + override fun onThumbnailDeleted(position: Int) { + presenter!!.deletePictureAtIndex(position) + thumbnailsAdapter?.notifyDataSetChanged() + } /** * The adapter used to show image upload intermediate fragments @@ -824,11 +857,11 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C } - fun onRlContainerTitleClicked() { + private fun onRlContainerTitleClicked() { binding.rvThumbnails.visibility = if (isTitleExpanded) View.GONE else View.VISIBLE isTitleExpanded = !isTitleExpanded - binding.ibToggleTopCard.rotation = binding.ibToggleTopCard.rotation + 180 + binding.ibToggleTopCard.rotation += 180 } override fun onDestroy() { @@ -845,21 +878,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C if (uploadCategoriesFragment != null) { uploadCategoriesFragment!!.callback = null } - } - - /** - * Overrides the back button to make sure the user is prepared to lose their progress - */ - @SuppressLint("MissingSuperCall") - override fun onBackPressed() { - showAlertDialog( - this, - getString(R.string.back_button_warning), - getString(R.string.back_button_warning_desc), - getString(R.string.back_button_continue), - getString(R.string.back_button_warning), - null - ) { finish() } + onBackPressedCallback.remove() } /** @@ -879,7 +898,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C .setView(view) .setTitle(getString(R.string.multiple_files_depiction_header)) .setMessage(getString(R.string.multiple_files_depiction)) - .setPositiveButton("OK") { dialog: DialogInterface?, which: Int -> + .setPositiveButton("OK") { _: DialogInterface?, _: Int -> if (checkBox.isChecked) { // Save the user's choice to not show the dialog again defaultKvStore.putBoolean("hasAlreadyLaunchedCategoriesDialog", true) @@ -913,14 +932,14 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C getString(R.string.cancel), { /* Since opening the right settings page might be device dependent, using - https://github.com/WaseemSabir/BatteryPermissionHelper - directly appeared like a promising idea. - However, this simply closed the popup and did not make - the settings page appear on a Pixel as well as a Xiaomi device. - Used the standard intent instead of using this library as - it shows a list of all the apps on the device and allows users to - turn battery optimisation off. - */ + https://github.com/WaseemSabir/BatteryPermissionHelper + directly appeared like a promising idea. + However, this simply closed the popup and did not make + the settings page appear on a Pixel as well as a Xiaomi device. + Used the standard intent instead of using this library as + it shows a list of all the apps on the device and allows users to + turn battery optimisation off. + */ val batteryOptimisationSettingsIntent = Intent( Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS ) @@ -958,7 +977,8 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C Also, location information is discarded if the difference between current location and location recorded just before capturing the image is greater than 100 meters */ - if (isLocationTagUnchecked || locationDifference > 100 || !defaultKvStore.getBoolean("inAppCameraLocationPref") + if (isLocationTagUnchecked || locationDifference > 100 + || !defaultKvStore.getBoolean("inAppCameraLocationPref") || !isInAppCameraUpload ) { currLocation = null @@ -979,8 +999,8 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C @JvmField var nearbyPopupAnswers: MutableMap? = null - const val keyForCurrentUploadImagesSize: String = "CurrentUploadImagesSize" - const val storeNameForCurrentUploadImagesSize: String = "CurrentUploadImageQualities" + const val KEY_FOR_CURRENT_UPLOAD_IMAGE_SIZE: String = "CurrentUploadImagesSize" + const val STORE_NAME_FOR_CURRENT_UPLOAD_IMAGE_SIZE: String = "CurrentUploadImageQualities" /** * Sets the flag indicating whether the upload is of a specific place. diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.kt index 9ee8fb483..5d721f408 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.kt @@ -146,34 +146,31 @@ class UploadPresenter @Inject internal constructor( override fun deletePictureAtIndex(index: Int) { val uploadableFiles = view.getUploadableFiles() - if (index == uploadableFiles!!.size - 1) { - // If the next fragment to be shown is not one of the MediaDetailsFragment - // lets hide the top card so that it doesn't appear on the other fragments - view.showHideTopCard(false) - } - view.setImageCancelled(true) - repository.deletePicture(uploadableFiles[index].getFilePath()) - if (uploadableFiles.size == 1) { - view.showMessage(R.string.upload_cancelled) - view.finish() - return - } - - presenter.updateImageQualitiesJSON(uploadableFiles.size, index) - view.onUploadMediaDeleted(index) - if (index != uploadableFiles.size && index != 0) { - // if the deleted image was not the last item to be uploaded, check quality of next - repository.getUploadItem(index)?.let { - presenter.checkImageQuality(it, index) + uploadableFiles?.let { + view.setImageCancelled(true) + repository.deletePicture(uploadableFiles[index].getFilePath()) + if (uploadableFiles.size == 1) { + view.showMessage(R.string.upload_cancelled) + view.finish() + return } - } - if (uploadableFiles.size < 2) { - view.showHideTopCard(false) - } + presenter.updateImageQualitiesJSON(uploadableFiles.size, index) + view.onUploadMediaDeleted(index) + if (index != uploadableFiles.size && index != 0) { + // if the deleted image was not the last item to be uploaded, check quality of next + repository.getUploadItem(index)?.let { + presenter.checkImageQuality(it, index) + } + } - //In case lets update the number of uploadable media - view.updateTopCardTitle() + if (uploadableFiles.size < 2) { + view.showHideTopCard(false) + } + + //In case lets update the number of uploadable media + view.updateTopCardTitle() + } } override fun onAttachView(view: UploadContract.View) { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt index af850a7e3..4a4c13ba7 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt @@ -532,7 +532,7 @@ class UploadMediaDetailFragment : UploadBaseFragment(), UploadMediaDetailsContra basicKvStore!!.putBoolean(keyForShowingAlertDialog, false) if (isInternetConnectionEstablished(requireActivity())) { val sizeOfUploads = basicKvStore!!.getInt( - UploadActivity.keyForCurrentUploadImagesSize + UploadActivity.KEY_FOR_CURRENT_UPLOAD_IMAGE_SIZE ) for (i in indexOfFragment until sizeOfUploads) { presenter.getImageQuality( diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.kt index 90c426091..77999cf2f 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.kt @@ -310,7 +310,7 @@ class UploadMediaPresenter @Inject constructor( private fun storeImageQuality( imageResult: Int, uploadItemIndex: Int, activity: Activity, uploadItem: UploadItem ) { - val store = BasicKvStore(activity, UploadActivity.storeNameForCurrentUploadImagesSize) + val store = BasicKvStore(activity, UploadActivity.STORE_NAME_FOR_CURRENT_UPLOAD_IMAGE_SIZE) val value = store.getString(UPLOAD_QUALITIES_KEY, null) try { val jsonObject = value.asJsonObject().apply { @@ -339,8 +339,10 @@ class UploadMediaPresenter @Inject constructor( */ override fun checkImageQuality(uploadItem: UploadItem, index: Int) { if ((uploadItem.imageQuality != IMAGE_OK) && (uploadItem.imageQuality != IMAGE_KEEP)) { - val value = basicKvStoreFactory?.let { it(UploadActivity.storeNameForCurrentUploadImagesSize) } + + val value = basicKvStoreFactory?.let { it(UploadActivity.STORE_NAME_FOR_CURRENT_UPLOAD_IMAGE_SIZE) } ?.getString(UPLOAD_QUALITIES_KEY, null) + try { val imageQuality = value.asJsonObject()["UploadItem$index"] as Int view.showProgress(false) @@ -363,8 +365,9 @@ class UploadMediaPresenter @Inject constructor( * @param index Index of the UploadItem which was deleted */ override fun updateImageQualitiesJSON(size: Int, index: Int) { - val value = basicKvStoreFactory?.let { it(UploadActivity.storeNameForCurrentUploadImagesSize) } + val value = basicKvStoreFactory?.let { it(UploadActivity.STORE_NAME_FOR_CURRENT_UPLOAD_IMAGE_SIZE) } ?.getString(UPLOAD_QUALITIES_KEY, null) + try { val jsonObject = value.asJsonObject().apply { for (i in index until (size - 1)) { @@ -372,7 +375,8 @@ class UploadMediaPresenter @Inject constructor( } remove("UploadItem" + (size - 1)) } - basicKvStoreFactory?.let { it(UploadActivity.storeNameForCurrentUploadImagesSize) } + + basicKvStoreFactory?.let { it(UploadActivity.STORE_NAME_FOR_CURRENT_UPLOAD_IMAGE_SIZE) } ?.putString(UPLOAD_QUALITIES_KEY, jsonObject.toString()) } catch (e: Exception) { Timber.e(e) diff --git a/app/src/main/java/fr/free/nrw/commons/utils/MediaAttributionUtil.kt b/app/src/main/java/fr/free/nrw/commons/utils/MediaAttributionUtil.kt new file mode 100644 index 000000000..7a66a87de --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/MediaAttributionUtil.kt @@ -0,0 +1,39 @@ +package fr.free.nrw.commons.utils + +import android.content.Context +import android.icu.text.ListFormatter +import android.os.Build +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.media.IdAndLabels +import java.util.Locale + +object MediaAttributionUtil { + fun getTagLine(media: Media, context: Context): String { + val uploader = media.user + val author = media.getAttributedAuthor() + return if (author.isNullOrEmpty()) { + context.getString(R.string.image_uploaded_by, uploader) + } else if (author == uploader) { + context.getString(R.string.image_tag_line_created_and_uploaded_by, author) + } else { + context.getString( + R.string.image_tag_line_created_by_and_uploaded_by, + author, + uploader + ) + } + } + + fun getCreatorName(idAndLabels: List): String? { + val locale = Locale.getDefault() + val names = idAndLabels.map{ x -> x.getLocalizedLabel(locale.language)} + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val formatter = ListFormatter.getInstance(locale) + return formatter.format(names) + } else { + return names.joinToString(", ") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/utils/StringUtil.kt b/app/src/main/java/fr/free/nrw/commons/utils/StringUtil.kt index b3c58d8b2..59c6aea97 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/StringUtil.kt +++ b/app/src/main/java/fr/free/nrw/commons/utils/StringUtil.kt @@ -1,9 +1,8 @@ package fr.free.nrw.commons.utils -import android.os.Build -import android.text.Html import android.text.Spanned import android.text.SpannedString +import androidx.core.text.HtmlCompat object StringUtil { @@ -26,12 +25,6 @@ object StringUtil { .replace("‏", "\u200F") .replace("&", "&") - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - Html.fromHtml(processedSource, Html.FROM_HTML_MODE_LEGACY) - } else { - //noinspection deprecation - @Suppress("DEPRECATION") - Html.fromHtml(processedSource) - } + return HtmlCompat.fromHtml(processedSource, HtmlCompat.FROM_HTML_MODE_LEGACY) } } diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataProperties.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataProperties.kt index 5e82c3c80..c4b95d0c5 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataProperties.kt +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataProperties.kt @@ -7,6 +7,7 @@ enum class WikidataProperties( ) { IMAGE("P18"), DEPICTS(BuildConfig.DEPICTS_PROPERTY), + CREATOR(BuildConfig.CREATOR_PROPERTY), COMMONS_CATEGORY("P373"), INSTANCE_OF("P31"), MEDIA_LEGENDS("P2096"), 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 index 63c018252..53fc44bc9 100644 --- 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 @@ -1,7 +1,6 @@ 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 diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml index 7cfd761a7..0da9f5d9f 100644 --- a/app/src/main/res/layout/activity_login.xml +++ b/app/src/main/res/layout/activity_login.xml @@ -146,6 +146,7 @@ android:layout_marginEnd="@dimen/standard_gap" android:layout_marginRight="@dimen/standard_gap" android:layout_marginBottom="@dimen/standard_gap" + android:hint="@string/_2fa_code" android:visibility="gone" app:passwordToggleEnabled="false" tools:visibility="visible"> @@ -154,9 +155,7 @@ android:id="@+id/login_two_factor" android:layout_width="match_parent" android:layout_height="wrap_content" - android:hint="@string/_2fa_code" android:imeOptions="flagNoExtractUi" - android:inputType="number" android:visibility="gone" tools:visibility="visible" /> diff --git a/app/src/main/res/layout/activity_wikidata_feedback.xml b/app/src/main/res/layout/activity_wikidata_feedback.xml index 5f6647efe..52034b3ac 100644 --- a/app/src/main/res/layout/activity_wikidata_feedback.xml +++ b/app/src/main/res/layout/activity_wikidata_feedback.xml @@ -102,6 +102,7 @@ android:layout_marginEnd="8dp" android:text="SEND" android:visibility="visible" + android:textColor="@color/white" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="@+id/textHeader" app:layout_constraintHorizontal_bias="1.0" diff --git a/app/src/main/res/layout/fragment_achievements.xml b/app/src/main/res/layout/fragment_achievements.xml index c688f983a..b002e4fc9 100644 --- a/app/src/main/res/layout/fragment_achievements.xml +++ b/app/src/main/res/layout/fragment_achievements.xml @@ -43,7 +43,7 @@ android:id="@+id/achievement_badge_image" android:layout_width="150dp" android:layout_height="150dp" - android:layout_marginTop="16dp" + android:layout_marginTop="100dp" android:background="@drawable/badge" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" @@ -263,7 +263,7 @@ app:layout_constraintTop_toBottomOf="@+id/images_used_tv"> + /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_explore_map.xml b/app/src/main/res/layout/fragment_explore_map.xml index e2d628dab..ad0b40b82 100644 --- a/app/src/main/res/layout/fragment_explore_map.xml +++ b/app/src/main/res/layout/fragment_explore_map.xml @@ -26,10 +26,11 @@ android:clickable="true" android:focusable="true" android:visibility="visible" - app:backgroundTint="@color/main_background_light" + app:backgroundTint="?attr/colorSurface" app:elevation="@dimen/dimen_6" app:fabSize="normal" app:srcCompat="@drawable/ic_my_location_black_24dp" + app:tint="?attr/colorOnSurface" app:useCompatPadding="true" /> الكثير من المحاولات غير الناجحة. الرجاء المحاولة مرة أخرى في بضع دقائق. عذراً، لقد تم منع هذا المستخدم على كومنز يجب توفير رمز التحقق المزدوج. + تم إرسال رمز التحقق إلى بريدك الإلكتروني. يُرجى إدخال الرمز لتسجيل الدخول. فشل تسجيل الدخول ارفع اسم هذه المجموعة @@ -273,6 +274,7 @@ انضم لمختبري اصدارات Beta (بيتا) يمكنك الاشتراك في القناة التجريبية على جوجل بلاي والحصول على إمكانية الوصول المبكر إلى الميزات الجديدة وإصلاحات الأخطاء رمز التحقق المزدوج 2FA + رمز التحقق من البريد الإلكتروني أترغب فعلا في الخروج؟ صورة الوسائط فشلت لم يتم العثور على تصنيفات فرعية. @@ -887,4 +889,6 @@ مبروك، جميع الصور الموجودة في هذا الألبوم تم تحميلها أو تم وضع علامة عليها بأنها غير قابلة للتحميل. عرض في استكشاف عرض في المناطق القريبة + تم الإنشاء والتحميل بواسطة: %1$s + تم إنشاؤه بواسطة %1$s وتم تحميله بواسطة %2$s diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index a7a3da9a7..9b2fde313 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -2,6 +2,7 @@ @@ -79,7 +80,7 @@ ⵉⴼⵔⵙ ⵉⵙⵉⴹⵏⵏ ⵜⵉⵙⵖⴰⵍ - ⴰⵏⵙⵙⵎⵔⵙ + ⴰⵏⵙⵎⵔⴰⵙ ⵙⵙⵉⴹⵏ ⴰⵙⴳⵯⵙⴰⵏ ⴰⵏⵎⵍⴰⵙⵙ diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ca11a4ec9..b2fd85c23 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -136,6 +136,7 @@ 失敗次數過多。請於幾分鐘後重試。 很抱歉,該使用者已被維基共享資源封禁 必須提供您的雙重驗證代碼。 + 登入驗證碼已發送到您的電子郵件地址。請提供驗證碼以登入。 登入失敗 上傳 給這個集合命名 @@ -241,6 +242,7 @@ 成為 Beta 測試員 選擇加入我們在 Google Play 上的測試版頻道並儘早訪問新功能和錯誤修復 2FA 代碼 + 電子郵件驗證碼 您確定要登出嗎? 媒體圖片失敗 找不到子分類 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 7543d98b7..5c7db0aae 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -25,6 +25,7 @@ * Liuxinyu970226 * Looong * McDutchie +* Mishidexfc * NACHOgao3 * Qiyue2001 * Shizhao @@ -168,6 +169,7 @@ 失败次数过多。请在几分钟后重试。 对不起,该用户已经被共享资源封禁 您必须提供您的双因素验证代码。 + 登录验证码已发送至您的邮箱,请提供该验证码进行登录。 登录失败 上传 命名这组图像 @@ -273,6 +275,7 @@ 成为测试版的测试者 加入我们在Google Play上的测试计划,更早获取新功能以及错误修复 2FA代码 + 电子邮件验证码 您真的想要退出么? 媒体图片失败 找不到子分类 @@ -873,4 +876,6 @@ 恭喜,专辑中的所有图片都已上传或标记为不上传。 在探索中显示 显示在附近 + 创建并上传者: %1$s + 由%1$s创建并由%2$s上传 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f9b16512f..bb1450e0f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,7 @@ Too many unsuccessful attempts. Please try again in a few minutes. Sorry, this user has been blocked on Commons You must provide your two factor authentication code. + A login verification code has been sent to your email address. Please provide the code to log in. Log-in failed Upload Name this set @@ -218,6 +219,7 @@ Opt-in to our beta channel on Google Play and get early access to new features and bug fixes https://play.google.com/apps/testing/fr.free.nrw.commons 2FA Code + Email verification code Do you really want to logout? Media Image Failed No subcategories found @@ -871,4 +873,6 @@ Upload your first media by tapping on the add button. Show in Explore Show in Nearby + Created and uploaded by: %1$s + Created by %1$s and uploaded by %2$s diff --git a/app/src/main/resources/queries/query_for_item.rq b/app/src/main/resources/queries/query_for_item.rq index 4a946ac96..2957b9b5b 100644 --- a/app/src/main/resources/queries/query_for_item.rq +++ b/app/src/main/resources/queries/query_for_item.rq @@ -10,6 +10,8 @@ SELECT (SAMPLE(?wikipediaArticle) AS ?wikipediaArticle) (SAMPLE(?commonsArticle) AS ?commonsArticle) (SAMPLE(?commonsCategory) AS ?commonsCategory) + (SAMPLE(?dateOfOfficialClosure) AS ?dateOfOfficialClosure) + (SAMPLE(?pointInTime) AS ?pointInTime) WHERE { SERVICE { values ?item { @@ -45,6 +47,8 @@ WHERE { # Get existence OPTIONAL {?item wdt:P576 ?destroyed} OPTIONAL {?item wdt:P582 ?endTime} + OPTIONAL {?item wdt:P3999 ?dateOfOfficialClosure} + OPTIONAL {?item wdt:P585 ?pointInTime} # Get Commons category OPTIONAL {?item wdt:P373 ?commonsCategory} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/ModelFunctions.kt b/app/src/test/kotlin/fr/free/nrw/commons/ModelFunctions.kt index 76f9c21d0..a3a201d94 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/ModelFunctions.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/ModelFunctions.kt @@ -50,6 +50,7 @@ fun media( licenseUrl: String? = "licenseUrl", author: String? = "creator", user: String? = "user", + creatorName: String? = null, pageId: String = "pageId", categories: List? = listOf("categories"), coordinates: LatLng? = LatLng(0.0, 0.0, 0.0f), @@ -67,6 +68,7 @@ fun media( licenseUrl, author, user, + creatorName, categories, coordinates, captions, diff --git a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionViewHolderUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionViewHolderUnitTests.kt index 0171f2693..343cc4377 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionViewHolderUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionViewHolderUnitTests.kt @@ -8,6 +8,7 @@ import androidx.test.core.app.ApplicationProvider import com.facebook.drawee.backends.pipeline.Fresco import com.facebook.soloader.SoLoader import fr.free.nrw.commons.Media +import fr.free.nrw.commons.MediaDataExtractor import fr.free.nrw.commons.R import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.TestUtility.setFinalStatic @@ -46,6 +47,9 @@ class ContributionViewHolderUnitTests { @Mock private lateinit var mediaClient: MediaClient + @Mock + private lateinit var mediaDataExtractor: MediaDataExtractor + @Mock private lateinit var uri: Uri @@ -66,8 +70,9 @@ class ContributionViewHolderUnitTests { SoLoader.setInTestMode() Fresco.initialize(ApplicationProvider.getApplicationContext()) activity = Robolectric.buildActivity(ProfileActivity::class.java).create().get() + compositeDisposable = CompositeDisposable() parent = LayoutInflater.from(activity).inflate(R.layout.layout_contribution, null) - contributionViewHolder = ContributionViewHolder(parent, callback, mediaClient) + contributionViewHolder = ContributionViewHolder(parent, callback, compositeDisposable, mediaClient, mediaDataExtractor) bindind = LayoutContributionBinding.bind(parent) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/media/MediaConverterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/media/MediaConverterTest.kt index 8a3022a35..c52b38ba0 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/explore/media/MediaConverterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/media/MediaConverterTest.kt @@ -10,6 +10,7 @@ import org.junit.Before import org.junit.Test import org.mockito.Mock import org.mockito.Mockito +import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import java.lang.IllegalArgumentException @@ -42,23 +43,61 @@ class MediaConverterTest { @Test fun testConvertIfThumbUrlBlank() { - Mockito.`when`(imageInfo.getMetadata()).thenReturn(metadata) - Mockito.`when`(imageInfo.getThumbUrl()).thenReturn("") - Mockito.`when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl") - Mockito.`when`(imageInfo.getMetadata()?.licenseUrl()).thenReturn("licenseUrl") - Mockito.`when`(imageInfo.getMetadata()?.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss") + `when`(imageInfo.getMetadata()).thenReturn(metadata) + `when`(imageInfo.getThumbUrl()).thenReturn("") + `when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl") + `when`(metadata.licenseUrl()).thenReturn("licenseUrl") + `when`(metadata.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss") + `when`(metadata.artist()).thenReturn("Foo Bar") media = mediaConverter.convert(page, entity, imageInfo) assertEquals(media.thumbUrl, media.imageUrl, "originalUrl") } @Test fun testConvertIfThumbUrlNotBlank() { - Mockito.`when`(imageInfo.getMetadata()).thenReturn(metadata) - Mockito.`when`(imageInfo.getThumbUrl()).thenReturn("thumbUrl") - Mockito.`when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl") - Mockito.`when`(imageInfo.getMetadata()?.licenseUrl()).thenReturn("licenseUrl") - Mockito.`when`(imageInfo.getMetadata()?.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss") + `when`(imageInfo.getMetadata()).thenReturn(metadata) + `when`(imageInfo.getThumbUrl()).thenReturn("thumbUrl") + `when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl") + `when`(metadata.licenseUrl()).thenReturn("licenseUrl") + `when`(metadata.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss") + `when`(metadata.artist()).thenReturn("Foo Bar") media = mediaConverter.convert(page, entity, imageInfo) assertEquals(media.thumbUrl, "thumbUrl") } + + @Test + fun `test converting artist value (author) with html links`() { + `when`(imageInfo.getMetadata()).thenReturn(metadata) + `when`(imageInfo.getThumbUrl()).thenReturn("thumbUrl") + `when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl") + `when`(metadata.licenseUrl()).thenReturn("licenseUrl") + `when`(metadata.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss") + `when`(metadata.artist()).thenReturn("Foo Bar") + // Artist values like above is very common, found in file pages created via UploadWizard + media = mediaConverter.convert(page, entity, imageInfo) + assertEquals("Foo Bar", media.author) + } + + @Test + fun `test convert artist value (author) in plain text`() { + `when`(imageInfo.getMetadata()).thenReturn(metadata) + `when`(imageInfo.getThumbUrl()).thenReturn("thumbUrl") + `when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl") + `when`(metadata.licenseUrl()).thenReturn("licenseUrl") + `when`(metadata.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss") + `when`(metadata.artist()).thenReturn("Foo Bar") + media = mediaConverter.convert(page, entity, imageInfo) + assertEquals("Foo Bar", media.author) + } + @Test + fun `test convert artist value (author) containing red link`() { + `when`(imageInfo.getMetadata()).thenReturn(metadata) + `when`(imageInfo.getThumbUrl()).thenReturn("thumbUrl") + `when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl") + `when`(metadata.licenseUrl()).thenReturn("licenseUrl") + `when`(metadata.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss") + `when`(metadata.artist()).thenReturn("Foo") + media = mediaConverter.convert(page, entity, imageInfo) + assertEquals("Foo", media.author) + } } diff --git a/app/src/test/kotlin/fr/free/nrw/commons/profile/achievements/AchievementsFragmentUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/profile/achievements/AchievementsFragmentUnitTests.kt index 4c2fbf52c..789f4d8b2 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/profile/achievements/AchievementsFragmentUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/profile/achievements/AchievementsFragmentUnitTests.kt @@ -173,33 +173,6 @@ class AchievementsFragmentUnitTests { method.invoke(fragment, "", "") } - @Test - @Throws(Exception::class) - fun testHideProgressBar() { - Shadows.shadowOf(Looper.getMainLooper()).idle() - val method: Method = - AchievementsFragment::class.java.getDeclaredMethod( - "hideProgressBar", - Achievements::class.java, - ) - method.isAccessible = true - method.invoke(fragment, achievements) - } - - @Test - @Throws(Exception::class) - fun testSetAchievementsUploadCount() { - Shadows.shadowOf(Looper.getMainLooper()).idle() - val method: Method = - AchievementsFragment::class.java.getDeclaredMethod( - "setAchievementsUploadCount", - Achievements::class.java, - Int::class.java, - ) - method.isAccessible = true - method.invoke(fragment, achievements, 0) - } - @Test @Throws(Exception::class) fun testCheckAccount() { @@ -211,20 +184,7 @@ class AchievementsFragmentUnitTests { method.isAccessible = true method.invoke(fragment) } - - @Test - @Throws(Exception::class) - fun testSetUploadCount() { - Shadows.shadowOf(Looper.getMainLooper()).idle() - val method: Method = - AchievementsFragment::class.java.getDeclaredMethod( - "setUploadCount", - Achievements::class.java, - ) - method.isAccessible = true - method.invoke(fragment, achievements) - } - + @Test @Throws(Exception::class) fun testOnError() { @@ -263,18 +223,6 @@ class AchievementsFragmentUnitTests { method.invoke(fragment, false) } - @Test - @Throws(Exception::class) - fun testSetWikidataEditCount() { - Shadows.shadowOf(Looper.getMainLooper()).idle() - val method: Method = - AchievementsFragment::class.java.getDeclaredMethod( - "setWikidataEditCount", - ) - method.isAccessible = true - method.invoke(fragment) - } - @Test @Throws(Exception::class) fun testSetAchievements() { diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadActivityUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadActivityUnitTests.kt index 1173d09b0..97fe68862 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadActivityUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadActivityUnitTests.kt @@ -262,15 +262,4 @@ class UploadActivityUnitTests { method.isAccessible = true method.invoke(activity) } - - @Test - @Throws(Exception::class) - fun testOnBackPressed() { - val method: Method = - UploadActivity::class.java.getDeclaredMethod( - "onBackPressed", - ) - method.isAccessible = true - method.invoke(activity) - } } diff --git a/app/src/test/kotlin/fr/free/nrw/commons/utils/MediaAttributionUtilTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/utils/MediaAttributionUtilTest.kt new file mode 100644 index 000000000..cd1e41321 --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/utils/MediaAttributionUtilTest.kt @@ -0,0 +1,78 @@ +package fr.free.nrw.commons.utils + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.nhaarman.mockitokotlin2.whenever +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.TestCommonsApplication +import fr.free.nrw.commons.media.IdAndLabels +import org.junit.Assert.* +import org.junit.Before + +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [21], application = TestCommonsApplication::class, qualifiers="en-rUS") +class MediaAttributionUtilTest { + + @Mock + private lateinit var appContext: Context + + @Before + fun setup() { + appContext = ApplicationProvider.getApplicationContext() + } + + @Test + fun getTagLineWithUploaderOnly() { + val media = mock(Media::class.java) + whenever(media.user).thenReturn("TestUploader") + whenever(media.author).thenReturn(null) + assertEquals("Uploaded by: TestUploader", + MediaAttributionUtil.getTagLine(media, appContext)) + } + + @Test + fun `get tag line from same author and uploader`() { + val media = mock(Media::class.java) + whenever(media.user).thenReturn("TestUser") + whenever(media.getAttributedAuthor()).thenReturn("TestUser") + assertEquals("Created and uploaded by: TestUser", + MediaAttributionUtil.getTagLine(media, appContext)) + } + + @Test + fun `get creator name from EN label`() { + assertEquals("FooBar", + MediaAttributionUtil.getCreatorName(listOf(IdAndLabels("Q1", mapOf("en" to "FooBar"))))) + } + + @Test + fun `get creator name from ES label`() { + assertEquals("FooBar", + MediaAttributionUtil.getCreatorName(listOf(IdAndLabels("Q2", mapOf("es" to "FooBar"))))) + } + + @Test + fun `get creator name from EN label and ignore ES label`() { + assertEquals("Bar", + MediaAttributionUtil.getCreatorName(listOf( + IdAndLabels("Q3", mapOf("en" to "Bar", "es" to "Foo"))))) + } + + @Test + fun `get creator name from two creators`() { + val name = MediaAttributionUtil.getCreatorName(listOf( + IdAndLabels("Q1", mapOf("en" to "Foo")), + IdAndLabels("Q1", mapOf("en" to "Bar")) + )) + assertNotNull(name) + assertTrue(name!!.contains("Foo")) + assertTrue(name.contains("Bar")) + } +} \ No newline at end of file