From 555fe590b73269b97ee4e16cbfa5e00d31a1acb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:24:56 +0000 Subject: [PATCH 01/41] Initial plan From 288429236669e201cc92cb6683323fe129dd53a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:38:03 +0000 Subject: [PATCH 02/41] Migrate image loading dependencies from Fresco + Glide to Coil - Remove Fresco (fresco, middleware), Glide (glide, compiler), and soloader dependencies from version catalog and build.gradle.kts - Add Coil 2.7.0 as the replacement image loading library Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- app/build.gradle.kts | 7 +------ gradle/libs.versions.toml | 10 ++-------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1d6e30c574f..72758340128 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -216,6 +216,7 @@ dependencies { // Utils implementation(libs.gson) implementation(libs.okhttp) + implementation(libs.coil) implementation(libs.retrofit) implementation(libs.retrofit.converter.gson) implementation(libs.retrofit.adapter.rxjava) @@ -223,8 +224,6 @@ dependencies { implementation(libs.rxjava) implementation(libs.rxbinding) implementation(libs.rxbinding.appcompat) - implementation(libs.facebook.fresco) - implementation(libs.facebook.fresco.middleware) implementation(libs.apache.commons.lang3) // UI @@ -299,7 +298,6 @@ dependencies { testImplementation(libs.androidx.core.testing) testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) - testImplementation(libs.soloader) testImplementation(libs.kotlinx.coroutines.test) debugImplementation(libs.androidx.fragment.testing) testImplementation(libs.commons.io) @@ -349,9 +347,6 @@ dependencies { implementation(libs.kotlinx.coroutines.rx2) testImplementation(libs.androidx.work.testing) - //Glide - implementation(libs.glide) - annotationProcessor(libs.glide.compiler) kaptTest(libs.androidx.databinding.compiler) kaptAndroidTest(libs.androidx.databinding.compiler) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f9ae80547be..6304fe50731 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,9 +27,8 @@ dexterVersion = "5.0.0" espresso = "3.6.1" exifinterface = "1.3.7" fragmentTesting = "1.6.2" -frescoVersion = "3.6.0" +coil = "2.7.0" commonsLang3Version = "3.8.1" -glide = "4.12.0" gson = "2.8.5" junit = "4.13.2" junitJupiter = "5.10.0" @@ -66,7 +65,6 @@ rxjava = "2.2.3" rxbinding = "2.1.1" rxbindingAppcompat = "3.0.0" slf4jApi = "1.7.25" -soloader = "0.10.5" timber = "4.7.1" uiautomator = "2.2.0" workManager = "2.8.1" @@ -125,10 +123,7 @@ dagger-android = { module = "com.google.dagger:dagger-android", version.ref = "d dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } # Image loading -facebook-fresco = { module = "com.facebook.fresco:fresco", version.ref = "frescoVersion" } -facebook-fresco-middleware = { module = "com.facebook.fresco:middleware", version.ref = "frescoVersion" } -glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" } -glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } +coil = { module = "io.coil-kt:coil", version.ref = "coil" } kotlinx-coroutines-rx2 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx2", version.ref = "kotlinxCoroutinesRx2" } photoview = { module = "com.github.chrisbanes:PhotoView", version.ref = "photoviewVersion" } @@ -152,7 +147,6 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } livedata-testing-ktx = { module = "com.jraska.livedata:testing-ktx", version.ref = "livedataTesting" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } -soloader = { module = "com.facebook.soloader:soloader", version.ref = "soloader" } # Mocking mockk = { module = "io.mockk:mockk", version.ref = "mockk" } From 33360541e925d120d912533e5753e69ac29cddf3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:42:38 +0000 Subject: [PATCH 03/41] Replace Fresco views with standard ImageView/PhotoView in XML layouts - Replace SimpleDraweeView with ImageView in 16 layout files - Replace ZoomableDraweeView with PhotoView in activity_zoomable.xml - Remove fresco: namespace declarations and Fresco-specific attributes (roundAsCircle, viewAspectRatio, placeholderImage, actualImageScaleType) - Add equivalent android:scaleType where appropriate - Keep all standard android: and app: attributes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- app/src/main/res/layout-land/activity_review.xml | 2 +- app/src/main/res/layout/activity_review.xml | 2 +- app/src/main/res/layout/activity_zoomable.xml | 4 ++-- app/src/main/res/layout/fragment_media_detail.xml | 5 ++--- app/src/main/res/layout/fragment_similar_image_dialog.xml | 8 ++++---- app/src/main/res/layout/item_depictions.xml | 5 ++--- app/src/main/res/layout/item_failed_upload.xml | 6 ++---- app/src/main/res/layout/item_pending_upload.xml | 8 +++----- app/src/main/res/layout/item_place.xml | 2 +- app/src/main/res/layout/item_upload_thumbnail.xml | 5 ++--- app/src/main/res/layout/layout_category_images.xml | 4 ++-- app/src/main/res/layout/layout_contribution.xml | 6 ++---- app/src/main/res/layout/layout_upload_categories_item.xml | 5 ++--- app/src/main/res/layout/layout_upload_depicts_item.xml | 5 ++--- app/src/main/res/layout/leaderboard_list_element.xml | 5 +---- app/src/main/res/layout/leaderboard_user_element.xml | 4 +--- app/src/main/res/layout/question_layout.xml | 2 +- 17 files changed, 31 insertions(+), 47 deletions(-) diff --git a/app/src/main/res/layout-land/activity_review.xml b/app/src/main/res/layout-land/activity_review.xml index 405ef054c94..204cfe3f1a4 100644 --- a/app/src/main/res/layout-land/activity_review.xml +++ b/app/src/main/res/layout-land/activity_review.xml @@ -87,7 +87,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> - - - - + android:layout_gravity="center_horizontal" /> - - + + android:scaleType="fitCenter" /> - + app:layout_constraintBottom_toBottomOf="parent" /> diff --git a/app/src/main/res/layout/item_failed_upload.xml b/app/src/main/res/layout/item_failed_upload.xml index 73ac9c7b414..4eb9454d7eb 100644 --- a/app/src/main/res/layout/item_failed_upload.xml +++ b/app/src/main/res/layout/item_failed_upload.xml @@ -3,18 +3,16 @@ android:layout_width="match_parent" android:layout_height="wrap_content" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:fresco="http://schemas.android.com/tools" android:paddingBottom="8dp" android:gravity="center" android:orientation="horizontal"> - + android:scaleType="centerCrop" /> - + app:layout_constraintTop_toTopOf="parent" /> - - + android:scaleType="fitCenter"/> - - + android:scaleType="centerCrop" /> - + app:layout_constraintEnd_toStartOf="@+id/text_container" /> - + app:layout_constraintEnd_toStartOf="@+id/text_container" /> - - diff --git a/app/src/main/res/layout/question_layout.xml b/app/src/main/res/layout/question_layout.xml index 6af04de0823..a30cfc695d7 100644 --- a/app/src/main/res/layout/question_layout.xml +++ b/app/src/main/res/layout/question_layout.xml @@ -5,7 +5,7 @@ android:layout_height="wrap_content" android:gravity="center_horizontal"> - Date: Sat, 28 Mar 2026 14:44:28 +0000 Subject: [PATCH 04/41] Replace Fresco with Coil image loader in CommonsApplication - Remove Fresco/ImagePipelineConfig imports and add Coil imports - Remove CustomOkHttpNetworkFetcher injection field - Replace Fresco initialization with Coil ImageLoader setup (crossfade, memory cache at 25%, disk cache at 2%) - Update clearImageCache() to use Coil's cache APIs - Delete CustomOkHttpNetworkFetcher.kt (no longer needed as Coil uses OkHttp internally) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../fr/free/nrw/commons/CommonsApplication.kt | 42 ++-- .../media/CustomOkHttpNetworkFetcher.kt | 199 ------------------ 2 files changed, 23 insertions(+), 218 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcher.kt diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt index 3326c2f3dc3..663b415aba8 100644 --- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt +++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt @@ -11,8 +11,10 @@ import android.os.Build import android.os.Process import android.util.Log import androidx.multidex.MultiDexApplication -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.imagepipeline.core.ImagePipelineConfig +import coil.Coil +import coil.ImageLoader +import coil.disk.DiskCache +import coil.memory.MemoryCache import fr.free.nrw.commons.auth.LoginActivity import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable @@ -28,7 +30,6 @@ import fr.free.nrw.commons.kvstore.JsonKvStore import fr.free.nrw.commons.language.AppLanguageLookUpTable import fr.free.nrw.commons.logging.FileLoggingTree import fr.free.nrw.commons.logging.LogUtils -import fr.free.nrw.commons.media.CustomOkHttpNetworkFetcher import fr.free.nrw.commons.settings.Prefs import fr.free.nrw.commons.upload.FileUtils import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha @@ -82,9 +83,6 @@ class CommonsApplication : MultiDexApplication() { @Inject lateinit var cookieJar: CommonsCookieJar - @Inject - lateinit var customOkHttpNetworkFetcher: CustomOkHttpNetworkFetcher - var languageLookUpTable: AppLanguageLookUpTable? = null private set @@ -116,17 +114,22 @@ class CommonsApplication : MultiDexApplication() { defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet) } - // Set DownsampleEnabled to True to downsample the image in case it's heavy - val config = ImagePipelineConfig.newBuilder(this) - .setNetworkFetcher(customOkHttpNetworkFetcher) - .setDownsampleEnabled(true) + // Initialize Coil image loader + val imageLoader = ImageLoader.Builder(this) + .crossfade(true) + .memoryCache { + MemoryCache.Builder(this) + .maxSizePercent(0.25) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(cacheDir.resolve("image_cache")) + .maxSizePercent(0.02) + .build() + } .build() - try { - Fresco.initialize(this, config) - } catch (e: Exception) { - Timber.e(e) - // TODO: Remove when we're able to initialize Fresco in test builds. - } + Coil.setImageLoader(imageLoader) createNotificationChannel(this) @@ -227,11 +230,12 @@ class CommonsApplication : MultiDexApplication() { } /** - * Clear all images cache held by Fresco + * Clear all images cache held by Coil */ private fun clearImageCache() { - val imagePipeline = Fresco.getImagePipeline() - imagePipeline.clearCaches() + val imageLoader = Coil.imageLoader(this) + imageLoader.memoryCache?.clear() + imageLoader.diskCache?.clear() } /** diff --git a/app/src/main/java/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcher.kt b/app/src/main/java/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcher.kt deleted file mode 100644 index c8de4022b30..00000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcher.kt +++ /dev/null @@ -1,199 +0,0 @@ -package fr.free.nrw.commons.media - -import android.os.Looper -import android.os.SystemClock -import com.facebook.imagepipeline.common.BytesRange -import com.facebook.imagepipeline.image.EncodedImage -import com.facebook.imagepipeline.producers.BaseNetworkFetcher -import com.facebook.imagepipeline.producers.BaseProducerContextCallbacks -import com.facebook.imagepipeline.producers.Consumer -import com.facebook.imagepipeline.producers.FetchState -import com.facebook.imagepipeline.producers.NetworkFetcher -import com.facebook.imagepipeline.producers.ProducerContext -import fr.free.nrw.commons.CommonsApplication -import fr.free.nrw.commons.kvstore.JsonKvStore -import okhttp3.CacheControl -import okhttp3.Call -import okhttp3.Callback -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import timber.log.Timber -import java.io.IOException -import java.util.concurrent.Executor -import javax.inject.Inject -import javax.inject.Named -import javax.inject.Singleton - -// Custom implementation of Fresco's Network fetcher to skip downloading of images when limited connection mode is enabled -// https://github.com/facebook/fresco/blob/master/imagepipeline-backends/imagepipeline-okhttp3/src/main/java/com/facebook/imagepipeline/backends/okhttp3/OkHttpNetworkFetcher.java -@Singleton -class CustomOkHttpNetworkFetcher -@JvmOverloads constructor( - private val mCallFactory: Call.Factory, - private val mCancellationExecutor: Executor, - private val defaultKvStore: JsonKvStore, - disableOkHttpCache: Boolean = true -) : BaseNetworkFetcher() { - - private val mCacheControl = - if (disableOkHttpCache) CacheControl.Builder().noStore().build() else null - private val isLimitedConnectionMode: Boolean - get() = defaultKvStore.getBoolean( - CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, - false - ) - - /** - * @param okHttpClient client to use - */ - @Inject - constructor( - okHttpClient: OkHttpClient, - @Named("default_preferences") defaultKvStore: JsonKvStore - ) : this(okHttpClient, okHttpClient.dispatcher.executorService, defaultKvStore) - - /** - * @param mCallFactory custom [Call.Factory] for fetching image from the network - * @param mCancellationExecutor executor on which fetching cancellation is performed if - * cancellation is requested from the UI Thread - * @param disableOkHttpCache true if network requests should not be cached by OkHttp - */ - override fun createFetchState(consumer: Consumer, context: ProducerContext) = - OkHttpNetworkFetchState(consumer, context) - - override fun fetch( - fetchState: OkHttpNetworkFetchState, callback: NetworkFetcher.Callback - ) { - fetchState.submitTime = SystemClock.elapsedRealtime() - - try { - if (isLimitedConnectionMode) { - Timber.d("Skipping loading of image as limited connection mode is enabled") - callback.onFailure(Exception("Failing image request as limited connection mode is enabled")) - return - } - - val requestBuilder = Request.Builder().url(fetchState.uri.toString()).get() - - if (mCacheControl != null) { - requestBuilder.cacheControl(mCacheControl) - } - - val bytesRange = fetchState.context.imageRequest.bytesRange - if (bytesRange != null) { - requestBuilder.addHeader("Range", bytesRange.toHttpRangeHeaderValue()) - } - - fetchWithRequest(fetchState, callback, requestBuilder.build()) - } catch (e: Exception) { - // handle error while creating the request - callback.onFailure(e) - } - } - - override fun onFetchCompletion(fetchState: OkHttpNetworkFetchState, byteSize: Int) { - fetchState.fetchCompleteTime = SystemClock.elapsedRealtime() - } - - override fun getExtraMap(fetchState: OkHttpNetworkFetchState, byteSize: Int) = - fetchState.toExtraMap(byteSize) - - private fun fetchWithRequest( - fetchState: OkHttpNetworkFetchState, callback: NetworkFetcher.Callback, request: Request - ) { - val call = mCallFactory.newCall(request) - - fetchState.context.addCallbacks(object : BaseProducerContextCallbacks() { - override fun onCancellationRequested() { - onFetchCancellationRequested(call) - } - }) - - call.enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) = - onFetchResponse(fetchState, call, response, callback) - - override fun onFailure(call: Call, e: IOException) = - handleException(call, e, callback) - }) - } - - private fun onFetchCancellationRequested(call: Call) { - if (Looper.myLooper() != Looper.getMainLooper()) { - call.cancel() - } else { - mCancellationExecutor.execute { call.cancel() } - } - } - - private fun onFetchResponse( - fetchState: OkHttpNetworkFetchState, - call: Call, - response: Response, - callback: NetworkFetcher.Callback - ) { - fetchState.responseTime = SystemClock.elapsedRealtime() - try { - response.body.use { body -> - if (!response.isSuccessful) { - handleException(call, IOException("Unexpected HTTP code $response"), callback) - return - } - val responseRange = - BytesRange.fromContentRangeHeader(response.header("Content-Range")) - if (responseRange != null && !(responseRange.from == 0 && responseRange.to == BytesRange.TO_END_OF_CONTENT)) { - // Only treat as a partial image if the range is not all of the content - fetchState.responseBytesRange = responseRange - fetchState.onNewResultStatusFlags = Consumer.IS_PARTIAL_RESULT - } - - var contentLength = body!!.contentLength() - if (contentLength < 0) { - contentLength = 0 - } - callback.onResponse(body.byteStream(), contentLength.toInt()) - } - } catch (e: Exception) { - handleException(call, e, callback) - } - } - - /** - * Handles exceptions. - * - * OkHttp notifies callers of cancellations via an IOException. If IOException is caught - * after request cancellation, then the exception is interpreted as successful cancellation and - * onCancellation is called. Otherwise onFailure is called. - */ - private fun handleException(call: Call, e: Exception, callback: NetworkFetcher.Callback) { - if (call.isCanceled()) { - callback.onCancellation() - } else { - callback.onFailure(e) - } - } -} - -class OkHttpNetworkFetchState( - consumer: Consumer?, producerContext: ProducerContext? -) : FetchState(consumer, producerContext) { - var submitTime: Long = 0 - var responseTime: Long = 0 - var fetchCompleteTime: Long = 0 - - fun toExtraMap(byteSize: Int) = buildMap { - put(QUEUE_TIME, (responseTime - submitTime).toString()) - put(FETCH_TIME, (fetchCompleteTime - responseTime).toString()) - put(TOTAL_TIME, (fetchCompleteTime - submitTime).toString()) - put(IMAGE_SIZE, byteSize.toString()) - } - - companion object { - private const val QUEUE_TIME = "queue_time" - private const val FETCH_TIME = "fetch_time" - private const val TOTAL_TIME = "total_time" - private const val IMAGE_SIZE = "image_size" - } -} - From b9dcb7ca8e5a1d318530b8d81a954fb44b16b2b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:47:05 +0000 Subject: [PATCH 05/41] Migrate SimpleDraweeView to ImageView + Coil in 5 adapter files Replace Fresco's SimpleDraweeView with standard ImageView and Coil image loading in: - GridViewAdapter.kt - LeaderboardListAdapter.kt - UserDetailAdapter.kt - BookmarkItemsAdapter.kt - ThumbnailsAdapter.kt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../commons/bookmarks/items/BookmarkItemsAdapter.kt | 11 +++++++---- .../fr/free/nrw/commons/category/GridViewAdapter.kt | 7 ++++--- .../profile/leaderboard/LeaderboardListAdapter.kt | 11 +++++++---- .../commons/profile/leaderboard/UserDetailAdapter.kt | 11 +++++++---- .../fr/free/nrw/commons/upload/ThumbnailsAdapter.kt | 7 +++---- 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt index 4233d950877..c325f2c6d1a 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt @@ -4,10 +4,11 @@ import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.RecyclerView -import com.facebook.drawee.view.SimpleDraweeView +import coil.load import fr.free.nrw.commons.R import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity import fr.free.nrw.commons.upload.structure.depictions.DepictedItem @@ -24,7 +25,7 @@ class BookmarkItemsAdapter( ) : RecyclerView.ViewHolder(itemView) { var depictsLabel: TextView = itemView.findViewById(R.id.depicts_label) var description: TextView = itemView.findViewById(R.id.description) - var depictsImage: SimpleDraweeView = itemView.findViewById(R.id.depicts_image) + var depictsImage: ImageView = itemView.findViewById(R.id.depicts_image) var layout: ConstraintLayout = itemView.findViewById(R.id.layout_item) } @@ -48,9 +49,11 @@ class BookmarkItemsAdapter( holder.description.text = depictedItem.description if (depictedItem.imageUrl?.isNotBlank() == true) { - holder.depictsImage.setImageURI(depictedItem.imageUrl) + holder.depictsImage.load(depictedItem.imageUrl) { + placeholder(R.drawable.ic_wikidata_logo_24dp) + } } else { - holder.depictsImage.setActualImageResource(R.drawable.ic_wikidata_logo_24dp) + holder.depictsImage.setImageResource(R.drawable.ic_wikidata_logo_24dp) } holder.layout.setOnClickListener { WikidataItemDetailsActivity.startYourself(context, depictedItem) diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt index 0198c61a5e0..98a17c77edd 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt @@ -6,8 +6,9 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter +import android.widget.ImageView import android.widget.TextView -import com.facebook.drawee.view.SimpleDraweeView +import coil.load import fr.free.nrw.commons.Media import fr.free.nrw.commons.R @@ -71,14 +72,14 @@ class GridViewAdapter( ) val item = data?.get(position) - val imageView = view.findViewById(R.id.categoryImageView) + val imageView = view.findViewById(R.id.categoryImageView) val fileName = view.findViewById(R.id.categoryImageTitle) val uploader = view.findViewById(R.id.categoryImageAuthor) item?.let { fileName.text = it.mostRelevantCaption setUploaderView(it, uploader) - imageView.setImageURI(it.thumbUrl) + imageView.load(it.thumbUrl) } return view diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt index 99de68f809b..6cf3588a328 100644 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt @@ -1,14 +1,15 @@ package fr.free.nrw.commons.profile.leaderboard import android.app.Activity -import android.net.Uri import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.TextView import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.RecyclerView -import com.facebook.drawee.view.SimpleDraweeView +import coil.load +import coil.transform.CircleCropTransformation import fr.free.nrw.commons.R import fr.free.nrw.commons.profile.ProfileActivity import fr.free.nrw.commons.profile.leaderboard.LeaderboardList.Companion.DIFF_CALLBACK @@ -21,7 +22,7 @@ import fr.free.nrw.commons.profile.leaderboard.LeaderboardListAdapter.ListViewHo class LeaderboardListAdapter : PagedListAdapter(DIFF_CALLBACK) { inner class ListViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { var rank: TextView? = itemView.findViewById(R.id.user_rank) - var avatar: SimpleDraweeView? = itemView.findViewById(R.id.user_avatar) + var avatar: ImageView? = itemView.findViewById(R.id.user_avatar) var username: TextView? = itemView.findViewById(R.id.user_name) var count: TextView? = itemView.findViewById(R.id.user_count) } @@ -47,7 +48,9 @@ class LeaderboardListAdapter : PagedListAdapter val item = getItem(position)!! rank?.text = item.rank.toString() - avatar?.setImageURI(Uri.parse(item.avatar)) + avatar?.load(item.avatar) { + transformations(CircleCropTransformation()) + } username?.text = item.username count?.text = item.categoryCount.toString() diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt index 34fd5ab5815..74a45612a9c 100644 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt @@ -1,14 +1,15 @@ package fr.free.nrw.commons.profile.leaderboard import android.accounts.AccountManager -import android.net.Uri import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.recyclerview.widget.RecyclerView -import com.facebook.drawee.view.SimpleDraweeView +import coil.load +import coil.transform.CircleCropTransformation import fr.free.nrw.commons.BuildConfig import fr.free.nrw.commons.R import fr.free.nrw.commons.profile.leaderboard.UserDetailAdapter.DataViewHolder @@ -26,7 +27,7 @@ class UserDetailAdapter(private val leaderboardResponse: LeaderboardResponse) : class DataViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val rank: TextView = itemView.findViewById(R.id.rank) - val avatar: SimpleDraweeView = itemView.findViewById(R.id.avatar) + val avatar: ImageView = itemView.findViewById(R.id.avatar) val username: TextView = itemView.findViewById(R.id.username) val count: TextView = itemView.findViewById(R.id.count) } @@ -53,7 +54,9 @@ class UserDetailAdapter(private val leaderboardResponse: LeaderboardResponse) : override fun onBindViewHolder(holder: DataViewHolder, position: Int) = with(holder) { val resources = itemView.context.resources - avatar.setImageURI(Uri.parse(leaderboardResponse.avatar)) + avatar.load(leaderboardResponse.avatar) { + transformations(CircleCropTransformation()) + } username.text = leaderboardResponse.username rank.text = String.format( Locale.getDefault(), diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailsAdapter.kt index d467f9bf63c..902ea76d204 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailsAdapter.kt @@ -1,14 +1,13 @@ package fr.free.nrw.commons.upload import android.graphics.drawable.GradientDrawable -import android.net.Uri import android.view.LayoutInflater import android.view.ViewGroup import android.widget.ImageView import android.widget.RelativeLayout import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView -import com.facebook.drawee.view.SimpleDraweeView +import coil.load import fr.free.nrw.commons.R import fr.free.nrw.commons.databinding.ItemUploadThumbnailBinding import fr.free.nrw.commons.filepicker.UploadableFile @@ -40,7 +39,7 @@ internal class ThumbnailsAdapter(private val callback: Callback) : inner class ViewHolder(val binding: ItemUploadThumbnailBinding) : RecyclerView.ViewHolder(binding.root) { private val rlContainer: RelativeLayout = binding.rlContainer - private val background: SimpleDraweeView = binding.ivThumbnail + private val background: ImageView = binding.ivThumbnail private val ivError: ImageView = binding.ivError private val ivCross: ImageView = binding.icCross @@ -50,7 +49,7 @@ internal class ThumbnailsAdapter(private val callback: Callback) : fun bind(position: Int) { val uploadableFile = uploadableFiles[position] val uri = uploadableFile.getMediaUri() - background.setImageURI(Uri.fromFile(File(uri.toString()))) + background.load(File(uri.toString())) if (position == callback.getCurrentSelectedFilePosition()) { val border = GradientDrawable() border.shape = GradientDrawable.RECTANGLE From c96953c5c50cc642639de5c771bb65e7954e0064 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:50:17 +0000 Subject: [PATCH 06/41] Migrate from Fresco ImageRequest to Coil for image loading Replace Fresco's ImageRequest/ImageRequestBuilder/SimpleDraweeView with Coil's ImageView.load() extension in: - ContributionViewHolder.kt - FailedUploadsAdapter.kt - PendingUploadsAdapter.kt Also update ContributionViewHolderUnitTests to remove assertions on the removed imageRequest field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../contributions/ContributionViewHolder.kt | 31 ++++++------------- .../commons/upload/FailedUploadsAdapter.kt | 20 +++++------- .../commons/upload/PendingUploadsAdapter.kt | 21 ++++++------- .../ContributionViewHolderUnitTests.kt | 3 -- 4 files changed, 26 insertions(+), 49 deletions(-) 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 899ef458f6e..b4d2e259593 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 @@ -6,8 +6,7 @@ import android.view.View import android.webkit.URLUtil import androidx.appcompat.app.AlertDialog import androidx.recyclerview.widget.RecyclerView -import com.facebook.imagepipeline.request.ImageRequest -import com.facebook.imagepipeline.request.ImageRequestBuilder +import coil.load import fr.free.nrw.commons.Media import fr.free.nrw.commons.utils.MediaAttributionUtil import fr.free.nrw.commons.MediaDataExtractor @@ -33,8 +32,6 @@ class ContributionViewHolder internal constructor( private var contribution: Contribution? = null private var isWikipediaButtonDisplayed = false private val pausingPopUp: AlertDialog - var imageRequest: ImageRequest? = null - private set init { binding.contributionImage.setOnClickListener { v: View? -> imageClicked() } @@ -62,30 +59,20 @@ an upload might take a dozen seconds. */ binding.contributionTitle.text = contribution.media.mostRelevantCaption setAuthorText(contribution.media) - //Removes flicker of loading image. - binding.contributionImage.hierarchy.fadeDuration = 0 - - binding.contributionImage.hierarchy.setPlaceholderImage(R.drawable.image_placeholder) - binding.contributionImage.hierarchy.setFailureImage(R.drawable.image_placeholder) - val imageSource = chooseImageSource( contribution.media.thumbUrl, contribution.localUri ) if (!TextUtils.isEmpty(imageSource)) { - if (URLUtil.isHttpsUrl(imageSource)) { - imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageSource)) - .setProgressiveRenderingEnabled(true) - .build() - } else if (URLUtil.isFileUrl(imageSource)) { - imageRequest = ImageRequest.fromUri(Uri.parse(imageSource)) - } else if (imageSource != null) { - val file = File(imageSource) - imageRequest = ImageRequest.fromFile(file) + val data: Any = when { + URLUtil.isHttpsUrl(imageSource) -> imageSource!! + URLUtil.isFileUrl(imageSource) -> Uri.parse(imageSource) + imageSource != null -> File(imageSource) + else -> R.drawable.image_placeholder } - - if (imageRequest != null) { - binding.contributionImage.setImageRequest(imageRequest) + binding.contributionImage.load(data) { + placeholder(R.drawable.image_placeholder) + error(R.drawable.image_placeholder) } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt index 5b3eb51400a..4d8d46c063a 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt @@ -15,7 +15,7 @@ import android.widget.TextView import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import com.facebook.imagepipeline.request.ImageRequest +import coil.load import com.google.android.material.snackbar.Snackbar import fr.free.nrw.commons.R import fr.free.nrw.commons.contributions.Contribution @@ -76,19 +76,16 @@ class FailedUploadsAdapter( if (item != null) { holder.titleTextView.setText(item.media.displayTitle) } - var imageRequest: ImageRequest? = null val imageSource: String = item?.localUri.toString() if (!TextUtils.isEmpty(imageSource)) { - if (URLUtil.isFileUrl(imageSource)) { - imageRequest = ImageRequest.fromUri(Uri.parse(imageSource))!! - } else if (imageSource != null) { - val file = File(imageSource) - imageRequest = ImageRequest.fromFile(file)!! + val data: Any = when { + URLUtil.isFileUrl(imageSource) -> Uri.parse(imageSource) + else -> File(imageSource) } - - if (imageRequest != null) { - holder.itemImage.setImageRequest(imageRequest) + holder.itemImage.load(data) { + placeholder(R.drawable.ic_image_black_24dp) + error(R.drawable.ic_image_black_24dp) } } @@ -109,7 +106,6 @@ class FailedUploadsAdapter( holder.retryButton.setOnClickListener { callback.restartUpload(position) } - holder.itemImage.setImageRequest(imageRequest) } /** @@ -118,7 +114,7 @@ class FailedUploadsAdapter( class ViewHolder( itemView: View, ) : RecyclerView.ViewHolder(itemView) { - var itemImage: com.facebook.drawee.view.SimpleDraweeView = + var itemImage: ImageView = itemView.findViewById(R.id.itemImage) var titleTextView: TextView = itemView.findViewById(R.id.titleTextView) var itemProgress: ProgressBar = itemView.findViewById(R.id.itemProgress) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt index 7f0b8aba19a..5c6f1a9b4e8 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt @@ -15,7 +15,7 @@ import android.widget.TextView import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import com.facebook.imagepipeline.request.ImageRequest +import coil.load import com.google.android.material.snackbar.Snackbar import fr.free.nrw.commons.R import fr.free.nrw.commons.contributions.Contribution @@ -94,7 +94,7 @@ class PendingUploadsAdapter( class ViewHolder( itemView: View, ) : RecyclerView.ViewHolder(itemView) { - var itemImage: com.facebook.drawee.view.SimpleDraweeView = + var itemImage: ImageView = itemView.findViewById(R.id.itemImage) var titleTextView: TextView = itemView.findViewById(R.id.titleTextView) var itemProgress: ProgressBar = itemView.findViewById(R.id.itemProgress) @@ -121,19 +121,16 @@ class PendingUploadsAdapter( } val imageSource: String = contribution.localUri.toString() - var imageRequest: ImageRequest? = null if (!TextUtils.isEmpty(imageSource)) { - if (URLUtil.isFileUrl(imageSource)) { - imageRequest = ImageRequest.fromUri(Uri.parse(imageSource)) - } else { - val file = File(imageSource) - imageRequest = ImageRequest.fromFile(file) + val data: Any = when { + URLUtil.isFileUrl(imageSource) -> Uri.parse(imageSource) + else -> File(imageSource) + } + itemImage.load(data) { + placeholder(R.drawable.ic_image_black_24dp) + error(R.drawable.ic_image_black_24dp) } - } - - if (imageRequest != null) { - itemImage.setImageRequest(imageRequest) } bindState(contribution.state) 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 343cc4377ae..9a4280bf8e1 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 @@ -212,7 +212,6 @@ class ContributionViewHolderUnitTests { `when`(contribution.media.thumbUrl).thenReturn("https://demo/sample.png") `when`(contribution.localUri).thenReturn(null) contributionViewHolder.init(0, contribution) - Assert.assertNotNull(contributionViewHolder.imageRequest) } @Test @@ -223,7 +222,6 @@ class ContributionViewHolderUnitTests { `when`(contribution.media.thumbUrl).thenReturn(null) `when`(contribution.localUri).thenReturn(null) contributionViewHolder.init(0, contribution) - Assert.assertNull(contributionViewHolder.imageRequest) } @Test @@ -234,6 +232,5 @@ class ContributionViewHolderUnitTests { `when`(contribution.media.thumbUrl).thenReturn(null) `when`(contribution.localUri).thenReturn(Uri.parse("/data/android/demo.png")) contributionViewHolder.init(0, contribution) - Assert.assertNotNull(contributionViewHolder.imageRequest) } } From b7c05cd911234d59cd59be6e179aa6882f2e6a81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:55:54 +0000 Subject: [PATCH 07/41] Replace Fresco with Coil in MediaDetailFragment, QuizActivity, and SimilarImageDialogFragment - MediaDetailFragment: Replace Fresco's DraweeController and ImageInfo-based aspect ratio listener with Coil's load() and drawable intrinsic dimensions - QuizActivity: Replace GenericDraweeHierarchyBuilder/setImageURI with Coil load() - SimilarImageDialogFragment: Replace GenericDraweeHierarchyBuilder/setImageURI with Coil load() using File objects directly - Remove all Fresco imports from the three files - Remove unused VectorDrawableCompat, Uri imports where applicable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../nrw/commons/media/MediaDetailFragment.kt | 62 +++++++------------ .../fr/free/nrw/commons/quiz/QuizActivity.kt | 15 ++--- .../upload/SimilarImageDialogFragment.kt | 40 +++--------- 3 files changed, 34 insertions(+), 83 deletions(-) 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 1ae954293fb..c77dc7add78 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 @@ -6,7 +6,7 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration -import android.graphics.drawable.Animatable +import coil.load import android.net.Uri import android.os.Bundle import android.text.Editable @@ -64,12 +64,6 @@ import androidx.compose.ui.unit.sp import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.viewModels -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.drawee.controller.BaseControllerListener -import com.facebook.drawee.controller.ControllerListener -import com.facebook.drawee.interfaces.DraweeController -import com.facebook.imagepipeline.image.ImageInfo -import com.facebook.imagepipeline.request.ImageRequest import fr.free.nrw.commons.BuildConfig import fr.free.nrw.commons.CameraPosition import fr.free.nrw.commons.CommonsApplication @@ -209,7 +203,8 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C * However unlike categories depictions is multi-lingual * Ex: key: en value: monument */ - private var imageInfoCache: ImageInfo? = null + private var cachedImageWidth: Int = 0 + private var cachedImageHeight: Int = 0 private var oldWidthOfImageView: Int = 0 private var newWidthOfImageView: Int = 0 private var heightVerifyingBoolean: Boolean = true // helps in maintaining aspect ratio @@ -675,7 +670,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } /** - * The imageSpacer is Basically a transparent overlay for the SimpleDraweeView + * The imageSpacer is basically a transparent overlay for the ImageView * which holds the image to be displayed( moreover this image is out of * the scroll view ) * @@ -687,8 +682,8 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C * @param scrollWidth the current width of the scrollView */ private fun updateAspectRatio(scrollWidth: Int) { - if (imageInfoCache != null) { - var finalHeight: Int = (scrollWidth * imageInfoCache!!.height) / imageInfoCache!!.width + if (cachedImageWidth > 0 && cachedImageHeight > 0) { + var finalHeight: Int = (scrollWidth * cachedImageHeight) / cachedImageWidth val params: ViewGroup.LayoutParams = binding.mediaDetailImageView.layoutParams val spacerParams: ViewGroup.LayoutParams = binding.mediaDetailImageViewSpacer.layoutParams @@ -709,27 +704,8 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } } - private val aspectRatioListener: ControllerListener = - object : BaseControllerListener() { - override fun onIntermediateImageSet(id: String, imageInfo: ImageInfo?) { - imageInfoCache = imageInfo - updateAspectRatio(binding.mediaDetailScrollView.width) - } - - override fun onFinalImageSet( - id: String, - imageInfo: ImageInfo?, - animatable: Animatable? - ) { - imageInfoCache = imageInfo - updateAspectRatio(binding.mediaDetailScrollView.width) - } - } - /** - * Uses two image sources. - * - low resolution thumbnail is shown initially - * - when the high resolution image is available, it replaces the low resolution image + * Uses Coil to load the media image with a placeholder. */ private fun setupImageView() { val imageBackgroundColor: Int = imageBackgroundColor @@ -737,17 +713,21 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor) } - binding.mediaDetailImageView.hierarchy.setPlaceholderImage(R.drawable.image_placeholder) - binding.mediaDetailImageView.hierarchy.setFailureImage(R.drawable.image_placeholder) + val imageUrl = if (media != null) media!!.imageUrl else null + val thumbUrl = if (media != null) media!!.thumbUrl else null - val controller: DraweeController = Fresco.newDraweeControllerBuilder() - .setLowResImageRequest(ImageRequest.fromUri(if (media != null) media!!.thumbUrl else null)) - .setRetainImageOnFailure(true) - .setImageRequest(ImageRequest.fromUri(if (media != null) media!!.imageUrl else null)) - .setControllerListener(aspectRatioListener) - .setOldController(binding.mediaDetailImageView.controller) - .build() - binding.mediaDetailImageView.controller = controller + binding.mediaDetailImageView.load(imageUrl ?: thumbUrl) { + placeholder(R.drawable.image_placeholder) + error(R.drawable.image_placeholder) + listener( + onSuccess = { _, result -> + val drawable = result.drawable + cachedImageWidth = drawable.intrinsicWidth + cachedImageHeight = drawable.intrinsicHeight + updateAspectRatio(binding.mediaDetailScrollView.width) + } + ) + } } private fun updateToDoWarning() { diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.kt b/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.kt index 11fd1e6a631..b09b765ebc7 100644 --- a/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.kt @@ -8,10 +8,7 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat -import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat - -import com.facebook.drawee.drawable.ProgressBarDrawable -import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder +import coil.load import fr.free.nrw.commons.databinding.ActivityQuizBinding import java.util.ArrayList @@ -94,13 +91,9 @@ class QuizActivity : AppCompatActivity() { binding.question.questionText.text = quiz[questionIndex].question binding.questionTitle.text = getString(R.string.question) + quiz[questionIndex].questionNumber - binding.question.questionImage.hierarchy = GenericDraweeHierarchyBuilder - .newInstance(resources) - .setFailureImage(VectorDrawableCompat.create(resources, R.drawable.ic_error_outline_black_24dp, theme)) - .setProgressBarImage(ProgressBarDrawable()) - .build() - - binding.question.questionImage.setImageURI(quiz[questionIndex].getUrl()) + binding.question.questionImage.load(quiz[questionIndex].getUrl()) { + error(R.drawable.ic_error_outline_black_24dp) + } isPositiveAnswerChecked = false isNegativeAnswerChecked = false diff --git a/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageDialogFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageDialogFragment.kt index c5d82ed1076..99734b7dd28 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageDialogFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageDialogFragment.kt @@ -2,15 +2,13 @@ package fr.free.nrw.commons.upload import android.app.Dialog import android.content.DialogInterface -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.Window import androidx.fragment.app.DialogFragment -import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat -import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder +import coil.load import fr.free.nrw.commons.R import fr.free.nrw.commons.databinding.FragmentSimilarImageDialogBinding import java.io.File @@ -38,35 +36,15 @@ class SimilarImageDialogFragment : DialogFragment() { ): View { _binding = FragmentSimilarImageDialogBinding.inflate(inflater, container, false) - binding.orginalImage.hierarchy = - GenericDraweeHierarchyBuilder.newInstance(resources).setPlaceholderImage( - VectorDrawableCompat.create( - resources, R.drawable.ic_image_black_24dp, requireContext().theme - ) - ).setFailureImage( - VectorDrawableCompat.create( - resources, R.drawable.ic_error_outline_black_24dp, requireContext().theme - ) - ).build() - - binding.possibleImage.hierarchy = - GenericDraweeHierarchyBuilder.newInstance(resources).setPlaceholderImage( - VectorDrawableCompat.create( - resources, R.drawable.ic_image_black_24dp, requireContext().theme - ) - ).setFailureImage( - VectorDrawableCompat.create( - resources, R.drawable.ic_error_outline_black_24dp, requireContext().theme - ) - ).build() - arguments?.let { - binding.orginalImage.setImageURI( - Uri.fromFile(File(it.getString("originalImagePath")!!)) - ) - binding.possibleImage.setImageURI( - Uri.fromFile(File(it.getString("possibleImagePath")!!)) - ) + binding.orginalImage.load(File(it.getString("originalImagePath")!!)) { + placeholder(R.drawable.ic_image_black_24dp) + error(R.drawable.ic_error_outline_black_24dp) + } + binding.possibleImage.load(File(it.getString("possibleImagePath")!!)) { + placeholder(R.drawable.ic_image_black_24dp) + error(R.drawable.ic_error_outline_black_24dp) + } } binding.postiveButton.setOnClickListener { From 6aa944bd922ffbccf1a2d40f3fe5848c65789d02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:58:19 +0000 Subject: [PATCH 08/41] Migrate SetWallpaperWorker and PicOfDayAppWidget from Fresco to Coil Replace Fresco image pipeline bitmap fetching with Coil ImageLoader in: - SetWallpaperWorker: use runBlocking + imageLoader.execute() since doWork() runs on a background thread - PicOfDayAppWidget: use CoroutineScope(Dispatchers.IO) + imageLoader.execute() for async bitmap loading Remove all Fresco imports and unused imports (Uri, Bitmap, Canvas, Paint, @Nullable) from both files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../contributions/SetWallpaperWorker.kt | 51 ++++++++--------- .../nrw/commons/widget/PicOfDayAppWidget.kt | 56 +++++++++---------- 2 files changed, 48 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt index 8e899fcbab6..01a3d98548f 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt @@ -5,19 +5,16 @@ import android.app.NotificationManager import android.app.WallpaperManager import android.content.Context import android.graphics.Bitmap -import android.net.Uri +import android.graphics.drawable.BitmapDrawable import android.os.Build import androidx.core.app.NotificationCompat import androidx.work.Worker import androidx.work.WorkerParameters -import com.facebook.common.executors.CallerThreadExecutor -import com.facebook.common.references.CloseableReference -import com.facebook.datasource.DataSource -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber -import com.facebook.imagepipeline.image.CloseableImage -import com.facebook.imagepipeline.request.ImageRequestBuilder +import coil.ImageLoader +import coil.request.ImageRequest +import coil.request.SuccessResult import fr.free.nrw.commons.R +import kotlinx.coroutines.runBlocking import timber.log.Timber class SetWallpaperWorker(context: Context, params: WorkerParameters) : @@ -29,30 +26,28 @@ class SetWallpaperWorker(context: Context, params: WorkerParameters) : val imageUrl = inputData.getString("imageUrl") ?: return Result.failure() - val imageRequest = ImageRequestBuilder - .newBuilderWithSource(Uri.parse(imageUrl)) - .build() - - val imagePipeline = Fresco.getImagePipeline() - val dataSource = imagePipeline.fetchDecodedImage(imageRequest, context) - - dataSource.subscribe(object : BaseBitmapDataSubscriber() { - public override fun onNewResultImpl(bitmap: Bitmap?) { - if (dataSource.isFinished && bitmap != null) { - Timber.d("Bitmap loaded from url %s", imageUrl.toString()) + return runBlocking { + try { + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(imageUrl) + .allowHardware(false) + .build() + val result = imageLoader.execute(request) + if (result is SuccessResult) { + val bitmap = (result.drawable as BitmapDrawable).bitmap setWallpaper(context, Bitmap.createBitmap(bitmap)) - dataSource.close() + } else { + Timber.d("Error getting bitmap from image url %s", imageUrl) + showNotification(context, "Setting Wallpaper Failed", "Failed to download image.") } - } - - override fun onFailureImpl(dataSource: DataSource?>) { - Timber.d("Error getting bitmap from image url %s", imageUrl.toString()) + Result.success() + } catch (e: Exception) { + Timber.e(e, "Error getting bitmap from image url %s", imageUrl) showNotification(context, "Setting Wallpaper Failed", "Failed to download image.") - dataSource.close() + Result.failure() } - }, CallerThreadExecutor.getInstance()) - - return Result.success() + } } private fun setWallpaper(context: Context, bitmap: Bitmap) { diff --git a/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt b/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt index ab6a45b8573..fc7673c1415 100644 --- a/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt +++ b/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt @@ -5,22 +5,16 @@ import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context import android.content.Intent -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Paint +import android.graphics.drawable.BitmapDrawable import android.net.Uri import android.os.Build import android.widget.RemoteViews -import androidx.annotation.Nullable -import com.facebook.common.executors.CallerThreadExecutor -import com.facebook.common.references.CloseableReference -import com.facebook.datasource.DataSource -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.imagepipeline.core.ImagePipeline -import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber -import com.facebook.imagepipeline.image.CloseableImage -import com.facebook.imagepipeline.request.ImageRequest -import com.facebook.imagepipeline.request.ImageRequestBuilder +import coil.ImageLoader +import coil.request.ImageRequest +import coil.request.SuccessResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import fr.free.nrw.commons.media.MediaClient import javax.inject.Inject import fr.free.nrw.commons.R @@ -117,7 +111,7 @@ class PicOfDayAppWidget : AppWidgetProvider() { } /** - * Uses Fresco to load an image from Url + * Uses Coil to load an image from Url * @param imageUrl The URL of the image to load. * @param context The application context. * @param views The RemoteViews object used to update the App Widget UI. @@ -131,25 +125,25 @@ class PicOfDayAppWidget : AppWidgetProvider() { appWidgetManager: AppWidgetManager, appWidgetId: Int ) { - val request = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageUrl)).build() - val imagePipeline = Fresco.getImagePipeline() - val dataSource = imagePipeline.fetchDecodedImage(request, context) - - dataSource.subscribe(object : BaseBitmapDataSubscriber() { - override fun onNewResultImpl(tempBitmap: Bitmap?) { - val bitmap = tempBitmap?.let { - Bitmap.createBitmap(it.width, it.height, Bitmap.Config.ARGB_8888).apply { - Canvas(this).drawBitmap(it, 0f, 0f, Paint()) - } + if (imageUrl == null) return + + CoroutineScope(Dispatchers.IO).launch { + try { + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(imageUrl) + .allowHardware(false) + .build() + val result = imageLoader.execute(request) + if (result is SuccessResult) { + val bitmap = (result.drawable as BitmapDrawable).bitmap + views.setImageViewBitmap(R.id.appwidget_image, bitmap) + appWidgetManager.updateAppWidget(appWidgetId, views) } - views.setImageViewBitmap(R.id.appwidget_image, bitmap) - appWidgetManager.updateAppWidget(appWidgetId, views) + } catch (e: Exception) { + Timber.e(e, "Error loading image for widget") } - - override fun onFailureImpl(dataSource: DataSource>) { - // Ignore failure for now. - } - }, CallerThreadExecutor.getInstance()) + } } override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { From 50d2e6938e821ceb4724ced3c92f5ebf9a210cbf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:01:28 +0000 Subject: [PATCH 09/41] Replace Glide with Coil 2.x in 4 files - ImageAdapter.kt: Replace 3 Glide.load().thumbnail() calls with Coil load{} - FolderAdapter.kt: Replace Glide.load().into() with Coil load{} - NearbyParentFragment.kt: Replace Glide clear/load with Coil dispose/load - ExploreMapController.kt: Replace Glide CustomTarget with Coil ImageLoader.enqueue() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../ui/adapter/FolderAdapter.kt | 6 ++- .../customselector/ui/adapter/ImageAdapter.kt | 29 ++++++------- .../explore/map/ExploreMapController.kt | 43 ++++++++----------- .../nearby/fragments/NearbyParentFragment.kt | 42 ++++++------------ 4 files changed, 47 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt index 87f68a3e138..4d5ed12d02b 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt @@ -7,7 +7,7 @@ import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide +import coil.load import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.model.Folder @@ -77,7 +77,9 @@ class FolderAdapter( } } else { val previewImage = folder.images[0] - Glide.with(holder.image).load(previewImage.uri).into(holder.image) + holder.image.load(previewImage.uri) { + crossfade(true) + } holder.name.text = folder.name holder.count.text = count.toString() holder.itemView.setOnClickListener { 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 aca4fb94bf7..ff3616c745b 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 @@ -10,7 +10,7 @@ import android.widget.Toast import androidx.constraintlayout.widget.Group import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide +import coil.load import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView import fr.free.nrw.commons.R import fr.free.nrw.commons.contributions.Contribution @@ -223,22 +223,20 @@ class ImageAdapter( val actionableImages: List = ArrayList(actionableImagesMap.values) if (actionableImages.size > position) { image = actionableImages[position] - Glide - .with(holder.image) - .load(image.uri) - .thumbnail(0.3f) - .into(holder.image) + holder.image.load(image.uri) { + crossfade(true) + size(coil.size.Size.ORIGINAL) + } } } // If switch is turned off, it just fetches the image from all images without any // further operations } else { - Glide - .with(holder.image) - .load(image.uri) - .thumbnail(0.3f) - .into(holder.image) + holder.image.load(image.uri) { + crossfade(true) + size(coil.size.Size.ORIGINAL) + } } } @@ -285,11 +283,10 @@ class ImageAdapter( alreadyAddedPositions.add(imagePositionAsPerIncreasingOrder) imagePositionAsPerIncreasingOrder++ _currentImagesCount.value = imagePositionAsPerIncreasingOrder - Glide - .with(holder.image) - .load(allImages[next].uri) - .thumbnail(0.3f) - .into(holder.image) + holder.image.load(allImages[next].uri) { + crossfade(true) + size(coil.size.Size.ORIGINAL) + } notifyItemInserted(position) notifyItemRangeChanged(position, itemCount + 1) } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt index 0873572d1d9..0e414a47893 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt @@ -3,12 +3,10 @@ package fr.free.nrw.commons.explore.map import android.content.Context import android.content.res.Resources import android.graphics.Bitmap -import android.graphics.drawable.Drawable +import android.graphics.drawable.BitmapDrawable import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat -import com.bumptech.glide.Glide -import com.bumptech.glide.request.RequestOptions -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition +import coil.ImageLoader +import coil.request.ImageRequest import fr.free.nrw.commons.BaseMarker import fr.free.nrw.commons.MapController import fr.free.nrw.commons.Media @@ -173,18 +171,16 @@ class ExploreMapController @Inject constructor( ) baseMarker.place = explorePlace - Glide.with(context) - .asBitmap() - .load(explorePlace.thumb) - .placeholder(R.drawable.image_placeholder_96) - .apply(RequestOptions().override(96, 96).centerCrop()) - .into(object : CustomTarget() { - // We add icons to markers when bitmaps are ready - override fun onResourceReady( - resource: Bitmap, - transition: Transition? - ) { - baseMarker.icon = addRedBorder(resource, 6, context) + val imageLoader = ImageLoader.Builder(context) + .build() + val request = ImageRequest.Builder(context) + .data(explorePlace.thumb) + .size(96, 96) + .allowHardware(false) + .target( + onSuccess = { drawable -> + val bitmap = (drawable as BitmapDrawable).bitmap + baseMarker.icon = addRedBorder(bitmap, 6, context) baseMarkerList.add(baseMarker) if (baseMarkerList.size == placeList.size) { // if true, we added all markers to list and can trigger thumbs ready callback @@ -193,13 +189,8 @@ class ExploreMapController @Inject constructor( explorePlacesInfo ) } - } - - override fun onLoadCleared(placeholder: Drawable?) = Unit - - // We add thumbnail icon for images that couldn't be loaded - override fun onLoadFailed(errorDrawable: Drawable?) { - super.onLoadFailed(errorDrawable) + }, + onError = { _ -> baseMarker.fromResource(context, R.drawable.image_placeholder_96) baseMarkerList.add(baseMarker) if (baseMarkerList.size == placeList.size) { @@ -210,7 +201,9 @@ class ExploreMapController @Inject constructor( ) } } - }) + ) + .build() + imageLoader.enqueue(request) } } return baseMarkerList diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt index 61dace3bb69..02da83dd922 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt @@ -45,10 +45,8 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager -import com.bumptech.glide.Glide -import com.bumptech.glide.load.engine.GlideException -import com.bumptech.glide.request.RequestListener -import com.bumptech.glide.request.target.Target +import coil.load +import coil.dispose import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback import com.google.android.material.snackbar.Snackbar @@ -2531,8 +2529,7 @@ class NearbyParentFragment : CommonsDaggerSupportFragment(), } selectedPlace?.pic?.substringAfterLast("/")?.takeIf { it.isNotEmpty() }?.let { imageName -> - Glide.with(binding!!.bottomSheetDetails.icon.context) - .clear(binding!!.bottomSheetDetails.icon) + binding!!.bottomSheetDetails.icon.dispose() val loadingDrawable = ContextCompat.getDrawable( binding!!.bottomSheetDetails.icon.context, @@ -2543,33 +2540,18 @@ class NearbyParentFragment : CommonsDaggerSupportFragment(), R.anim.rotate ) - Glide.with(binding!!.bottomSheetDetails.icon.context) - .load("https://commons.wikimedia.org/wiki/Special:Redirect/file/$imageName?width=25") - .placeholder(loadingDrawable) - .error(selectedPlace!!.label.icon) - .listener(object : RequestListener { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target, - isFirstResource: Boolean - ): Boolean { + binding!!.bottomSheetDetails.icon.load("https://commons.wikimedia.org/wiki/Special:Redirect/file/$imageName?width=25") { + placeholder(loadingDrawable) + error(selectedPlace!!.label.icon) + listener( + onSuccess = { _, _ -> binding!!.bottomSheetDetails.icon.clearAnimation() - return false - } - - override fun onResourceReady( - resource: Drawable, - model: Any, - target: Target?, - dataSource: com.bumptech.glide.load.DataSource, - isFirstResource: Boolean - ): Boolean { + }, + onError = { _, _ -> binding!!.bottomSheetDetails.icon.clearAnimation() - return false } - }) - .into(binding!!.bottomSheetDetails.icon) + ) + } if (binding!!.bottomSheetDetails.icon.drawable != null && binding!!.bottomSheetDetails.icon.drawable.constantState == loadingDrawable?.constantState) { binding!!.bottomSheetDetails.icon.startAnimation(animation) From f08c6baae4b7e599cdcd66f4eb96b9ced3cb3dbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:04:30 +0000 Subject: [PATCH 10/41] Migrate ZoomableActivity from Fresco ZoomableDraweeView to PhotoView + Coil - Remove all Fresco imports (DraweeController, ControllerListener, etc.) - Remove DoubleTapGestureListener import (no longer needed with PhotoView) - Remove loadingListener field (Fresco ControllerListener) - Replace init() to use PhotoView's setOnPhotoTapListener + Coil's load() - Replace getZoomableController().isIdentity() with PhotoView's scale > 1.0f - Add coil.load import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../nrw/commons/media/ZoomableActivity.kt | 86 +++++-------------- 1 file changed, 20 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt index dc6e2377844..cfdc3e81d7c 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.app.Dialog import android.content.Intent import android.content.SharedPreferences -import android.graphics.drawable.Animatable import android.net.Uri import android.os.Bundle import android.view.View @@ -15,14 +14,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.ViewModelProvider -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.drawee.controller.BaseControllerListener -import com.facebook.drawee.controller.ControllerListener -import com.facebook.drawee.drawable.ProgressBarDrawable -import com.facebook.drawee.drawable.ScalingUtils -import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder -import com.facebook.drawee.interfaces.DraweeController -import com.facebook.imagepipeline.image.ImageInfo +import coil.load import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.database.NotForUploadStatus import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao @@ -37,7 +29,6 @@ import fr.free.nrw.commons.customselector.model.Result import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorViewModel import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorViewModelFactory import fr.free.nrw.commons.databinding.ActivityZoomableBinding -import fr.free.nrw.commons.media.zoomControllers.zoomable.DoubleTapGestureListener import fr.free.nrw.commons.theme.BaseActivity import fr.free.nrw.commons.upload.FileProcessor import fr.free.nrw.commons.upload.FileUtilsWrapper @@ -281,7 +272,7 @@ class ZoomableActivity : BaseActivity() { * Handles down swipe action */ private fun onDownSwiped() { - if (!binding.zoomable.getZoomableController().isIdentity()) { + if (binding.zoomable.scale > 1.0f) { return } @@ -351,7 +342,7 @@ class ZoomableActivity : BaseActivity() { * Handles up swipe action */ private fun onUpSwiped() { - if (!binding.zoomable.getZoomableController().isIdentity()) { + if (binding.zoomable.scale > 1.0f) { return } @@ -424,7 +415,7 @@ class ZoomableActivity : BaseActivity() { * Handles right swipe action */ private fun onRightSwiped(showAlreadyActionedImages: Boolean) { - if (!binding.zoomable.getZoomableController().isIdentity()) { + if (binding.zoomable.scale > 1.0f) { return } @@ -461,7 +452,7 @@ class ZoomableActivity : BaseActivity() { * Handles left swipe action */ private fun onLeftSwiped(showAlreadyActionedImages: Boolean) { - if (!binding.zoomable.getZoomableController().isIdentity()) { + if (binding.zoomable.scale > 1.0f) { return } @@ -615,62 +606,25 @@ class ZoomableActivity : BaseActivity() { image: Image, ): Int = list!!.indexOf(image) - /** - * Two types of loading indicators have been added to the zoom activity: - * 1. An Indeterminate spinner for showing the time lapsed between dispatch of the image request - * and starting to receiving the image. - * 2. ProgressBarDrawable that reflects how much image has been downloaded - */ - private val loadingListener: ControllerListener = - object : BaseControllerListener() { - override fun onSubmit( - id: String, - callerContext: Any, - ) { - // Sometimes the spinner doesn't appear when rapidly switching between images, this fixes that - binding.zoomProgressBar.visibility = View.VISIBLE - } - - override fun onIntermediateImageSet( - id: String, - imageInfo: ImageInfo?, - ) { - binding.zoomProgressBar.visibility = View.GONE - } + private fun init(imageUri: Uri?) { + if (imageUri != null) { + binding.zoomProgressBar.visibility = View.VISIBLE - override fun onFinalImageSet( - id: String, - imageInfo: ImageInfo?, - animatable: Animatable?, - ) { - binding.zoomProgressBar.visibility = View.GONE + // PhotoView provides built-in zoom/pan + binding.zoomable.setOnPhotoTapListener { _, _, _ -> + toggleSystemBars() } - } - private fun init(imageUri: Uri?) { - if (imageUri != null) { - val hierarchy = - GenericDraweeHierarchyBuilder - .newInstance(resources) - .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) - .setProgressBarImage(ProgressBarDrawable()) - .setProgressBarImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) - .build() - with(binding.zoomable) { - setHierarchy(hierarchy) - setAllowTouchInterceptionWhileZoomed(true) - setIsLongpressEnabled(false) - setTapListener(DoubleTapGestureListener(this) { - toggleSystemBars() - }) + binding.zoomable.load(imageUri) { + listener( + onSuccess = { _, _ -> + binding.zoomProgressBar.visibility = View.GONE + }, + onError = { _, _ -> + binding.zoomProgressBar.visibility = View.GONE + }, + ) } - val controller: DraweeController = - Fresco - .newDraweeControllerBuilder() - .setUri(imageUri) - .setControllerListener(loadingListener) - .build() - binding.zoomable.controller = controller if (photoBackgroundColor != null) { binding.zoomable.setBackgroundColor(photoBackgroundColor!!) From 020fdd8b3660cf7e6d75e0a92be4a5ecbbe608d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:08:05 +0000 Subject: [PATCH 11/41] Remove Fresco initialization and imports from test files Remove all Fresco-related imports (com.facebook.drawee.backends.pipeline.Fresco, com.facebook.soloader.SoLoader, com.facebook.drawee.view.SimpleDraweeView, com.facebook.drawee.generic.GenericDraweeHierarchy) and initialization calls (SoLoader.setInTestMode(), Fresco.initialize()) from 13 test files. Replace SimpleDraweeView mock fields with ImageView to match updated layout XML definitions. Clean up now-unused ApplicationProvider imports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../category/GridViewAdapterUnitTest.kt | 7 +------ .../ContributionViewHolderUnitTests.kt | 6 +----- .../ui/selector/FolderFragmentTest.kt | 5 +---- .../ui/selector/ImageFragmentTest.kt | 5 +---- .../LeaderboardListAdapterUnitTests.kt | 10 +++------ .../media/MediaDetailFragmentUnitTests.kt | 21 +++++-------------- .../MediaDetailPagerFragmentUnitTests.kt | 7 +------ .../media/ZoomableActivityUnitTests.kt | 5 +---- .../nrw/commons/quiz/QuizActivityUnitTest.kt | 6 +----- .../nrw/commons/quiz/QuizCheckerUnitTest.kt | 6 +----- .../nrw/commons/review/ReviewActivityTest.kt | 7 +------ .../commons/review/ReviewControllerTest.kt | 5 +---- .../commons/review/ReviewImageFragmentTest.kt | 5 +---- 13 files changed, 19 insertions(+), 76 deletions(-) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/category/GridViewAdapterUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/category/GridViewAdapterUnitTest.kt index 6fb400f3e80..f07266b7db0 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/category/GridViewAdapterUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/category/GridViewAdapterUnitTest.kt @@ -6,8 +6,7 @@ import android.view.View import android.view.ViewGroup import android.widget.TextView 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.R import fr.free.nrw.commons.TestCommonsApplication @@ -50,10 +49,6 @@ class GridViewAdapterUnitTest { context = ApplicationProvider.getApplicationContext() - SoLoader.setInTestMode() - - Fresco.initialize(context) - activity = Robolectric.buildActivity(CategoryDetailsActivity::class.java).get() convertView = 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 9a4280bf8e1..4b53a4bc654 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 @@ -4,9 +4,7 @@ import android.net.Uri import android.os.Looper import android.view.LayoutInflater import android.view.View -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 @@ -67,8 +65,6 @@ class ContributionViewHolderUnitTests { @Before fun setUp() { MockitoAnnotations.initMocks(this) - 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) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/FolderFragmentTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/FolderFragmentTest.kt index 49da532591b..95e033289e9 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/FolderFragmentTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/FolderFragmentTest.kt @@ -10,8 +10,7 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ApplicationProvider -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.soloader.SoLoader + import fr.free.nrw.commons.OkHttpConnectionFactory import fr.free.nrw.commons.R import fr.free.nrw.commons.TestCommonsApplication @@ -62,8 +61,6 @@ class FolderFragmentTest { MockitoAnnotations.initMocks(this) context = ApplicationProvider.getApplicationContext() OkHttpConnectionFactory.CLIENT = createTestClient() - SoLoader.setInTestMode() - Fresco.initialize(context) val activity = Robolectric.buildActivity(CustomSelectorActivity::class.java).create().get() fragment = FolderFragment.newInstance() diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageFragmentTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageFragmentTest.kt index eeb6db46a59..2e06a302049 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageFragmentTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageFragmentTest.kt @@ -12,8 +12,7 @@ import androidx.fragment.app.FragmentTransaction import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ApplicationProvider -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.soloader.SoLoader + import com.nhaarman.mockitokotlin2.whenever import fr.free.nrw.commons.OkHttpConnectionFactory import fr.free.nrw.commons.R @@ -78,8 +77,6 @@ class ImageFragmentTest { MockitoAnnotations.initMocks(this) context = ApplicationProvider.getApplicationContext() OkHttpConnectionFactory.CLIENT = createTestClient() - SoLoader.setInTestMode() - Fresco.initialize(context) activity = Robolectric.buildActivity(CustomSelectorActivity::class.java).create().get() fragment = ImageFragment.newInstance(1, 0) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/LeaderboardListAdapterUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/LeaderboardListAdapterUnitTests.kt index 50932d67b9f..04fc5c5a3bf 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/LeaderboardListAdapterUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/LeaderboardListAdapterUnitTests.kt @@ -5,9 +5,7 @@ import android.widget.TextView import androidx.paging.PagedList import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ApplicationProvider -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.drawee.view.SimpleDraweeView -import com.facebook.soloader.SoLoader +import android.widget.ImageView import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.profile.leaderboard.LeaderboardList import fr.free.nrw.commons.profile.leaderboard.LeaderboardListAdapter @@ -38,14 +36,12 @@ class LeaderboardListAdapterUnitTests { private lateinit var textView: TextView @Mock - private lateinit var simpleDraweeView: SimpleDraweeView + private lateinit var imageView: ImageView @Before fun setUp() { context = ApplicationProvider.getApplicationContext() MockitoAnnotations.initMocks(this) - SoLoader.setInTestMode() - Fresco.initialize(context) adapter = LeaderboardListAdapter() @@ -57,7 +53,7 @@ class LeaderboardListAdapterUnitTests { val avatar: Field = LeaderboardListAdapter.ListViewHolder::class.java.getDeclaredField("avatar") avatar.isAccessible = true - avatar.set(viewHolder, simpleDraweeView) + avatar.set(viewHolder, imageView) val username: Field = LeaderboardListAdapter.ListViewHolder::class.java.getDeclaredField("username") diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailFragmentUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailFragmentUnitTests.kt index 6159a3ccf0f..181ec1de79c 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailFragmentUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailFragmentUnitTests.kt @@ -21,10 +21,7 @@ import android.widget.TextView import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction import androidx.test.core.app.ApplicationProvider -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.drawee.generic.GenericDraweeHierarchy -import com.facebook.drawee.view.SimpleDraweeView -import com.facebook.soloader.SoLoader +import android.widget.ImageView import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.whenever import fr.free.nrw.commons.locationpicker.LocationPickerActivity @@ -114,7 +111,7 @@ class MediaDetailFragmentUnitTests { private lateinit var media: Media @Mock - private lateinit var simpleDraweeView: SimpleDraweeView + private lateinit var imageView: ImageView @Mock private lateinit var textView: TextView @@ -125,9 +122,6 @@ class MediaDetailFragmentUnitTests { @Mock private lateinit var linearLayout: LinearLayout - @Mock - private lateinit var genericDraweeHierarchy: GenericDraweeHierarchy - @Mock private lateinit var button: Button @@ -169,10 +163,6 @@ class MediaDetailFragmentUnitTests { context = ApplicationProvider.getApplicationContext() OkHttpConnectionFactory.CLIENT = createTestClient() - SoLoader.setInTestMode() - - Fresco.initialize(ApplicationProvider.getApplicationContext()) - activity = Robolectric.buildActivity(SearchActivity::class.java).create().get() fragment = MediaDetailFragment() @@ -198,14 +188,13 @@ class MediaDetailFragmentUnitTests { Whitebox.setInternalState(fragment, "deleteHelper", deleteHelper) Whitebox.setInternalState(fragment, "_binding", _binding) Whitebox.setInternalState(fragment, "detailProvider", detailProvider) - Whitebox.setInternalState(_binding, "mediaDetailImageView", simpleDraweeView) + Whitebox.setInternalState(_binding, "mediaDetailImageView", imageView) Whitebox.setInternalState(_binding, "mediaDetailTitle", textView) Whitebox.setInternalState(_binding, "mediaDetailDepictionContainer", linearLayout) Whitebox.setInternalState(_binding, "dummyCaptionDescriptionContainer", linearLayout) Whitebox.setInternalState(_binding, "depictionsEditButton", button) Whitebox.setInternalState(fragment, "locationManager", locationManager) - `when`(simpleDraweeView.hierarchy).thenReturn(genericDraweeHierarchy) val map = HashMap() map[Locale.getDefault().language] = "" `when`(media.descriptions).thenReturn(map) @@ -860,7 +849,7 @@ class MediaDetailFragmentUnitTests { spyFragment.onImageBackgroundChanged(color) - verify(simpleDraweeView, times(1)).setBackgroundColor(color) + verify(imageView, times(1)).setBackgroundColor(color) verify(mockSharedPreferencesEditor, times(1)).putInt(anyString(), anyInt()) } @@ -872,7 +861,7 @@ class MediaDetailFragmentUnitTests { doReturn(color).`when`(mockSharedPreferences).getInt(anyString(), anyInt()) spyFragment.onImageBackgroundChanged(color) - verify(simpleDraweeView, never()).setBackgroundColor(anyInt()) + verify(imageView, never()).setBackgroundColor(anyInt()) verify(mockSharedPreferencesEditor, never()).putInt(anyString(), anyInt()) } } diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailPagerFragmentUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailPagerFragmentUnitTests.kt index a6c925543f1..b668d00a1f0 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailPagerFragmentUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailPagerFragmentUnitTests.kt @@ -10,8 +10,7 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction import androidx.test.core.app.ApplicationProvider import androidx.work.testing.WorkManagerTestInitHelper -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.soloader.SoLoader + import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.never @@ -86,10 +85,6 @@ class MediaDetailPagerFragmentUnitTests { OkHttpConnectionFactory.CLIENT = createTestClient() - SoLoader.setInTestMode() - - Fresco.initialize(context) - val activity = Robolectric.buildActivity(SearchActivity::class.java).create().get() binding = FragmentMediaDetailPagerBinding.inflate(activity.layoutInflater) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/ZoomableActivityUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/ZoomableActivityUnitTests.kt index 848e0881aa5..5277d269642 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/media/ZoomableActivityUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/media/ZoomableActivityUnitTests.kt @@ -4,8 +4,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.test.core.app.ApplicationProvider -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.soloader.SoLoader + import fr.free.nrw.commons.OkHttpConnectionFactory import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.createTestClient @@ -45,8 +44,6 @@ class ZoomableActivityUnitTests { MockitoAnnotations.openMocks(this) OkHttpConnectionFactory.CLIENT = createTestClient() context = ApplicationProvider.getApplicationContext() - SoLoader.setInTestMode() - Fresco.initialize(context) val intent = Intent().setData(uri) activity = Robolectric.buildActivity(ZoomableActivity::class.java, intent).create().get() diff --git a/app/src/test/kotlin/fr/free/nrw/commons/quiz/QuizActivityUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/quiz/QuizActivityUnitTest.kt index a0508ea5ea5..19adbbb2470 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/quiz/QuizActivityUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/quiz/QuizActivityUnitTest.kt @@ -4,9 +4,7 @@ import android.content.Context import android.view.LayoutInflater import android.view.View import android.widget.Button -import androidx.test.core.app.ApplicationProvider -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.soloader.SoLoader + import fr.free.nrw.commons.R import fr.free.nrw.commons.TestCommonsApplication import org.junit.Assert @@ -40,8 +38,6 @@ class QuizActivityUnitTest { @Before fun setUp() { MockitoAnnotations.openMocks(this) - SoLoader.setInTestMode() - Fresco.initialize(ApplicationProvider.getApplicationContext()) activity = Robolectric.buildActivity(QuizActivity::class.java).create().get() context = mock(Context::class.java) view = diff --git a/app/src/test/kotlin/fr/free/nrw/commons/quiz/QuizCheckerUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/quiz/QuizCheckerUnitTest.kt index 0b7498592cc..02112c21052 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/quiz/QuizCheckerUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/quiz/QuizCheckerUnitTest.kt @@ -1,9 +1,7 @@ package fr.free.nrw.commons.quiz import android.app.Activity -import androidx.test.core.app.ApplicationProvider -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.soloader.SoLoader + import com.nhaarman.mockitokotlin2.any import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.auth.SessionManager @@ -45,8 +43,6 @@ class QuizCheckerUnitTest { @Before fun setUp() { MockitoAnnotations.openMocks(this) - SoLoader.setInTestMode() - Fresco.initialize(ApplicationProvider.getApplicationContext()) activity = Robolectric.buildActivity(QuizActivity::class.java).create().get() quizChecker = QuizChecker(sessionManager, okHttpJsonApiClient, jsonKvStore) Mockito.`when`(sessionManager.userName).thenReturn("") diff --git a/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewActivityTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewActivityTest.kt index f2faf769acc..4371922fee5 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewActivityTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewActivityTest.kt @@ -5,8 +5,7 @@ import android.os.Looper.getMainLooper import android.view.Menu import android.view.MenuItem import androidx.test.core.app.ApplicationProvider -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.soloader.SoLoader + import com.nhaarman.mockitokotlin2.doNothing import fr.free.nrw.commons.Media import fr.free.nrw.commons.OkHttpConnectionFactory @@ -70,10 +69,6 @@ class ReviewActivityTest { OkHttpConnectionFactory.CLIENT = createTestClient() - SoLoader.setInTestMode() - - Fresco.initialize(context) - activity = Robolectric.buildActivity(ReviewActivity::class.java).create().get() binding = ActivityReviewBinding.inflate(activity.layoutInflater) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewControllerTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewControllerTest.kt index 08237c5d924..2a7e761266b 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewControllerTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewControllerTest.kt @@ -4,8 +4,7 @@ import android.app.Activity import android.content.Context import android.os.Looper import androidx.test.core.app.ApplicationProvider -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.soloader.SoLoader + import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever import fr.free.nrw.commons.Media @@ -58,8 +57,6 @@ class ReviewControllerTest { MockitoAnnotations.openMocks(this) context = ApplicationProvider.getApplicationContext() OkHttpConnectionFactory.CLIENT = createTestClient() - SoLoader.setInTestMode() - Fresco.initialize(context) activity = Robolectric.buildActivity(ReviewActivity::class.java).create().get() controller = ReviewController(deleteHelper, context) media = media(filename = "test_file", dateUploaded = Date()) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewImageFragmentTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewImageFragmentTest.kt index bf4bd18784f..cb687a31471 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewImageFragmentTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/review/ReviewImageFragmentTest.kt @@ -10,8 +10,7 @@ import android.widget.TextView import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction import androidx.test.core.app.ApplicationProvider -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.soloader.SoLoader + import com.nhaarman.mockitokotlin2.doReturn import fr.free.nrw.commons.Media import fr.free.nrw.commons.OkHttpConnectionFactory @@ -65,9 +64,7 @@ class ReviewImageFragmentTest { MockitoAnnotations.openMocks(this) context = ApplicationProvider.getApplicationContext() OkHttpConnectionFactory.CLIENT = createTestClient() - SoLoader.setInTestMode() - Fresco.initialize(context) activity = Robolectric.buildActivity(ReviewActivity::class.java).create().get() fragment = ReviewImageFragment() val bundle = Bundle() From aa90847c248af6059a72820169b4d1110016f733 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:12:59 +0000 Subject: [PATCH 12/41] Migrate from Fresco and Glide to Coil for image loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Fresco and Glide dependencies with Coil 2.7.0 - Update all XML layouts: SimpleDraweeView → ImageView, ZoomableDraweeView → PhotoView - Update CommonsApplication: Replace Fresco init with Coil ImageLoader setup - Update all adapters and fragments to use Coil's load() extension - Replace Fresco bitmap pipeline with Coil ImageRequest in widget/worker - Replace Glide usage in custom selector, nearby, and explore map - Replace ZoomableDraweeView with PhotoView in ZoomableActivity - Remove CustomOkHttpNetworkFetcher (Fresco-specific) - Remove zoomControllers directory (Fresco-dependent, replaced by PhotoView) - Update all test files to remove Fresco initialization Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/942f4288-cb09-4721-856d-8b3a085759d4 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../gestures/MultiPointerGestureDetector.kt | 252 -------- .../gestures/TransformGestureDetector.kt | 154 ----- .../AbstractAnimatedZoomableController.kt | 147 ----- .../zoomable/AnimatedZoomableController.kt | 75 --- .../zoomable/DefaultZoomableController.kt | 607 ------------------ .../zoomable/DoubleTapGestureListener.kt | 96 --- .../zoomable/GestureListenerWrapper.kt | 61 -- .../zoomable/MultiGestureListener.kt | 150 ----- .../MultiZoomableControllerListener.kt | 46 -- .../zoomable/ZoomableController.kt | 120 ---- .../zoomable/ZoomableDraweeView.kt | 320 --------- .../fragment_upload_media_detail_fragment.xml | 2 +- .../CustomOkHttpNetworkFetcherUnitTest.kt | 242 ------- .../MultiPointerGestureDetectorUnitTest.kt | 179 ------ .../TransformGestureDetectorUnitTest.kt | 187 ------ .../widget/PicOfDayAppWidgetUnitTests.kt | 4 - 16 files changed, 1 insertion(+), 2641 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/MultiPointerGestureDetector.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/TransformGestureDetector.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AbstractAnimatedZoomableController.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AnimatedZoomableController.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DefaultZoomableController.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DoubleTapGestureListener.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/GestureListenerWrapper.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiGestureListener.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiZoomableControllerListener.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableController.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableDraweeView.kt delete mode 100644 app/src/test/kotlin/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcherUnitTest.kt delete mode 100644 app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/MultiPointerGestureDetectorUnitTest.kt delete mode 100644 app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/TransformGestureDetectorUnitTest.kt diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/MultiPointerGestureDetector.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/MultiPointerGestureDetector.kt deleted file mode 100644 index 84dccfc0732..00000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/MultiPointerGestureDetector.kt +++ /dev/null @@ -1,252 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers.gestures - -import android.view.MotionEvent - -/** - * Component that detects and tracks multiple pointers based on touch events. - * - * Each time a pointer gets pressed or released, the current gesture (if any) will end, and a new - * one will be started (if there are still pressed pointers left). It is guaranteed that the number - * of pointers within the single gesture will remain the same during the whole gesture. - */ -open class MultiPointerGestureDetector { - - /** The listener for receiving notifications when gestures occur. */ - interface Listener { - /** A callback called right before the gesture is about to start. */ - fun onGestureBegin(detector: MultiPointerGestureDetector) - - /** A callback called each time the gesture gets updated. */ - fun onGestureUpdate(detector: MultiPointerGestureDetector) - - /** A callback called right after the gesture has finished. */ - fun onGestureEnd(detector: MultiPointerGestureDetector) - } - - companion object { - private const val MAX_POINTERS = 2 - - /** Factory method that creates a new instance of MultiPointerGestureDetector */ - fun newInstance(): MultiPointerGestureDetector { - return MultiPointerGestureDetector() - } - } - - private var mGestureInProgress = false - private var mPointerCount = 0 - private var mNewPointerCount = 0 - private val mId = IntArray(MAX_POINTERS) { MotionEvent.INVALID_POINTER_ID } - private val mStartX = FloatArray(MAX_POINTERS) - private val mStartY = FloatArray(MAX_POINTERS) - private val mCurrentX = FloatArray(MAX_POINTERS) - private val mCurrentY = FloatArray(MAX_POINTERS) - - private var mListener: Listener? = null - - init { - reset() - } - - /** - * Sets the listener. - * - * @param listener listener to set - */ - fun setListener(listener: Listener?) { - mListener = listener - } - - /** Resets the component to the initial state. */ - fun reset() { - mGestureInProgress = false - mPointerCount = 0 - for (i in 0 until MAX_POINTERS) { - mId[i] = MotionEvent.INVALID_POINTER_ID - } - } - - /** - * This method can be overridden in order to perform threshold check or something similar. - * - * @return whether or not to start a new gesture - */ - protected open fun shouldStartGesture(): Boolean { - return true - } - - /** Starts a new gesture and calls the listener just before starting it. */ - private fun startGesture() { - if (!mGestureInProgress) { - mListener?.onGestureBegin(this) - mGestureInProgress = true - } - } - - /** Stops the current gesture and calls the listener right after stopping it. */ - private fun stopGesture() { - if (mGestureInProgress) { - mGestureInProgress = false - mListener?.onGestureEnd(this) - } - } - - /** - * Gets the index of the i-th pressed pointer. Normally, the index will be equal to i, except in - * the case when the pointer is released. - * - * @return index of the specified pointer or -1 if not found (i.e. not enough pointers are down) - */ - private fun getPressedPointerIndex(event: MotionEvent, i: Int): Int { - val count = event.pointerCount - val action = event.actionMasked - val index = event.actionIndex - var adjustedIndex = i - - if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) { - if (adjustedIndex >= index) { - adjustedIndex++ - } - } - return if (adjustedIndex < count) adjustedIndex else -1 - } - - /** Gets the number of pressed pointers (fingers down). */ - private fun getPressedPointerCount(event: MotionEvent): Int { - var count = event.pointerCount - val action = event.actionMasked - if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) { - count-- - } - return count - } - - private fun updatePointersOnTap(event: MotionEvent) { - mPointerCount = 0 - for (i in 0 until MAX_POINTERS) { - val index = getPressedPointerIndex(event, i) - if (index == -1) { - mId[i] = MotionEvent.INVALID_POINTER_ID - } else { - mId[i] = event.getPointerId(index) - mCurrentX[i] = event.getX(index) - mStartX[i] = mCurrentX[i] - mCurrentY[i] = event.getY(index) - mStartY[i] = mCurrentY[i] - mPointerCount++ - } - } - } - - private fun updatePointersOnMove(event: MotionEvent) { - for (i in 0 until MAX_POINTERS) { - val index = event.findPointerIndex(mId[i]) - if (index != -1) { - mCurrentX[i] = event.getX(index) - mCurrentY[i] = event.getY(index) - } - } - } - - /** - * Handles the given motion event. - * - * @param event event to handle - * @return whether or not the event was handled - */ - fun onTouchEvent(event: MotionEvent): Boolean { - when (event.actionMasked) { - MotionEvent.ACTION_MOVE -> { - // update pointers - updatePointersOnMove(event) - // start a new gesture if not already started - if (!mGestureInProgress && mPointerCount > 0 && shouldStartGesture()) { - startGesture() - } - // notify listener - if (mGestureInProgress) { - mListener?.onGestureUpdate(this) - } - } - - MotionEvent.ACTION_DOWN, - MotionEvent.ACTION_POINTER_DOWN, - MotionEvent.ACTION_POINTER_UP, - MotionEvent.ACTION_UP -> { - // restart gesture whenever the number of pointers changes - mNewPointerCount = getPressedPointerCount(event) - stopGesture() - updatePointersOnTap(event) - if (mPointerCount > 0 && shouldStartGesture()) { - startGesture() - } - } - - MotionEvent.ACTION_CANCEL -> { - mNewPointerCount = 0 - stopGesture() - reset() - } - } - return true - } - - /** Restarts the current gesture (if any). */ - fun restartGesture() { - if (!mGestureInProgress) { - return - } - stopGesture() - for (i in 0 until MAX_POINTERS) { - mStartX[i] = mCurrentX[i] - mStartY[i] = mCurrentY[i] - } - startGesture() - } - - /** Gets whether there is a gesture in progress */ - fun isGestureInProgress(): Boolean { - return mGestureInProgress - } - - /** Gets the number of pointers after the current gesture */ - fun getNewPointerCount(): Int { - return mNewPointerCount - } - - /** Gets the number of pointers in the current gesture */ - fun getPointerCount(): Int { - return mPointerCount - } - - /** - * Gets the start X coordinates for all pointers Mutable array is exposed for performance - * reasons and is not to be modified by the callers. - */ - fun getStartX(): FloatArray { - return mStartX - } - - /** - * Gets the start Y coordinates for all pointers Mutable array is exposed for performance - * reasons and is not to be modified by the callers. - */ - fun getStartY(): FloatArray { - return mStartY - } - - /** - * Gets the current X coordinates for all pointers Mutable array is exposed for performance - * reasons and is not to be modified by the callers. - */ - fun getCurrentX(): FloatArray { - return mCurrentX - } - - /** - * Gets the current Y coordinates for all pointers Mutable array is exposed for performance - * reasons and is not to be modified by the callers. - */ - fun getCurrentY(): FloatArray { - return mCurrentY - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/TransformGestureDetector.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/TransformGestureDetector.kt deleted file mode 100644 index 9dd0b5813f0..00000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/gestures/TransformGestureDetector.kt +++ /dev/null @@ -1,154 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers.gestures - -import android.view.MotionEvent -import kotlin.math.atan2 -import kotlin.math.hypot - -/** - * Component that detects translation, scale and rotation based on touch events. - * - * This class notifies its listeners whenever a gesture begins, updates or ends. The instance of - * this detector is passed to the listeners, so it can be queried for pivot, translation, scale or - * rotation. - */ -class TransformGestureDetector(private val mDetector: MultiPointerGestureDetector) : - MultiPointerGestureDetector.Listener { - - /** The listener for receiving notifications when gestures occur. */ - interface Listener { - /** A callback called right before the gesture is about to start. */ - fun onGestureBegin(detector: TransformGestureDetector) - - /** A callback called each time the gesture gets updated. */ - fun onGestureUpdate(detector: TransformGestureDetector) - - /** A callback called right after the gesture has finished. */ - fun onGestureEnd(detector: TransformGestureDetector) - } - - private var mListener: Listener? = null - - init { - mDetector.setListener(this) - } - - /** Factory method that creates a new instance of TransformGestureDetector */ - companion object { - fun newInstance(): TransformGestureDetector { - return TransformGestureDetector(MultiPointerGestureDetector.newInstance()) - } - } - - /** - * Sets the listener. - * - * @param listener listener to set - */ - fun setListener(listener: Listener?) { - mListener = listener - } - - /** Resets the component to the initial state. */ - fun reset() { - mDetector.reset() - } - - /** - * Handles the given motion event. - * - * @param event event to handle - * @return whether or not the event was handled - */ - fun onTouchEvent(event: MotionEvent): Boolean { - return mDetector.onTouchEvent(event) - } - - override fun onGestureBegin(detector: MultiPointerGestureDetector) { - mListener?.onGestureBegin(this) - } - - override fun onGestureUpdate(detector: MultiPointerGestureDetector) { - mListener?.onGestureUpdate(this) - } - - override fun onGestureEnd(detector: MultiPointerGestureDetector) { - mListener?.onGestureEnd(this) - } - - private fun calcAverage(arr: FloatArray, len: Int): Float { - val sum = arr.take(len).sum() - return if (len > 0) sum / len else 0f - } - - /** Restarts the current gesture (if any). */ - fun restartGesture() { - mDetector.restartGesture() - } - - /** Gets whether there is a gesture in progress */ - fun isGestureInProgress(): Boolean { - return mDetector.isGestureInProgress() - } - - /** Gets the number of pointers after the current gesture */ - fun getNewPointerCount(): Int { - return mDetector.getNewPointerCount() - } - - /** Gets the number of pointers in the current gesture */ - fun getPointerCount(): Int { - return mDetector.getPointerCount() - } - - /** Gets the X coordinate of the pivot point */ - fun getPivotX(): Float { - return calcAverage(mDetector.getStartX(), mDetector.getPointerCount()) - } - - /** Gets the Y coordinate of the pivot point */ - fun getPivotY(): Float { - return calcAverage(mDetector.getStartY(), mDetector.getPointerCount()) - } - - /** Gets the X component of the translation */ - fun getTranslationX(): Float { - return calcAverage(mDetector.getCurrentX(), mDetector.getPointerCount()) - - calcAverage(mDetector.getStartX(), mDetector.getPointerCount()) - } - - /** Gets the Y component of the translation */ - fun getTranslationY(): Float { - return calcAverage(mDetector.getCurrentY(), mDetector.getPointerCount()) - - calcAverage(mDetector.getStartY(), mDetector.getPointerCount()) - } - - /** Gets the scale */ - fun getScale(): Float { - return if (mDetector.getPointerCount() < 2) { - 1f - } else { - val startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0] - val startDeltaY = mDetector.getStartY()[1] - mDetector.getStartY()[0] - val currentDeltaX = mDetector.getCurrentX()[1] - mDetector.getCurrentX()[0] - val currentDeltaY = mDetector.getCurrentY()[1] - mDetector.getCurrentY()[0] - val startDist = hypot(startDeltaX, startDeltaY) - val currentDist = hypot(currentDeltaX, currentDeltaY) - currentDist / startDist - } - } - - /** Gets the rotation in radians */ - fun getRotation(): Float { - return if (mDetector.getPointerCount() < 2) { - 0f - } else { - val startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0] - val startDeltaY = mDetector.getStartY()[1] - mDetector.getStartY()[0] - val currentDeltaX = mDetector.getCurrentX()[1] - mDetector.getCurrentX()[0] - val currentDeltaY = mDetector.getCurrentY()[1] - mDetector.getCurrentY()[0] - val startAngle = atan2(startDeltaY, startDeltaX) - val currentAngle = atan2(currentDeltaY, currentDeltaX) - currentAngle - startAngle - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AbstractAnimatedZoomableController.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AbstractAnimatedZoomableController.kt deleted file mode 100644 index 27aa7fe2f6d..00000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AbstractAnimatedZoomableController.kt +++ /dev/null @@ -1,147 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers.zoomable - -import android.graphics.Matrix -import android.graphics.PointF -import com.facebook.common.logging.FLog -import fr.free.nrw.commons.media.zoomControllers.gestures.TransformGestureDetector - -/** - * Abstract class for ZoomableController that adds animation capabilities to - * DefaultZoomableController. - */ -abstract class AbstractAnimatedZoomableController( - transformGestureDetector: TransformGestureDetector -) : DefaultZoomableController(transformGestureDetector) { - - private var isAnimating: Boolean = false - private val startValues = FloatArray(9) - private val stopValues = FloatArray(9) - private val currentValues = FloatArray(9) - private val newTransform = Matrix() - private val workingTransform = Matrix() - - override fun reset() { - FLog.v(logTag, "reset") - stopAnimation() - workingTransform.reset() - newTransform.reset() - super.reset() - } - - /** Returns true if the zoomable transform is identity matrix, and the controller is idle. */ - override fun isIdentity(): Boolean { - return !isAnimating && super.isIdentity() - } - - /** - * Zooms to the desired scale and positions the image so that the given image point corresponds - * to the given view point. - * - * If this method is called while an animation or gesture is already in progress, the current - * animation or gesture will be stopped first. - * - * @param scale desired scale, will be limited to {min, max} scale factor - * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) - * @param viewPoint 2D point in view's absolute coordinate system - */ - override fun zoomToPoint(scale: Float, imagePoint: PointF, viewPoint: PointF) { - zoomToPoint(scale, imagePoint, viewPoint, LIMIT_ALL, 0, null) - } - - /** - * Zooms to the desired scale and positions the image so that the given image point corresponds - * to the given view point. - * - * If this method is called while an animation or gesture is already in progress, the current - * animation or gesture will be stopped first. - * - * @param scale desired scale, will be limited to {min, max} scale factor - * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) - * @param viewPoint 2D point in view's absolute coordinate system - * @param limitFlags whether to limit translation and/or scale. - * @param durationMs length of animation of the zoom, or 0 if no animation desired - * @param onAnimationComplete code to run when the animation completes. Ignored if durationMs=0 - */ - fun zoomToPoint( - scale: Float, - imagePoint: PointF, - viewPoint: PointF, - @LimitFlag limitFlags: Int, - durationMs: Long, - onAnimationComplete: Runnable? - ) { - FLog.v(logTag, "zoomToPoint: duration $durationMs ms") - calculateZoomToPointTransform(newTransform, scale, imagePoint, viewPoint, limitFlags) - setTransform(newTransform, durationMs, onAnimationComplete) - } - - /** - * Sets a new zoomable transformation and animates to it if desired. - * - * If this method is called while an animation or gesture is already in progress, the current - * animation or gesture will be stopped first. - * - * @param newTransform new transform to make active - * @param durationMs duration of the animation, or 0 to not animate - * @param onAnimationComplete code to run when the animation completes. Ignored if durationMs=0 - */ - private fun setTransform( - newTransform: Matrix, - durationMs: Long, - onAnimationComplete: Runnable? - ) { - FLog.v(logTag, "setTransform: duration $durationMs ms") - if (durationMs <= 0) { - setTransformImmediate(newTransform) - } else { - setTransformAnimated(newTransform, durationMs, onAnimationComplete) - } - } - - private fun setTransformImmediate(newTransform: Matrix) { - FLog.v(logTag, "setTransformImmediate") - stopAnimation() - workingTransform.set(newTransform) - super.setTransform(newTransform) - getDetector().restartGesture() - } - - protected fun getIsAnimating(): Boolean = isAnimating - - protected fun setAnimating(isAnimating: Boolean) { - this.isAnimating = isAnimating - } - - protected fun getStartValues(): FloatArray = startValues - - protected fun getStopValues(): FloatArray = stopValues - - protected fun getWorkingTransform(): Matrix = workingTransform - - override fun onGestureBegin(detector: TransformGestureDetector) { - FLog.v(logTag, "onGestureBegin") - stopAnimation() - super.onGestureBegin(detector) - } - - override fun onGestureUpdate(detector: TransformGestureDetector) { - FLog.v(logTag, "onGestureUpdate ${if (isAnimating) "(ignored)" else ""}") - if (isAnimating) return - super.onGestureUpdate(detector) - } - - protected fun calculateInterpolation(outMatrix: Matrix, fraction: Float) { - for (i in 0..8) { - currentValues[i] = (1 - fraction) * startValues[i] + fraction * stopValues[i] - } - outMatrix.setValues(currentValues) - } - - abstract fun setTransformAnimated( - newTransform: Matrix, durationMs: Long, onAnimationComplete: Runnable? - ) - - protected abstract fun stopAnimation() - - protected abstract val logTag: Class<*> -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AnimatedZoomableController.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AnimatedZoomableController.kt deleted file mode 100644 index 6ba9270078c..00000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/AnimatedZoomableController.kt +++ /dev/null @@ -1,75 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers.zoomable - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.ValueAnimator -import android.annotation.SuppressLint -import android.graphics.Matrix -import android.view.animation.DecelerateInterpolator -import com.facebook.common.logging.FLog -import fr.free.nrw.commons.media.zoomControllers.gestures.TransformGestureDetector - -/** - * ZoomableController that adds animation capabilities to DefaultZoomableController using standard - * Android animation classes - */ -class AnimatedZoomableController private constructor() : - AbstractAnimatedZoomableController(TransformGestureDetector.newInstance()) { - - private val valueAnimator: ValueAnimator = ValueAnimator.ofFloat(0f, 1f).apply { - interpolator = DecelerateInterpolator() - } - - companion object { - fun newInstance(): AnimatedZoomableController { - return AnimatedZoomableController() - } - } - - @SuppressLint("NewApi") - override fun setTransformAnimated( - newTransform: Matrix, durationMs: Long, onAnimationComplete: Runnable? - ) { - FLog.v(logTag, "setTransformAnimated: duration $durationMs ms") - stopAnimation() - require(durationMs > 0) { "Duration must be greater than zero" } - check(!getIsAnimating()) { "Animation is already in progress" } - setAnimating(true) - valueAnimator.duration = durationMs - getTransform().getValues(getStartValues()) - newTransform.getValues(getStopValues()) - valueAnimator.addUpdateListener { animator -> - calculateInterpolation(getWorkingTransform(), animator.animatedValue as Float) - super.setTransform(getWorkingTransform()) - } - valueAnimator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationCancel(animation: Animator) { - FLog.v(logTag, "setTransformAnimated: animation cancelled") - onAnimationStopped() - } - - override fun onAnimationEnd(animation: Animator) { - FLog.v(logTag, "setTransformAnimated: animation finished") - onAnimationStopped() - } - - private fun onAnimationStopped() { - onAnimationComplete?.run() - setAnimating(false) - getDetector().restartGesture() - } - }) - valueAnimator.start() - } - - @SuppressLint("NewApi") - override fun stopAnimation() { - if (!getIsAnimating()) return - FLog.v(logTag, "stopAnimation") - valueAnimator.cancel() - valueAnimator.removeAllUpdateListeners() - valueAnimator.removeAllListeners() - } - - override val logTag: Class<*> = AnimatedZoomableController::class.java -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DefaultZoomableController.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DefaultZoomableController.kt deleted file mode 100644 index 3d6498f6a2d..00000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DefaultZoomableController.kt +++ /dev/null @@ -1,607 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers.zoomable - -import android.graphics.Matrix -import android.graphics.PointF -import android.graphics.RectF -import android.view.MotionEvent -import androidx.annotation.IntDef -import com.facebook.common.logging.FLog -import fr.free.nrw.commons.media.zoomControllers.gestures.TransformGestureDetector -import kotlin.math.abs - -/** Zoomable controller that calculates transformation based on touch events. */ -open class DefaultZoomableController( - private val mGestureDetector: TransformGestureDetector -) : ZoomableController, TransformGestureDetector.Listener { - - /** Interface for handling call backs when the image bounds are set. */ - fun interface ImageBoundsListener { - fun onImageBoundsSet(imageBounds: RectF) - } - - @IntDef( - flag = true, - value = [LIMIT_NONE, LIMIT_TRANSLATION_X, LIMIT_TRANSLATION_Y, LIMIT_SCALE, LIMIT_ALL] - ) - @Retention - annotation class LimitFlag - - companion object { - const val LIMIT_NONE = 0 - const val LIMIT_TRANSLATION_X = 1 - const val LIMIT_TRANSLATION_Y = 2 - const val LIMIT_SCALE = 4 - const val LIMIT_ALL = LIMIT_TRANSLATION_X or LIMIT_TRANSLATION_Y or LIMIT_SCALE - - private const val EPS = 1e-3f - - private val TAG: Class<*> = DefaultZoomableController::class.java - - private val IDENTITY_RECT = RectF(0f, 0f, 1f, 1f) - - fun newInstance(): DefaultZoomableController { - return DefaultZoomableController(TransformGestureDetector.newInstance()) - } - } - - private var mImageBoundsListener: ImageBoundsListener? = null - - private var mListener: ZoomableController.Listener? = null - - private var mIsEnabled = false - private var mIsRotationEnabled = false - private var mIsScaleEnabled = true - private var mIsTranslationEnabled = true - private var mIsGestureZoomEnabled = true - - private var mMinScaleFactor = 1.0f - private var mMaxScaleFactor = 2.0f - - // View bounds, in view-absolute coordinates - private val mViewBounds = RectF() - // Non-transformed image bounds, in view-absolute coordinates - private val mImageBounds = RectF() - // Transformed image bounds, in view-absolute coordinates - private val mTransformedImageBounds = RectF() - - private val mPreviousTransform = Matrix() - private val mActiveTransform = Matrix() - private val mActiveTransformInverse = Matrix() - private val mTempValues = FloatArray(9) - private val mTempRect = RectF() - private var mWasTransformCorrected = false - - init { - mGestureDetector.setListener(this) - } - - /** Rests the controller. */ - open fun reset() { - FLog.v(TAG, "reset") - mGestureDetector.reset() - mPreviousTransform.reset() - mActiveTransform.reset() - onTransformChanged() - } - - /** Sets the zoomable listener. */ - override fun setListener(listener: ZoomableController.Listener?) { - mListener = listener - } - - /** Sets whether the controller is enabled or not. */ - override fun setEnabled(enabled: Boolean) { - mIsEnabled = enabled - if (!enabled) { - reset() - } - } - - /** Gets whether the controller is enabled or not. */ - override fun isEnabled(): Boolean { - return mIsEnabled - } - - /** Sets whether the rotation gesture is enabled or not. */ - fun setRotationEnabled(enabled: Boolean) { - mIsRotationEnabled = enabled - } - - /** Gets whether the rotation gesture is enabled or not. */ - fun isRotationEnabled(): Boolean { - return mIsRotationEnabled - } - - /** Sets whether the scale gesture is enabled or not. */ - fun setScaleEnabled(enabled: Boolean) { - mIsScaleEnabled = enabled - } - - /** Gets whether the scale gesture is enabled or not. */ - fun isScaleEnabled(): Boolean { - return mIsScaleEnabled - } - - /** Sets whether the translation gesture is enabled or not. */ - fun setTranslationEnabled(enabled: Boolean) { - mIsTranslationEnabled = enabled - } - - /** Gets whether the translations gesture is enabled or not. */ - fun isTranslationEnabled(): Boolean { - return mIsTranslationEnabled - } - - /** - * Sets the minimum scale factor allowed. - * - *

Hierarchy's scaling (if any) is not taken into account. - */ - fun setMinScaleFactor(minScaleFactor: Float) { - mMinScaleFactor = minScaleFactor - } - - /** Gets the minimum scale factor allowed. */ - fun getMinScaleFactor(): Float { - return mMinScaleFactor - } - - /** - * Sets the maximum scale factor allowed. - * - *

Hierarchy's scaling (if any) is not taken into account. - */ - fun setMaxScaleFactor(maxScaleFactor: Float) { - mMaxScaleFactor = maxScaleFactor - } - - /** Gets the maximum scale factor allowed. */ - fun getMaxScaleFactor(): Float { - return mMaxScaleFactor - } - - /** Sets whether gesture zooms are enabled or not. */ - fun setGestureZoomEnabled(isGestureZoomEnabled: Boolean) { - mIsGestureZoomEnabled = isGestureZoomEnabled - } - - /** Gets whether gesture zooms are enabled or not. */ - fun isGestureZoomEnabled(): Boolean { - return mIsGestureZoomEnabled - } - - /** Gets the current scale factor. */ - override fun getScaleFactor(): Float { - return getMatrixScaleFactor(mActiveTransform) - } - - /** Sets the image bounds, in view-absolute coordinates. */ - override fun setImageBounds(imageBounds: RectF) { - if (imageBounds != mImageBounds) { - mImageBounds.set(imageBounds) - onTransformChanged() - mImageBoundsListener?.onImageBoundsSet(mImageBounds) - } - } - - /** Gets the non-transformed image bounds, in view-absolute coordinates. */ - fun getImageBounds(): RectF { - return mImageBounds - } - - /** Gets the transformed image bounds, in view-absolute coordinates */ - private fun getTransformedImageBounds(): RectF { - return mTransformedImageBounds - } - - /** Sets the view bounds. */ - override fun setViewBounds(viewBounds: RectF) { - mViewBounds.set(viewBounds) - } - - /** Gets the view bounds. */ - fun getViewBounds(): RectF { - return mViewBounds - } - - /** Sets the image bounds listener. */ - fun setImageBoundsListener(imageBoundsListener: ImageBoundsListener?) { - mImageBoundsListener = imageBoundsListener - } - - /** Gets the image bounds listener. */ - fun getImageBoundsListener(): ImageBoundsListener? { - return mImageBoundsListener - } - - /** Returns true if the zoomable transform is identity matrix. */ - override fun isIdentity(): Boolean { - return isMatrixIdentity(mActiveTransform, 1e-3f) - } - - /** - * Returns true if the transform was corrected during the last update. - * - *

We should rename this method to `wasTransformedWithoutCorrection` and just return the - * internal flag directly. However, this requires interface change and negation of meaning. - */ - override fun wasTransformCorrected(): Boolean { - return mWasTransformCorrected - } - - /** - * Gets the matrix that transforms image-absolute coordinates to view-absolute coordinates. The - * zoomable transformation is taken into account. - * - *

Internal matrix is exposed for performance reasons and is not to be modified by the - * callers. - */ - override fun getTransform(): Matrix { - return mActiveTransform - } - - /** - * Gets the matrix that transforms image-relative coordinates to view-absolute coordinates. The - * zoomable transformation is taken into account. - */ - fun getImageRelativeToViewAbsoluteTransform(outMatrix: Matrix) { - outMatrix.setRectToRect(IDENTITY_RECT, mTransformedImageBounds, Matrix.ScaleToFit.FILL) - } - - /** - * Maps point from view-absolute to image-relative coordinates. This takes into account the - * zoomable transformation. - */ - fun mapViewToImage(viewPoint: PointF): PointF { - val points = mTempValues - points[0] = viewPoint.x - points[1] = viewPoint.y - mActiveTransform.invert(mActiveTransformInverse) - mActiveTransformInverse.mapPoints(points, 0, points, 0, 1) - mapAbsoluteToRelative(points, points, 1) - return PointF(points[0], points[1]) - } - - /** - * Maps point from image-relative to view-absolute coordinates. This takes into account the - * zoomable transformation. - */ - fun mapImageToView(imagePoint: PointF): PointF { - val points = mTempValues - points[0] = imagePoint.x - points[1] = imagePoint.y - mapRelativeToAbsolute(points, points, 1) - mActiveTransform.mapPoints(points, 0, points, 0, 1) - return PointF(points[0], points[1]) - } - - /** - * Maps array of 2D points from view-absolute to image-relative coordinates. This does NOT take - * into account the zoomable transformation. Points are represented by a float array of [x0, y0, - * x1, y1, ...]. - * - * @param destPoints destination array (may be the same as source array) - * @param srcPoints source array - * @param numPoints number of points to map - */ - private fun mapAbsoluteToRelative( - destPoints: FloatArray, - srcPoints: FloatArray, - numPoints: Int - ) { - for (i in 0 until numPoints) { - destPoints[i * 2] = (srcPoints[i * 2] - mImageBounds.left) / mImageBounds.width() - destPoints[i * 2 + 1] = - (srcPoints[i * 2 + 1] - mImageBounds.top) / mImageBounds.height() - } - } - - /** - * Maps array of 2D points from image-relative to view-absolute coordinates. This does NOT take - * into account the zoomable transformation. Points are represented by float array of - * [x0, y0, x1, y1, ...]. - * - * @param destPoints destination array (may be the same as source array) - * @param srcPoints source array - * @param numPoints number of points to map - */ - private fun mapRelativeToAbsolute( - destPoints: FloatArray, - srcPoints: FloatArray, - numPoints: Int - ) { - for (i in 0 until numPoints) { - destPoints[i * 2] = srcPoints[i * 2] * mImageBounds.width() + mImageBounds.left - destPoints[i * 2 + 1] = srcPoints[i * 2 + 1] * mImageBounds.height() + mImageBounds.top - } - } - - /** - * Zooms to the desired scale and positions the image so that the given image point - * corresponds to the given view point. - * - * @param scale desired scale, will be limited to {min, max} scale factor - * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) - * @param viewPoint 2D point in view's absolute coordinate system - */ - open fun zoomToPoint(scale: Float, imagePoint: PointF, viewPoint: PointF) { - FLog.v(TAG, "zoomToPoint") - calculateZoomToPointTransform(mActiveTransform, scale, imagePoint, viewPoint, LIMIT_ALL) - onTransformChanged() - } - - /** - * Calculates the zoom transformation that would zoom to the desired scale and position - * the image so that the given image point corresponds to the given view point. - * - * @param outTransform the matrix to store the result to - * @param scale desired scale, will be limited to {min, max} scale factor - * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) - * @param viewPoint 2D point in view's absolute coordinate system - * @param limitFlags whether to limit translation and/or scale. - * @return whether or not the transform has been corrected due to limitation - */ - protected fun calculateZoomToPointTransform( - outTransform: Matrix, - scale: Float, - imagePoint: PointF, - viewPoint: PointF, - @LimitFlag limitFlags: Int - ): Boolean { - val viewAbsolute = mTempValues - viewAbsolute[0] = imagePoint.x - viewAbsolute[1] = imagePoint.y - mapRelativeToAbsolute(viewAbsolute, viewAbsolute, 1) - val distanceX = viewPoint.x - viewAbsolute[0] - val distanceY = viewPoint.y - viewAbsolute[1] - var transformCorrected = false - outTransform.setScale(scale, scale, viewAbsolute[0], viewAbsolute[1]) - transformCorrected = transformCorrected or - limitScale(outTransform, viewAbsolute[0], viewAbsolute[1], limitFlags) - outTransform.postTranslate(distanceX, distanceY) - transformCorrected = transformCorrected or limitTranslation(outTransform, limitFlags) - return transformCorrected - } - - /** Sets a new zoom transformation. */ - fun setTransform(newTransform: Matrix) { - FLog.v(TAG, "setTransform") - mActiveTransform.set(newTransform) - onTransformChanged() - } - - /** Gets the gesture detector. */ - protected fun getDetector(): TransformGestureDetector { - return mGestureDetector - } - - /** Notifies controller of the received touch event. */ - override fun onTouchEvent(event: MotionEvent): Boolean { - FLog.v(TAG, "onTouchEvent: action: ", event.action) - return if (mIsEnabled && mIsGestureZoomEnabled) { - mGestureDetector.onTouchEvent(event) - } else { - false - } - } - - /* TransformGestureDetector.Listener methods */ - - override fun onGestureBegin(detector: TransformGestureDetector) { - FLog.v(TAG, "onGestureBegin") - mPreviousTransform.set(mActiveTransform) - onTransformBegin() - mWasTransformCorrected = !canScrollInAllDirection() - } - - override fun onGestureUpdate(detector: TransformGestureDetector) { - FLog.v(TAG, "onGestureUpdate") - val transformCorrected = calculateGestureTransform(mActiveTransform, LIMIT_ALL) - onTransformChanged() - if (transformCorrected) { - mGestureDetector.restartGesture() - } - mWasTransformCorrected = transformCorrected - } - - override fun onGestureEnd(detector: TransformGestureDetector) { - FLog.v(TAG, "onGestureEnd") - onTransformEnd() - } - - /** - * Calculates the zoom transformation based on the current gesture. - * - * @param outTransform the matrix to store the result to - * @param limitTypes whether to limit translation and/or scale. - * @return whether or not the transform has been corrected due to limitation - */ - private fun calculateGestureTransform( - outTransform: Matrix, - @LimitFlag limitTypes: Int - ): Boolean { - val detector = mGestureDetector - var transformCorrected = false - outTransform.set(mPreviousTransform) - if (mIsRotationEnabled) { - val angle = detector.getRotation() * (180 / Math.PI).toFloat() - outTransform.postRotate(angle, detector.getPivotX(), detector.getPivotY()) - } - if (mIsScaleEnabled) { - val scale = detector.getScale() - outTransform.postScale(scale, scale, detector.getPivotX(), detector.getPivotY()) - } - transformCorrected = transformCorrected or limitScale( - outTransform, - detector.getPivotX(), - detector.getPivotY(), - limitTypes - ) - if (mIsTranslationEnabled) { - outTransform.postTranslate(detector.getTranslationX(), detector.getTranslationY()) - } - transformCorrected = transformCorrected or limitTranslation(outTransform, limitTypes) - return transformCorrected - } - - private fun onTransformBegin() { - if (mListener != null && isEnabled()) { - mListener?.onTransformBegin(mActiveTransform) - } - } - - private fun onTransformChanged() { - mActiveTransform.mapRect(mTransformedImageBounds, mImageBounds) - if (mListener != null && isEnabled()) { - mListener?.onTransformChanged(mActiveTransform) - } - } - - private fun onTransformEnd() { - if (mListener != null && isEnabled()) { - mListener?.onTransformEnd(mActiveTransform) - } - } - - /** - * Keeps the scaling factor within the specified limits. - * - * @param pivotX x coordinate of the pivot point - * @param pivotY y coordinate of the pivot point - * @param limitTypes whether to limit scale. - * @return whether limiting has been applied or not - */ - private fun limitScale( - transform: Matrix, pivotX: Float, pivotY: Float, @LimitFlag limitTypes: Int - ): Boolean { - if (!shouldLimit(limitTypes, LIMIT_SCALE)) { - return false - } - val currentScale = getMatrixScaleFactor(transform) - val targetScale = limit(currentScale, mMinScaleFactor, mMaxScaleFactor) - return if (targetScale != currentScale) { - val scale = targetScale / currentScale - transform.postScale(scale, scale, pivotX, pivotY) - true - } else { - false - } - } - - /** - * Limits the translation so that there are no empty spaces on the sides if possible. - */ - private fun limitTranslation(transform: Matrix, @LimitFlag limitTypes: Int): Boolean { - if (!shouldLimit(limitTypes, LIMIT_TRANSLATION_X or LIMIT_TRANSLATION_Y)) { - return false - } - val b = mTempRect - b.set(mImageBounds) - transform.mapRect(b) - val offsetLeft = - if (shouldLimit(limitTypes, LIMIT_TRANSLATION_X)) getOffset( - b.left, b.right, mViewBounds.left, mViewBounds.right, mImageBounds.centerX() - ) else 0f - val offsetTop = - if (shouldLimit(limitTypes, LIMIT_TRANSLATION_Y)) getOffset( - b.top, b.bottom, mViewBounds.top, mViewBounds.bottom, mImageBounds.centerY() - ) else 0f - - return if (offsetLeft != 0f || offsetTop != 0f) { - transform.postTranslate(offsetLeft, offsetTop) - true - } else { - false - } - } - - /** - * Checks whether the specified limit flag is present in the limits provided. - */ - private fun shouldLimit(@LimitFlag limits: Int, @LimitFlag flag: Int): Boolean { - return (limits and flag) != LIMIT_NONE - } - - /** - * Returns the offset necessary to make sure that: - * - The image is centered if it's smaller than the limit - * - There is no empty space if the image is bigger than the limit - */ - private fun getOffset( - imageStart: Float, imageEnd: Float, limitStart: Float, limitEnd: Float, limitCenter: Float - ): Float { - val imageWidth = imageEnd - imageStart - val limitWidth = limitEnd - limitStart - val limitInnerWidth = minOf(limitCenter - limitStart, limitEnd - limitCenter) * 2 - - return when { - imageWidth < limitInnerWidth -> limitCenter - (imageEnd + imageStart) / 2 - imageWidth < limitWidth -> if (limitCenter < (limitStart + limitEnd) / 2) { - limitStart - imageStart - } else { - limitEnd - imageEnd - } - imageStart > limitStart -> limitStart - imageStart - imageEnd < limitEnd -> limitEnd - imageEnd - else -> 0f - } - } - - /** Limits the value to the given min and max range. */ - private fun limit(value: Float, min: Float, max: Float): Float { - return min.coerceAtLeast(value).coerceAtMost(max) - } - - /** - * Gets the scale factor for the given matrix. Assumes equal scaling for X and Y axis. - */ - private fun getMatrixScaleFactor(transform: Matrix): Float { - transform.getValues(mTempValues) - return mTempValues[Matrix.MSCALE_X] - } - - /** Checks if the matrix is an identity matrix within a given tolerance `eps`. */ - private fun isMatrixIdentity(transform: Matrix, eps: Float): Boolean { - transform.getValues(mTempValues) - mTempValues[0] -= 1.0f // m00 - mTempValues[4] -= 1.0f // m11 - mTempValues[8] -= 1.0f // m22 - return mTempValues.all { abs(it) <= eps } - } - - /** Returns whether the scroll can happen in all directions. */ - private fun canScrollInAllDirection(): Boolean { - return mTransformedImageBounds.left < mViewBounds.left - EPS && - mTransformedImageBounds.top < mViewBounds.top - EPS && - mTransformedImageBounds.right > mViewBounds.right + EPS && - mTransformedImageBounds.bottom > mViewBounds.bottom + EPS - } - - override fun computeHorizontalScrollRange(): Int { - return mTransformedImageBounds.width().toInt() - } - - override fun computeHorizontalScrollOffset(): Int { - return (mViewBounds.left - mTransformedImageBounds.left).toInt() - } - - override fun computeHorizontalScrollExtent(): Int { - return mViewBounds.width().toInt() - } - - override fun computeVerticalScrollRange(): Int { - return mTransformedImageBounds.height().toInt() - } - - override fun computeVerticalScrollOffset(): Int { - return (mViewBounds.top - mTransformedImageBounds.top).toInt() - } - - override fun computeVerticalScrollExtent(): Int { - return mViewBounds.height().toInt() - } - - fun getListener(): ZoomableController.Listener? { - return mListener - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DoubleTapGestureListener.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DoubleTapGestureListener.kt deleted file mode 100644 index 5f9a256e650..00000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/DoubleTapGestureListener.kt +++ /dev/null @@ -1,96 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers.zoomable - -import android.graphics.PointF -import android.view.GestureDetector -import android.view.MotionEvent -import kotlin.math.abs -import kotlin.math.hypot - -/** - * Tap gesture listener for double tap to zoom/unzoom and double-tap-and-drag to zoom. - * - * @see ZoomableDraweeView.setTapListener - */ -class DoubleTapGestureListener( - private val draweeView: ZoomableDraweeView, - private val onSingleTap: (() -> Unit)? = null -) : GestureDetector.SimpleOnGestureListener() { - - companion object { - private const val DURATION_MS = 300L - private const val DOUBLE_TAP_SCROLL_THRESHOLD = 20 - } - - private val doubleTapViewPoint = PointF() - private val doubleTapImagePoint = PointF() - private var doubleTapScale = 1f - private var doubleTapScroll = false - - override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - if(onSingleTap != null) { - onSingleTap.invoke() - return true - } else { - return super.onSingleTapConfirmed(e) - } - } - - override fun onDoubleTapEvent(e: MotionEvent): Boolean { - val zc = draweeView.getZoomableController() as AbstractAnimatedZoomableController - val vp = PointF(e.x, e.y) - val ip = zc.mapViewToImage(vp) - - when (e.actionMasked) { - MotionEvent.ACTION_DOWN -> { - doubleTapViewPoint.set(vp) - doubleTapImagePoint.set(ip) - doubleTapScale = zc.getScaleFactor() - } - - MotionEvent.ACTION_MOVE -> { - doubleTapScroll = doubleTapScroll || shouldStartDoubleTapScroll(vp) - if (doubleTapScroll) { - val scale = calcScale(vp) - zc.zoomToPoint(scale, doubleTapImagePoint, doubleTapViewPoint) - } - } - - MotionEvent.ACTION_UP -> { - if (doubleTapScroll) { - val scale = calcScale(vp) - zc.zoomToPoint(scale, doubleTapImagePoint, doubleTapViewPoint) - } else { - val maxScale = zc.getMaxScaleFactor() - val minScale = zc.getMinScaleFactor() - val targetScale = - if (zc.getScaleFactor() < (maxScale + minScale) / 2) maxScale else minScale - - zc.zoomToPoint( - targetScale, - ip, - vp, - DefaultZoomableController.LIMIT_ALL, - DURATION_MS, - null - ) - } - doubleTapScroll = false - } - } - return true - } - - private fun shouldStartDoubleTapScroll(viewPoint: PointF): Boolean { - val dist = hypot( - (viewPoint.x - doubleTapViewPoint.x).toDouble(), - (viewPoint.y - doubleTapViewPoint.y).toDouble() - ) - return dist > DOUBLE_TAP_SCROLL_THRESHOLD - } - - private fun calcScale(currentViewPoint: PointF): Float { - val dy = currentViewPoint.y - doubleTapViewPoint.y - val t = 1 + abs(dy) * 0.001f - return if (dy < 0) doubleTapScale / t else doubleTapScale * t - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/GestureListenerWrapper.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/GestureListenerWrapper.kt deleted file mode 100644 index 994e98cabf7..00000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/GestureListenerWrapper.kt +++ /dev/null @@ -1,61 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers.zoomable - -import android.view.GestureDetector -import android.view.MotionEvent - -/** Wrapper for SimpleOnGestureListener as GestureDetector does not allow changing its listener. */ -class GestureListenerWrapper : GestureDetector.SimpleOnGestureListener() { - - private var delegate: GestureDetector.SimpleOnGestureListener = - GestureDetector.SimpleOnGestureListener() - - fun setListener(listener: GestureDetector.SimpleOnGestureListener) { - delegate = listener - } - - override fun onLongPress(e: MotionEvent) { - delegate.onLongPress(e) - } - - override fun onScroll( - e1: MotionEvent?, - e2: MotionEvent, - distanceX: Float, - distanceY: Float - ): Boolean { - return delegate.onScroll(e1, e2, distanceX, distanceY) - } - - override fun onFling( - e1: MotionEvent?, - e2: MotionEvent, - velocityX: Float, - velocityY: Float - ): Boolean { - return delegate.onFling(e1, e2, velocityX, velocityY) - } - - override fun onShowPress(e: MotionEvent) { - delegate.onShowPress(e) - } - - override fun onDown(e: MotionEvent): Boolean { - return delegate.onDown(e) - } - - override fun onDoubleTap(e: MotionEvent): Boolean { - return delegate.onDoubleTap(e) - } - - override fun onDoubleTapEvent(e: MotionEvent): Boolean { - return delegate.onDoubleTapEvent(e) - } - - override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - return delegate.onSingleTapConfirmed(e) - } - - override fun onSingleTapUp(e: MotionEvent): Boolean { - return delegate.onSingleTapUp(e) - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiGestureListener.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiGestureListener.kt deleted file mode 100644 index 70f7921c12e..00000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiGestureListener.kt +++ /dev/null @@ -1,150 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers.zoomable - -import android.os.Build -import android.view.GestureDetector -import android.view.MotionEvent -import androidx.annotation.RequiresApi -import java.util.Collections.synchronizedList - -/** - * Gesture listener that allows multiple child listeners to be added and notified about gesture - * events. - * - * NOTE: The order of the listeners is important. Listeners can consume gesture events. For - * example, if one of the child listeners consumes [onLongPress] (the listener returned true), - * subsequent listeners will not be notified about the event anymore since it has been consumed. - */ -class MultiGestureListener : GestureDetector.SimpleOnGestureListener() { - - private val listeners: MutableList = - synchronizedList(mutableListOf()) - - /** - * Adds a listener to the multi-gesture listener. - * - * NOTE: The order of the listeners is important since gesture events can be consumed. - * - * @param listener the listener to be added - */ - @Synchronized - fun addListener(listener: GestureDetector.SimpleOnGestureListener) { - listeners.add(listener) - } - - /** - * Removes the given listener so that it will not be notified about future events. - * - * NOTE: The order of the listeners is important since gesture events can be consumed. - * - * @param listener the listener to remove - */ - @Synchronized - fun removeListener(listener: GestureDetector.SimpleOnGestureListener) { - listeners.remove(listener) - } - - @Synchronized - override fun onSingleTapUp(e: MotionEvent): Boolean { - for (listener in listeners) { - if (listener.onSingleTapUp(e)) { - return true - } - } - return false - } - - @Synchronized - override fun onLongPress(e: MotionEvent) { - for (listener in listeners) { - listener.onLongPress(e) - } - } - - @Synchronized - override fun onScroll( - e1: MotionEvent?, - e2: MotionEvent, - distanceX: Float, - distanceY: Float - ): Boolean { - for (listener in listeners) { - if (listener.onScroll(e1, e2, distanceX, distanceY)) { - return true - } - } - return false - } - - @Synchronized - override fun onFling( - e1: MotionEvent?, - e2: MotionEvent, - velocityX: Float, - velocityY: Float - ): Boolean { - for (listener in listeners) { - if (listener.onFling(e1, e2, velocityX, velocityY)) { - return true - } - } - return false - } - - @Synchronized - override fun onShowPress(e: MotionEvent) { - for (listener in listeners) { - listener.onShowPress(e) - } - } - - @Synchronized - override fun onDown(e: MotionEvent): Boolean { - for (listener in listeners) { - if (listener.onDown(e)) { - return true - } - } - return false - } - - @Synchronized - override fun onDoubleTap(e: MotionEvent): Boolean { - for (listener in listeners) { - if (listener.onDoubleTap(e)) { - return true - } - } - return false - } - - @Synchronized - override fun onDoubleTapEvent(e: MotionEvent): Boolean { - for (listener in listeners) { - if (listener.onDoubleTapEvent(e)) { - return true - } - } - return false - } - - @Synchronized - override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - for (listener in listeners) { - if (listener.onSingleTapConfirmed(e)) { - return true - } - } - return false - } - - @RequiresApi(Build.VERSION_CODES.M) - @Synchronized - override fun onContextClick(e: MotionEvent): Boolean { - for (listener in listeners) { - if (listener.onContextClick(e)) { - return true - } - } - return false - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiZoomableControllerListener.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiZoomableControllerListener.kt deleted file mode 100644 index 3bd7c00b957..00000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/MultiZoomableControllerListener.kt +++ /dev/null @@ -1,46 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers.zoomable - -import android.graphics.Matrix -import java.util.ArrayList - -/** - * MultiZoomableControllerListener that allows multiple listeners to be added and notified about - * transform events. - * - * NOTE: The order of the listeners is important. Listeners can consume transform events. - */ -class MultiZoomableControllerListener : ZoomableController.Listener { - - private val listeners: MutableList = mutableListOf() - - @Synchronized - override fun onTransformBegin(transform: Matrix) { - for (listener in listeners) { - listener.onTransformBegin(transform) - } - } - - @Synchronized - override fun onTransformChanged(transform: Matrix) { - for (listener in listeners) { - listener.onTransformChanged(transform) - } - } - - @Synchronized - override fun onTransformEnd(transform: Matrix) { - for (listener in listeners) { - listener.onTransformEnd(transform) - } - } - - @Synchronized - fun addListener(listener: ZoomableController.Listener) { - listeners.add(listener) - } - - @Synchronized - fun removeListener(listener: ZoomableController.Listener) { - listeners.remove(listener) - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableController.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableController.kt deleted file mode 100644 index 503f6716967..00000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableController.kt +++ /dev/null @@ -1,120 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers.zoomable - -import android.graphics.Matrix -import android.graphics.RectF -import android.view.MotionEvent - -/** - * Interface for implementing a controller that works with [ZoomableDraweeView] to control the - * zoom. - */ -interface ZoomableController { - - /** Listener interface. */ - interface Listener { - - /** - * Notifies the view that the transform began. - * - * @param transform the current transform matrix - */ - fun onTransformBegin(transform: Matrix) - - /** - * Notifies the view that the transform changed. - * - * @param transform the new matrix - */ - fun onTransformChanged(transform: Matrix) - - /** - * Notifies the view that the transform ended. - * - * @param transform the current transform matrix - */ - fun onTransformEnd(transform: Matrix) - } - - /** - * Enables the controller. The controller is enabled when the image has been loaded. - * - * @param enabled whether to enable the controller - */ - fun setEnabled(enabled: Boolean) - - /** - * Gets whether the controller is enabled. This should return the last value passed - * to [setEnabled]. - * @return whether the controller is enabled. - */ - fun isEnabled(): Boolean - - /** - * Sets the listener for the controller to call back when the matrix changes. - * - * @param listener the listener - */ - fun setListener(listener: Listener?) - - /** - * Gets the current scale factor. A convenience method for calculating the scale from the - * transform. - * - * @return the current scale factor - */ - fun getScaleFactor(): Float - - /** Returns true if the zoomable transform is identity matrix, and the controller is idle. */ - fun isIdentity(): Boolean - - /** - * Returns true if the transform was corrected during the last update. - * - *

This mainly happens when a gesture would cause the image to get out of limits and the - * transform gets corrected in order to prevent that. - */ - fun wasTransformCorrected(): Boolean - - /** See [androidx.core.view.ScrollingView]. */ - fun computeHorizontalScrollRange(): Int - - fun computeHorizontalScrollOffset(): Int - - fun computeHorizontalScrollExtent(): Int - - fun computeVerticalScrollRange(): Int - - fun computeVerticalScrollOffset(): Int - - fun computeVerticalScrollExtent(): Int - - /** - * Gets the current transform. - * - * @return the transform - */ - fun getTransform(): Matrix - - /** - * Sets the bounds of the image post transform prior to application of the zoomable - * transformation. - * - * @param imageBounds the bounds of the image - */ - fun setImageBounds(imageBounds: RectF) - - /** - * Sets the bounds of the view. - * - * @param viewBounds the bounds of the view - */ - fun setViewBounds(viewBounds: RectF) - - /** - * Allows the controller to handle a touch event. - * - * @param event the touch event - * @return whether the controller handled the event - */ - fun onTouchEvent(event: MotionEvent): Boolean -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableDraweeView.kt b/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableDraweeView.kt deleted file mode 100644 index 5df63668b74..00000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/zoomControllers/zoomable/ZoomableDraweeView.kt +++ /dev/null @@ -1,320 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers.zoomable - -import android.annotation.SuppressLint -import android.content.Context -import android.content.res.Resources -import android.graphics.Canvas -import android.graphics.Matrix -import android.graphics.RectF -import android.graphics.drawable.Animatable -import android.util.AttributeSet -import android.view.GestureDetector -import android.view.MotionEvent - -import androidx.core.view.ScrollingView -import com.facebook.common.internal.Preconditions -import com.facebook.common.logging.FLog -import com.facebook.drawee.controller.AbstractDraweeController -import com.facebook.drawee.controller.BaseControllerListener -import com.facebook.drawee.drawable.ScalingUtils -import com.facebook.drawee.generic.GenericDraweeHierarchy -import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder -import com.facebook.drawee.generic.GenericDraweeHierarchyInflater -import com.facebook.drawee.interfaces.DraweeController -import com.facebook.drawee.view.DraweeView - -/** - * DraweeView that has zoomable capabilities. - * - *

Once the image loads, pinch-to-zoom and translation gestures are enabled. - */ -open class ZoomableDraweeView : DraweeView, ScrollingView { - - companion object { - private val TAG = ZoomableDraweeView::class.java - private const val HUGE_IMAGE_SCALE_FACTOR_THRESHOLD = 1.1f - } - - private val imageBounds = RectF() - private val viewBounds = RectF() - - private var hugeImageController: DraweeController? = null - private var zoomableController: ZoomableController = createZoomableController() - private var tapGestureDetector: GestureDetector? = null - private var allowTouchInterceptionWhileZoomed = true - private var isDialToneEnabled = false - private var zoomingEnabled = true - private var transformationListener: TransformationListener? = null - - private val controllerListener = object : BaseControllerListener() { - override fun onFinalImageSet(id: String, imageInfo: Any?, animatable: Animatable?) { - this@ZoomableDraweeView.onFinalImageSet() - } - - override fun onRelease(id: String) { - this@ZoomableDraweeView.onRelease() - } - } - - private val zoomableListener = object : ZoomableController.Listener { - override fun onTransformBegin(transform: Matrix) {} - - override fun onTransformChanged(transform: Matrix) { - this@ZoomableDraweeView.onTransformChanged(transform) - } - - override fun onTransformEnd(transform: Matrix) { - transformationListener?.onTransformationEnd() - } - } - - private val tapListenerWrapper = GestureListenerWrapper() - - constructor(context: Context, hierarchy: GenericDraweeHierarchy) : super(context) { - setHierarchy(hierarchy) - init() - } - - constructor(context: Context) : super(context) { - inflateHierarchy(context, null) - init() - } - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - inflateHierarchy(context, attrs) - init() - } - - constructor(context: Context, attrs: AttributeSet?, defStyle: Int) - : super(context, attrs, defStyle) { - inflateHierarchy(context, attrs) - init() - } - - fun setTransformationListener(transformationListener: TransformationListener) { - this.transformationListener = transformationListener - } - - protected fun inflateHierarchy(context: Context, attrs: AttributeSet?) { - val resources: Resources = context.resources - val builder = GenericDraweeHierarchyBuilder(resources) - .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) - GenericDraweeHierarchyInflater.updateBuilder(builder, context, attrs) - aspectRatio = builder.desiredAspectRatio - setHierarchy(builder.build()) - } - - private fun init() { - zoomableController.setListener(zoomableListener) - tapGestureDetector = GestureDetector(context, tapListenerWrapper) - } - - fun setIsDialToneEnabled(isDialtoneEnabled: Boolean) { - this.isDialToneEnabled = isDialtoneEnabled - } - - protected fun getImageBounds(outBounds: RectF) { - hierarchy.getActualImageBounds(outBounds) - } - - protected fun getLimitBounds(outBounds: RectF) { - outBounds.set(0f, 0f, width.toFloat(), height.toFloat()) - } - - fun setZoomableController(zoomableController: ZoomableController) { - Preconditions.checkNotNull(zoomableController) - this.zoomableController.setListener(null) - this.zoomableController = zoomableController - this.zoomableController.setListener(zoomableListener) - } - - fun getZoomableController(): ZoomableController = zoomableController - - fun allowsTouchInterceptionWhileZoomed(): Boolean = allowTouchInterceptionWhileZoomed - - fun setAllowTouchInterceptionWhileZoomed(allow: Boolean) { - allowTouchInterceptionWhileZoomed = allow - } - - fun setTapListener(tapListener: GestureDetector.SimpleOnGestureListener) { - tapListenerWrapper.setListener(tapListener) - } - - fun setIsLongpressEnabled(enabled: Boolean) { - tapGestureDetector?.setIsLongpressEnabled(enabled) - } - - fun setZoomingEnabled(zoomingEnabled: Boolean) { - this.zoomingEnabled = zoomingEnabled - zoomableController.setEnabled(false) - } - - override fun setController(controller: DraweeController?) { - setControllers(controller, null) - } - - fun setControllers(controller: DraweeController?, hugeImageController: DraweeController?) { - setControllersInternal(null, null) - zoomableController.setEnabled(false) - setControllersInternal(controller, hugeImageController) - } - - private fun setControllersInternal( - controller: DraweeController?, - hugeImageController: DraweeController? - ) { - removeControllerListener(getController()) - addControllerListener(controller) - this.hugeImageController = hugeImageController - super.setController(controller) - } - - private fun maybeSetHugeImageController() { - if ( - hugeImageController != null - && - zoomableController.getScaleFactor() > HUGE_IMAGE_SCALE_FACTOR_THRESHOLD - ) { - setControllersInternal(hugeImageController, null) - } - } - - private fun removeControllerListener(controller: DraweeController?) { - if (controller is AbstractDraweeController<*, *>) { - controller.removeControllerListener(controllerListener) - } - } - - private fun addControllerListener(controller: DraweeController?) { - if (controller is AbstractDraweeController<*, *>) { - controller.addControllerListener(controllerListener) - } - } - - override fun onDraw(canvas: Canvas) { - val saveCount = canvas.save() - canvas.concat(zoomableController.getTransform()) - try { - super.onDraw(canvas) - } catch (e: Exception) { - val controller = controller - if (controller is AbstractDraweeController<*, *>) { - val callerContext = controller.callerContext - if (callerContext != null) { - throw RuntimeException("Exception in onDraw, callerContext=${callerContext}", e) - } - } - throw e - } - canvas.restoreToCount(saveCount) - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - var action = event.actionMasked - FLog.v(getLogTag(), "onTouchEvent: $action, view ${hashCode()}, received") - - if (!isDialToneEnabled && tapGestureDetector?.onTouchEvent(event) == true) { - FLog.v( - getLogTag(), - "onTouchEvent: $action, view ${hashCode()}, handled by tap gesture detector" - ) - return true - } - - if (!isDialToneEnabled && zoomableController.onTouchEvent(event)) { - FLog.v( - getLogTag(), - "onTouchEvent: $action, view ${hashCode()}, handled by zoomable controller" - ) - if (!allowTouchInterceptionWhileZoomed && !zoomableController.isIdentity()) { - parent.requestDisallowInterceptTouchEvent(true) - } - return true - } - - if (super.onTouchEvent(event)) { - FLog.v( - getLogTag(), - "onTouchEvent: $action, view ${hashCode()}, handled by the super" - ) - return true - } - - // If none of our components handled the event, we send a cancel event to avoid unwanted actions. - val cancelEvent = MotionEvent.obtain(event).apply { action = MotionEvent.ACTION_CANCEL } - tapGestureDetector?.onTouchEvent(cancelEvent) - zoomableController.onTouchEvent(cancelEvent) - cancelEvent.recycle() - - return false - } - - override fun computeHorizontalScrollRange(): Int = - zoomableController.computeHorizontalScrollRange() - - override fun computeHorizontalScrollOffset(): Int = - zoomableController.computeHorizontalScrollOffset() - - override fun computeHorizontalScrollExtent(): Int = - zoomableController.computeHorizontalScrollExtent() - - override fun computeVerticalScrollRange(): Int = - zoomableController.computeVerticalScrollRange() - - override fun computeVerticalScrollOffset(): Int = - zoomableController.computeVerticalScrollOffset() - - override fun computeVerticalScrollExtent(): Int = - zoomableController.computeVerticalScrollExtent() - - - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - FLog.v(getLogTag(), "onLayout: view ${hashCode()}") - super.onLayout(changed, left, top, right, bottom) - updateZoomableControllerBounds() - } - - private fun onFinalImageSet() { - FLog.v(getLogTag(), "onFinalImageSet: view ${hashCode()}") - if (!zoomableController.isEnabled() && zoomingEnabled) { - zoomableController.setEnabled(true) - updateZoomableControllerBounds() - } - } - - private fun onRelease() { - FLog.v(getLogTag(), "onRelease: view ${hashCode()}") - zoomableController.setEnabled(false) - } - - protected fun onTransformChanged(transform: Matrix) { - FLog.v(getLogTag(), "onTransformChanged: view ${hashCode()}, transform: $transform") - maybeSetHugeImageController() - invalidate() - } - - protected fun updateZoomableControllerBounds() { - getImageBounds(imageBounds) - getLimitBounds(viewBounds) - zoomableController.setImageBounds(imageBounds) - zoomableController.setViewBounds(viewBounds) - - FLog.v( - getLogTag(), - "updateZoomableControllerBounds: view ${hashCode()}, " + - "view bounds: $viewBounds, image bounds: $imageBounds" - ) - } - - protected fun getLogTag(): Class<*> = TAG - - protected fun createZoomableController(): ZoomableController = AnimatedZoomableController.newInstance() - - /** - * Interface to listen for scale change events. - */ - interface TransformationListener { - fun onTransformationEnd() - } -} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_upload_media_detail_fragment.xml b/app/src/main/res/layout/fragment_upload_media_detail_fragment.xml index 6b1b163702c..280d38ac722 100644 --- a/app/src/main/res/layout/fragment_upload_media_detail_fragment.xml +++ b/app/src/main/res/layout/fragment_upload_media_detail_fragment.xml @@ -11,7 +11,7 @@ android:id="@+id/backgroundImage" android:layout_width="match_parent" android:layout_height="match_parent" - app:actualImageScaleType="fitXY" /> + android:scaleType="fitXY" /> - - @Mock - private lateinit var context: ProducerContext - - @Mock - private lateinit var imageRequest: ImageRequest - - @Mock - private lateinit var uri: Uri - - @Mock - private lateinit var mCacheControl: CacheControl - - @Mock - private lateinit var bytesRange: BytesRange - - @Mock - private lateinit var executor: Executor - - @Mock - private lateinit var call: Call - - @Mock - private lateinit var response: Response - - @Mock - private lateinit var body: ResponseBody - - @Before - @Throws(Exception::class) - fun setUp() { - MockitoAnnotations.openMocks(this) - okHttpClient = OkHttpClient() - fetcher = CustomOkHttpNetworkFetcher(okHttpClient, defaultKvStore) - whenever(context.imageRequest).thenReturn(imageRequest) - whenever(imageRequest.sourceUri).thenReturn(uri) - whenever(imageRequest.bytesRange).thenReturn(bytesRange) - whenever(bytesRange.toHttpRangeHeaderValue()).thenReturn("bytes 200-1000/67589") - whenever(uri.toString()).thenReturn("https://example.com") - state = fetcher.createFetchState(consumer, context) - } - - @Test - @Throws(Exception::class) - fun checkNotNull() { - Assert.assertNotNull(fetcher) - } - - @Test - @Throws(Exception::class) - fun testFetchCaseReturn() { - whenever( - defaultKvStore.getBoolean( - CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, - false, - ), - ).thenReturn(true) - fetcher.fetch(state, callback) - verify(callback).onFailure(any()) - } - - @Test - @Throws(Exception::class) - fun testFetch() { - Whitebox.setInternalState(fetcher, "mCacheControl", mCacheControl) - whenever( - defaultKvStore.getBoolean( - CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, - false, - ), - ).thenReturn(false) - fetcher.fetch(state, callback) - fetcher.onFetchCompletion(state, 0) - verify(bytesRange).toHttpRangeHeaderValue() - } - - @Test - @Throws(Exception::class) - fun testFetchCaseException() { - whenever(uri.toString()).thenReturn("") - whenever( - defaultKvStore.getBoolean( - CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, - false, - ), - ).thenReturn(false) - fetcher.fetch(state, callback) - verify(callback).onFailure(any()) - } - - @Test - @Throws(Exception::class) - fun testGetExtraMap() { - val map = fetcher.getExtraMap(state, 40) - Assertions.assertEquals(map!!["image_size"], 40.toString()) - } - - @Test - @Throws(Exception::class) - fun testOnFetchCancellationRequested() { - Whitebox.setInternalState(fetcher, "mCancellationExecutor", executor) - val method: Method = - CustomOkHttpNetworkFetcher::class.java.getDeclaredMethod( - "onFetchCancellationRequested", - Call::class.java, - ) - method.isAccessible = true - method.invoke(fetcher, call) - verify(executor).execute(any()) - } - - @Test - @Throws(Exception::class) - fun testOnFetchResponseCaseReturn() { - whenever(response.body).thenReturn(body) - whenever(response.isSuccessful).thenReturn(false) - whenever(call.isCanceled()).thenReturn(true) - val method: Method = - CustomOkHttpNetworkFetcher::class.java.getDeclaredMethod( - "onFetchResponse", - OkHttpNetworkFetchState::class.java, - Call::class.java, - Response::class.java, - NetworkFetcher.Callback::class.java, - ) - method.isAccessible = true - method.invoke(fetcher, state, call, response, callback) - verify(callback).onCancellation() - } - - @Test - @Throws(Exception::class) - fun testOnFetchResponse() { - whenever(response.body).thenReturn(body) - whenever(response.isSuccessful).thenReturn(true) - - whenever(call.isCanceled()).thenReturn(true) - whenever(body.contentLength()).thenReturn(-1) - - // Build Response object with Content-Range header - val responseBuilder = - Response - .Builder() - .request(Request.Builder().url("http://example.com").build()) - .protocol(Protocol.HTTP_1_1) - .code(200) - .message("OK") - .header("Content-Range", "bytes 200-1000/67589") - .body(body) - whenever(call.execute()).thenReturn(responseBuilder.build()) - - val method: Method = - CustomOkHttpNetworkFetcher::class.java.getDeclaredMethod( - "onFetchResponse", - OkHttpNetworkFetchState::class.java, - Call::class.java, - Response::class.java, - NetworkFetcher.Callback::class.java, - ) - method.isAccessible = true - method.invoke(fetcher, state, call, responseBuilder.build(), callback) - verify(callback).onResponse(null, 0) - } - - @Test - @Throws(Exception::class) - fun testOnFetchResponseCaseException() { - whenever(response.body).thenReturn(body) - whenever(response.isSuccessful).thenReturn(true) - - whenever(call.isCanceled()).thenReturn(false) - whenever(body.contentLength()).thenReturn(-1) - - // Build Response object with Content-Range header - val responseBuilder = - Response - .Builder() - .request(Request.Builder().url("http://example.com").build()) - .protocol(Protocol.HTTP_1_1) - .code(200) - .message("OK") - .header("Content-Range", "Test") - .body(body) - whenever(call.execute()).thenReturn(responseBuilder.build()) - - val method: Method = - CustomOkHttpNetworkFetcher::class.java.getDeclaredMethod( - "onFetchResponse", - OkHttpNetworkFetchState::class.java, - Call::class.java, - Response::class.java, - NetworkFetcher.Callback::class.java, - ) - method.isAccessible = true - method.invoke(fetcher, state, call, responseBuilder.build(), callback) - verify(callback).onFailure(any()) - } -} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/MultiPointerGestureDetectorUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/MultiPointerGestureDetectorUnitTest.kt deleted file mode 100644 index 5389c8551df..00000000000 --- a/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/MultiPointerGestureDetectorUnitTest.kt +++ /dev/null @@ -1,179 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers - -import android.view.MotionEvent -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever -import fr.free.nrw.commons.media.zoomControllers.gestures.MultiPointerGestureDetector -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations -import org.powermock.reflect.Whitebox -import java.lang.reflect.Method - -class MultiPointerGestureDetectorUnitTest { - private lateinit var detector: MultiPointerGestureDetector - - @Mock - private lateinit var listener: MultiPointerGestureDetector.Listener - - @Mock - private lateinit var event: MotionEvent - - @Before - fun setUp() { - MockitoAnnotations.openMocks(this) - detector = MultiPointerGestureDetector() - detector = MultiPointerGestureDetector.newInstance() - detector.setListener(listener) - - Whitebox.setInternalState(detector, "mGestureInProgress", false) - } - - @Test - @Throws(Exception::class) - fun checkDetectorNotNull() { - Assert.assertNotNull(detector) - } - - @Test - @Throws(Exception::class) - fun testSetAvatarCaseNull() { - val method: Method = - MultiPointerGestureDetector::class.java.getDeclaredMethod( - "shouldStartGesture", - ) - method.isAccessible = true - Assert.assertEquals(method.invoke(detector), true) - } - - @Test - @Throws(Exception::class) - fun testStartGesture() { - val method: Method = - MultiPointerGestureDetector::class.java.getDeclaredMethod( - "startGesture", - ) - method.isAccessible = true - method.invoke(detector) - verify(listener).onGestureBegin(detector) - } - - @Test - @Throws(Exception::class) - fun testStopGesture() { - Whitebox.setInternalState(detector, "mGestureInProgress", true) - val method: Method = - MultiPointerGestureDetector::class.java.getDeclaredMethod( - "stopGesture", - ) - method.isAccessible = true - method.invoke(detector) - verify(listener).onGestureEnd(detector) - } - - @Test - @Throws(Exception::class) - fun testUpdatePointersOnTap() { - whenever(event.pointerCount).thenReturn(3) - whenever(event.actionMasked).thenReturn(MotionEvent.ACTION_UP) - val method: Method = - MultiPointerGestureDetector::class.java.getDeclaredMethod( - "updatePointersOnTap", - MotionEvent::class.java, - ) - method.isAccessible = true - method.invoke(detector, event) - verify(event, times(2)).actionIndex - } - - @Test - @Throws(Exception::class) - fun testRestartGestureCaseReturn() { - detector.restartGesture() - } - - @Test - @Throws(Exception::class) - fun testRestartGesture() { - Whitebox.setInternalState(detector, "mGestureInProgress", true) - detector.restartGesture() - verify(listener).onGestureBegin(detector) - verify(listener).onGestureEnd(detector) - } - - @Test - @Throws(Exception::class) - fun testIsGestureInProgress() { - Assert.assertEquals(detector.isGestureInProgress(), false) - } - - @Test - @Throws(Exception::class) - fun testGetNewPointerCount() { - Assert.assertEquals(detector.getNewPointerCount(), 0) - } - - @Test - @Throws(Exception::class) - fun testGetPointerCount() { - Assert.assertEquals(detector.getPointerCount(), 0) - } - - @Test - @Throws(Exception::class) - fun testGetStartX() { - Assert.assertEquals(detector.getStartX()[0], 0.0f) - } - - @Test - @Throws(Exception::class) - fun testGetStartY() { - Assert.assertEquals(detector.getStartY()[0], 0.0f) - } - - @Test - @Throws(Exception::class) - fun testGetCurrentX() { - Assert.assertEquals(detector.getCurrentX()[0], 0.0f) - } - - @Test - @Throws(Exception::class) - fun testGetCurrentY() { - Assert.assertEquals(detector.getCurrentY()[0], 0.0f) - } - - @Test - @Throws(Exception::class) - fun testOnTouchEvent() { - Assert.assertEquals(detector.onTouchEvent(event), true) - } - - @Test - @Throws(Exception::class) - fun `testOnTouchEventCase ACTION_MOVE`() { - Whitebox.setInternalState(detector, "mPointerCount", 1) - whenever(event.actionMasked).thenReturn(MotionEvent.ACTION_MOVE) - Assert.assertEquals(detector.onTouchEvent(event), true) - } - - @Test - @Throws(Exception::class) - fun `testOnTouchEventCase ACTION_UP`() { - whenever(event.pointerCount).thenReturn(3) - Whitebox.setInternalState(detector, "mPointerCount", 1) - whenever(event.actionMasked).thenReturn(MotionEvent.ACTION_UP) - whenever(event.pointerCount).thenReturn(-2) - Assert.assertEquals(detector.onTouchEvent(event), true) - } - - @Test - @Throws(Exception::class) - fun `testOnTouchEventCase ACTION_CANCEL`() { - whenever(event.actionMasked).thenReturn(MotionEvent.ACTION_CANCEL) - Assert.assertEquals(detector.onTouchEvent(event), true) - } -} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/TransformGestureDetectorUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/TransformGestureDetectorUnitTest.kt deleted file mode 100644 index 3c6af2f56d8..00000000000 --- a/app/src/test/kotlin/fr/free/nrw/commons/media/zoomControllers/TransformGestureDetectorUnitTest.kt +++ /dev/null @@ -1,187 +0,0 @@ -package fr.free.nrw.commons.media.zoomControllers - -import android.view.MotionEvent -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever -import fr.free.nrw.commons.media.zoomControllers.gestures.MultiPointerGestureDetector -import fr.free.nrw.commons.media.zoomControllers.gestures.TransformGestureDetector -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations -import org.powermock.reflect.Whitebox -import java.lang.reflect.Method - -class TransformGestureDetectorUnitTest { - private lateinit var detector: TransformGestureDetector - - @Mock - private lateinit var listener: TransformGestureDetector.Listener - - @Mock - private lateinit var mDetector: MultiPointerGestureDetector - - @Mock - private lateinit var event: MotionEvent - - @Before - fun setUp() { - MockitoAnnotations.openMocks(this) - detector = TransformGestureDetector(MultiPointerGestureDetector()) - detector = TransformGestureDetector.newInstance() - detector.setListener(listener) - } - - @Test - @Throws(Exception::class) - fun checkDetectorNotNull() { - Assert.assertNotNull(detector) - } - - @Test - @Throws(Exception::class) - fun testReset() { - Whitebox.setInternalState(detector, "mDetector", mDetector) - detector.reset() - verify(mDetector).reset() - } - - @Test - @Throws(Exception::class) - fun testOnTouchEvent() { - whenever(mDetector.onTouchEvent(event)).thenReturn(true) - Assert.assertEquals(detector.onTouchEvent(event), true) - } - - @Test - @Throws(Exception::class) - fun testOnGestureBegin() { - detector.onGestureBegin(mDetector) - verify(listener).onGestureBegin(detector) - } - - @Test - @Throws(Exception::class) - fun testOnGestureUpdate() { - detector.onGestureUpdate(mDetector) - verify(listener).onGestureUpdate(detector) - } - - @Test - @Throws(Exception::class) - fun testOnGestureEnd() { - detector.onGestureEnd(mDetector) - verify(listener).onGestureEnd(detector) - } - - @Test - @Throws(Exception::class) - fun testRestartGesture() { - detector.restartGesture() - } - - @Test - @Throws(Exception::class) - fun testIsGestureInProgress() { - Assert.assertEquals(detector.isGestureInProgress(), false) - } - - @Test - @Throws(Exception::class) - fun testGetNewPointerCount() { - Assert.assertEquals(detector.getNewPointerCount(), 0) - } - - @Test - @Throws(Exception::class) - fun testGetPointerCount() { - Assert.assertEquals(detector.getPointerCount(), 0) - } - - @Test - @Throws(Exception::class) - fun testGetPivotX() { - Assert.assertEquals(detector.getPivotX(), 0.0f) - } - - @Test - @Throws(Exception::class) - fun testGetPivotY() { - Assert.assertEquals(detector.getPivotY(), 0.0f) - } - - @Test - @Throws(Exception::class) - fun testGetTranslationX() { - Assert.assertEquals(detector.getTranslationX(), 0.0f) - } - - @Test - @Throws(Exception::class) - fun testGetTranslationY() { - Assert.assertEquals(detector.getTranslationY(), 0.0f) - } - - @Test - @Throws(Exception::class) - fun testGetScaleCaseLessThan2() { - Whitebox.setInternalState(detector, "mDetector", mDetector) - whenever(mDetector.getPointerCount()).thenReturn(1) - Assert.assertEquals(detector.getScale(), 1f) - } - - @Test - @Throws(Exception::class) - fun testGetScaleCaseGreaterThan2() { - val array = FloatArray(2) - array[0] = 0.0f - array[1] = 1.0f - Whitebox.setInternalState(detector, "mDetector", mDetector) - whenever(mDetector.getPointerCount()).thenReturn(2) - whenever(mDetector.getStartX()).thenReturn(array) - whenever(mDetector.getStartY()).thenReturn(array) - whenever(mDetector.getCurrentX()).thenReturn(array) - whenever(mDetector.getCurrentY()).thenReturn(array) - Assert.assertEquals(detector.getScale(), 1f) - } - - @Test - @Throws(Exception::class) - fun testGetRotationCaseLessThan2() { - Whitebox.setInternalState(detector, "mDetector", mDetector) - whenever(mDetector.getPointerCount()).thenReturn(1) - Assert.assertEquals(detector.getRotation(), 0f) - } - - @Test - @Throws(Exception::class) - fun testGetRotationCaseGreaterThan2() { - val array = FloatArray(2) - array[0] = 0.0f - array[1] = 1.0f - Whitebox.setInternalState(detector, "mDetector", mDetector) - whenever(mDetector.getPointerCount()).thenReturn(2) - whenever(mDetector.getStartX()).thenReturn(array) - whenever(mDetector.getStartY()).thenReturn(array) - whenever(mDetector.getCurrentX()).thenReturn(array) - whenever(mDetector.getCurrentY()).thenReturn(array) - Assert.assertEquals(detector.getRotation(), 0f) - } - - @Test - @Throws(Exception::class) - fun testCalcAverage() { - val array = FloatArray(2) - array[0] = 0.0f - array[1] = 1.0f - val method: Method = - TransformGestureDetector::class.java.getDeclaredMethod( - "calcAverage", - FloatArray::class.java, - Int::class.java, - ) - method.isAccessible = true - Assert.assertEquals(method.invoke(detector, array, 2), 0.5f) - } -} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/widget/PicOfDayAppWidgetUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/widget/PicOfDayAppWidgetUnitTests.kt index 623bd2affee..3f934c63fa5 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/widget/PicOfDayAppWidgetUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/widget/PicOfDayAppWidgetUnitTests.kt @@ -4,8 +4,6 @@ import android.appwidget.AppWidgetManager import android.content.Context import android.widget.RemoteViews import androidx.test.core.app.ApplicationProvider -import com.facebook.imagepipeline.core.ImagePipelineFactory -import com.facebook.soloader.SoLoader import fr.free.nrw.commons.Media import fr.free.nrw.commons.OkHttpConnectionFactory import fr.free.nrw.commons.TestCommonsApplication @@ -47,8 +45,6 @@ class PicOfDayAppWidgetUnitTests { fun setUp() { OkHttpConnectionFactory.CLIENT = createTestClient() context = ApplicationProvider.getApplicationContext() - SoLoader.setInTestMode() - ImagePipelineFactory.initialize(context) MockitoAnnotations.openMocks(this) widget = PicOfDayAppWidget() Whitebox.setInternalState(widget, "compositeDisposable", compositeDisposable) From 72c076d89ad7aa935f4ed6cb9acd17ccb0b9737c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:17:31 +0000 Subject: [PATCH 13/41] Fix remaining compilation errors: update missed files using Fresco setImageURI/setActualImageResource - DepictionAdapter.kt: Replace setImageURI/setActualImageResource with Coil load() - PagedMediaAdapter.kt: Replace setImageURI with Coil load() - ReviewActivity.kt: Replace setImageURI with Coil load() - UploadCategoryAdapterDelegates.kt: Replace setImageURI/setActualImageResource with Coil - UploadDepictsAdapterDelegates.kt: Replace setImageURI/setActualImageResource with Coil Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/942f4288-cb09-4721-856d-8b3a085759d4 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../nrw/commons/explore/depictions/DepictionAdapter.kt | 7 +++++-- .../free/nrw/commons/explore/media/PagedMediaAdapter.kt | 3 ++- .../java/fr/free/nrw/commons/review/ReviewActivity.kt | 3 ++- .../upload/categories/UploadCategoryAdapterDelegates.kt | 7 +++++-- .../upload/depicts/UploadDepictsAdapterDelegates.kt | 8 +++++--- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt index 56baacc6593..3bbb7b1a2e7 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt @@ -2,6 +2,7 @@ package fr.free.nrw.commons.explore.depictions import android.view.LayoutInflater import android.view.ViewGroup +import coil.load import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView @@ -38,9 +39,11 @@ class DepictedItemViewHolder( depictsLabel.text = item.name description.text = item.description if (item.imageUrl?.isNotBlank() == true) { - depictsImage.setImageURI(item.imageUrl) + depictsImage.load(item.imageUrl) { + placeholder(R.drawable.ic_wikidata_logo_24dp) + } } else { - depictsImage.setActualImageResource(R.drawable.ic_wikidata_logo_24dp) + depictsImage.setImageResource(R.drawable.ic_wikidata_logo_24dp) } } } 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 521ba77c668..dd14e41aec0 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 @@ -2,6 +2,7 @@ package fr.free.nrw.commons.explore.media import android.view.View import android.view.ViewGroup +import coil.load import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import fr.free.nrw.commons.Media @@ -78,7 +79,7 @@ class SearchImagesViewHolder( val media = item.first binding.categoryImageView.setOnClickListener { onImageClicked(item.second) } binding.categoryImageTitle.text = media.mostRelevantCaption - binding.categoryImageView.setImageURI(media.thumbUrl) + binding.categoryImageView.load(media.thumbUrl) setAuthorText(media) } diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt index dccb77af1eb..0230d442e32 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt @@ -1,6 +1,7 @@ package fr.free.nrw.commons.review import android.annotation.SuppressLint +import coil.load import android.content.Context import android.content.Intent import android.graphics.PorterDuff @@ -188,7 +189,7 @@ class ReviewActivity : BaseActivity() { return } - binding.reviewImageView.setImageURI(media.thumbUrl) + binding.reviewImageView.load(media.thumbUrl) reviewController.onImageRefreshed(media) // filename is updated compositeDisposable.add( diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoryAdapterDelegates.kt b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoryAdapterDelegates.kt index 619db9ab7af..c7ceae4e414 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoryAdapterDelegates.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoryAdapterDelegates.kt @@ -1,6 +1,7 @@ package fr.free.nrw.commons.upload.categories import android.view.View +import coil.load import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import fr.free.nrw.commons.R import fr.free.nrw.commons.category.CategoryItem @@ -38,9 +39,11 @@ fun uploadCategoryDelegate( } binding.categoryLabel.text = item.name if (item.thumbnail != "null") { - binding.categoryImage.setImageURI(item.thumbnail) + binding.categoryImage.load(item.thumbnail) { + placeholder(R.drawable.commons) + } } else { - binding.categoryImage.setActualImageResource(R.drawable.commons) + binding.categoryImage.setImageResource(R.drawable.commons) } if (item.description != "null") { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/UploadDepictsAdapterDelegates.kt b/app/src/main/java/fr/free/nrw/commons/upload/depicts/UploadDepictsAdapterDelegates.kt index 0f02c9672b6..43b6c24c113 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/UploadDepictsAdapterDelegates.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/UploadDepictsAdapterDelegates.kt @@ -1,8 +1,8 @@ package fr.free.nrw.commons.upload.depicts -import android.net.Uri import android.text.TextUtils import android.view.View +import coil.load import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import fr.free.nrw.commons.R import fr.free.nrw.commons.databinding.LayoutUploadDepictsItemBinding @@ -37,9 +37,11 @@ fun uploadDepictsDelegate( binding.description.text = item.description val imageUrl = item.imageUrl if (TextUtils.isEmpty(imageUrl)) { - binding.depictedImage.setActualImageResource(R.drawable.ic_wikidata_logo_24dp) + binding.depictedImage.setImageResource(R.drawable.ic_wikidata_logo_24dp) } else { - binding.depictedImage.setImageURI(Uri.parse(imageUrl)) + binding.depictedImage.load(imageUrl) { + placeholder(R.drawable.ic_wikidata_logo_24dp) + } } } } From 3034d2ef946861faee924b9305ef89d21e825099 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:32:14 +0000 Subject: [PATCH 14/41] Upgrade Coil from 2.7.0 to 3.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update Maven coordinates from io.coil-kt to io.coil-kt.coil3 - Add coil-network-okhttp dependency for network image loading - Update all imports from coil.* to coil3.* - Add explicit extension function imports (crossfade, placeholder, error, etc.) - Replace Coil singleton API: Coil → SingletonImageLoader - Replace result.drawable with result.image.toBitmap() for bitmap extraction - Update MemoryCache.Builder API for Coil 3 (context parameter moved) - Update DiskCache.Builder to use okio.Path instead of java.io.File - Build APK (31MB release) and all unit tests pass Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/a901a8e6-19f7-446d-a0ee-ff5ee05ad16b Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .gitignore | 1 + .kotlin/errors/errors-1775895276596.log | 81 +++++++++++++++++++ app/build.gradle.kts | 1 + .../fr/free/nrw/commons/CommonsApplication.kt | 20 ++--- .../bookmarks/items/BookmarkItemsAdapter.kt | 3 +- .../nrw/commons/category/GridViewAdapter.kt | 2 +- .../contributions/ContributionViewHolder.kt | 4 +- .../contributions/SetWallpaperWorker.kt | 11 +-- .../ui/adapter/FolderAdapter.kt | 3 +- .../customselector/ui/adapter/ImageAdapter.kt | 9 ++- .../explore/depictions/DepictionAdapter.kt | 3 +- .../explore/map/ExploreMapController.kt | 15 ++-- .../explore/media/PagedMediaAdapter.kt | 2 +- .../nrw/commons/media/MediaDetailFragment.kt | 14 ++-- .../nrw/commons/media/ZoomableActivity.kt | 2 +- .../nearby/fragments/NearbyParentFragment.kt | 6 +- .../leaderboard/LeaderboardListAdapter.kt | 5 +- .../profile/leaderboard/UserDetailAdapter.kt | 5 +- .../fr/free/nrw/commons/quiz/QuizActivity.kt | 3 +- .../free/nrw/commons/review/ReviewActivity.kt | 2 +- .../commons/upload/FailedUploadsAdapter.kt | 4 +- .../commons/upload/PendingUploadsAdapter.kt | 4 +- .../upload/SimilarImageDialogFragment.kt | 4 +- .../nrw/commons/upload/ThumbnailsAdapter.kt | 2 +- .../UploadCategoryAdapterDelegates.kt | 3 +- .../depicts/UploadDepictsAdapterDelegates.kt | 3 +- .../nrw/commons/widget/PicOfDayAppWidget.kt | 11 +-- gradle/libs.versions.toml | 5 +- 28 files changed, 172 insertions(+), 56 deletions(-) create mode 100644 .kotlin/errors/errors-1775895276596.log diff --git a/.gitignore b/.gitignore index 7fa4767a7fb..9ddb37550b1 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ captures/* app/jacoco.exec app/CommonsContributions app/.* +local.properties diff --git a/.kotlin/errors/errors-1775895276596.log b/.kotlin/errors/errors-1775895276596.log new file mode 100644 index 00000000000..293baafbdda --- /dev/null +++ b/.kotlin/errors/errors-1775895276596.log @@ -0,0 +1,81 @@ +kotlin version: 2.1.0 +error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing /home/runner/work/apps-android-commons/apps-android-commons/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt:48:5: java.lang.IllegalArgumentException: source must not be null + at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57) + at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249) + at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:68) + at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77) + at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirFromKtFiles(firUtils.kt:64) + at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelinePsiKt.compileSourceFilesToAnalyzedFirViaPsi(jvmCompilerPipelinePsi.kt:190) + at org.jetbrains.kotlin.cli.jvm.compiler.FirKotlinToJvmBytecodeCompiler.runFrontendForKapt(FirKotlinToJvmBytecodeCompiler.kt:66) + at org.jetbrains.kotlin.kapt4.FirKaptAnalysisHandlerExtension.contextForStubGeneration(FirKaptAnalysisHandlerExtension.kt:211) + at org.jetbrains.kotlin.kapt4.FirKaptAnalysisHandlerExtension.doAnalysis(FirKaptAnalysisHandlerExtension.kt:116) + at org.jetbrains.kotlin.fir.extensions.FirAnalysisHandlerExtension$Companion.analyze(FirAnalysisHandlerExtension.kt:29) + at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineLightTreeKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipelineLightTree.kt:72) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:146) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:102) + at org.jetbrains.kotlin.cli.common.CLICompiler.exec(CLICompiler.kt:316) + at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464) + at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:674) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:91) + at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1659) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360) + at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200) + at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197) + at java.base/java.security.AccessController.doPrivileged(AccessController.java:712) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:587) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:828) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:705) + at java.base/java.security.AccessController.doPrivileged(AccessController.java:399) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:704) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635) + at java.base/java.lang.Thread.run(Thread.java:840) +Caused by: java.lang.IllegalArgumentException: source must not be null + at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68) + at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39) + at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31) + at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50) + at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42) + at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17) + at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:15) + at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:99) + at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19) + at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28) + at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30) + at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28) + at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30) + at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51) + at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30) + at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48) + at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30) + at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42) + at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36) + at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:37) + ... 38 more + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72758340128..f35a2ff408a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -217,6 +217,7 @@ dependencies { implementation(libs.gson) implementation(libs.okhttp) implementation(libs.coil) + implementation(libs.coil.network.okhttp) implementation(libs.retrofit) implementation(libs.retrofit.converter.gson) implementation(libs.retrofit.adapter.rxjava) diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt index 663b415aba8..d94297e99be 100644 --- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt +++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt @@ -11,10 +11,12 @@ import android.os.Build import android.os.Process import android.util.Log import androidx.multidex.MultiDexApplication -import coil.Coil -import coil.ImageLoader -import coil.disk.DiskCache -import coil.memory.MemoryCache +import coil3.ImageLoader +import coil3.request.crossfade +import coil3.SingletonImageLoader +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import okio.Path.Companion.toPath import fr.free.nrw.commons.auth.LoginActivity import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.bookmarks.items.BookmarkItemsTable @@ -118,18 +120,18 @@ class CommonsApplication : MultiDexApplication() { val imageLoader = ImageLoader.Builder(this) .crossfade(true) .memoryCache { - MemoryCache.Builder(this) - .maxSizePercent(0.25) + MemoryCache.Builder() + .maxSizePercent(this, 0.25) .build() } .diskCache { DiskCache.Builder() - .directory(cacheDir.resolve("image_cache")) + .directory(cacheDir.resolve("image_cache").absolutePath.toPath()) .maxSizePercent(0.02) .build() } .build() - Coil.setImageLoader(imageLoader) + SingletonImageLoader.setSafe { imageLoader } createNotificationChannel(this) @@ -233,7 +235,7 @@ class CommonsApplication : MultiDexApplication() { * Clear all images cache held by Coil */ private fun clearImageCache() { - val imageLoader = Coil.imageLoader(this) + val imageLoader = SingletonImageLoader.get(this) imageLoader.memoryCache?.clear() imageLoader.diskCache?.clear() } diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt index c325f2c6d1a..6642dd08009 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsAdapter.kt @@ -8,7 +8,8 @@ import android.widget.ImageView import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.RecyclerView -import coil.load +import coil3.load +import coil3.request.placeholder import fr.free.nrw.commons.R import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity import fr.free.nrw.commons.upload.structure.depictions.DepictedItem diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt index 98a17c77edd..187ea1bf140 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt @@ -8,7 +8,7 @@ import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.ImageView import android.widget.TextView -import coil.load +import coil3.load import fr.free.nrw.commons.Media import fr.free.nrw.commons.R 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 b4d2e259593..1e4eb645e69 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 @@ -6,7 +6,9 @@ import android.view.View import android.webkit.URLUtil import androidx.appcompat.app.AlertDialog import androidx.recyclerview.widget.RecyclerView -import coil.load +import coil3.load +import coil3.request.placeholder +import coil3.request.error import fr.free.nrw.commons.Media import fr.free.nrw.commons.utils.MediaAttributionUtil import fr.free.nrw.commons.MediaDataExtractor diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt index 01a3d98548f..fe6c7747683 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt @@ -5,14 +5,15 @@ import android.app.NotificationManager import android.app.WallpaperManager import android.content.Context import android.graphics.Bitmap -import android.graphics.drawable.BitmapDrawable import android.os.Build import androidx.core.app.NotificationCompat import androidx.work.Worker import androidx.work.WorkerParameters -import coil.ImageLoader -import coil.request.ImageRequest -import coil.request.SuccessResult +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +import coil3.toBitmap import fr.free.nrw.commons.R import kotlinx.coroutines.runBlocking import timber.log.Timber @@ -35,7 +36,7 @@ class SetWallpaperWorker(context: Context, params: WorkerParameters) : .build() val result = imageLoader.execute(request) if (result is SuccessResult) { - val bitmap = (result.drawable as BitmapDrawable).bitmap + val bitmap = result.image.toBitmap() setWallpaper(context, Bitmap.createBitmap(bitmap)) } else { Timber.d("Error getting bitmap from image url %s", imageUrl) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt index 4d5ed12d02b..6d71979004b 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt @@ -7,7 +7,8 @@ import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import coil.load +import coil3.load +import coil3.request.crossfade import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.model.Folder 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 ff3616c745b..44c6ec19531 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 @@ -10,7 +10,8 @@ import android.widget.Toast import androidx.constraintlayout.widget.Group import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import coil.load +import coil3.load +import coil3.request.crossfade import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView import fr.free.nrw.commons.R import fr.free.nrw.commons.contributions.Contribution @@ -225,7 +226,7 @@ class ImageAdapter( image = actionableImages[position] holder.image.load(image.uri) { crossfade(true) - size(coil.size.Size.ORIGINAL) + size(coil3.size.Size.ORIGINAL) } } } @@ -235,7 +236,7 @@ class ImageAdapter( } else { holder.image.load(image.uri) { crossfade(true) - size(coil.size.Size.ORIGINAL) + size(coil3.size.Size.ORIGINAL) } } } @@ -285,7 +286,7 @@ class ImageAdapter( _currentImagesCount.value = imagePositionAsPerIncreasingOrder holder.image.load(allImages[next].uri) { crossfade(true) - size(coil.size.Size.ORIGINAL) + size(coil3.size.Size.ORIGINAL) } notifyItemInserted(position) notifyItemRangeChanged(position, itemCount + 1) diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt index 3bbb7b1a2e7..593ee3dbd96 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt @@ -2,7 +2,8 @@ package fr.free.nrw.commons.explore.depictions import android.view.LayoutInflater import android.view.ViewGroup -import coil.load +import coil3.load +import coil3.request.placeholder import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView diff --git a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt index 0e414a47893..8f62b36059c 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt @@ -3,10 +3,15 @@ package fr.free.nrw.commons.explore.map import android.content.Context import android.content.res.Resources import android.graphics.Bitmap -import android.graphics.drawable.BitmapDrawable import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat -import coil.ImageLoader -import coil.request.ImageRequest +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.allowHardware +import coil3.request.target +import coil3.size.Size +import coil3.target.Target +import coil3.Image +import coil3.toBitmap import fr.free.nrw.commons.BaseMarker import fr.free.nrw.commons.MapController import fr.free.nrw.commons.Media @@ -178,8 +183,8 @@ class ExploreMapController @Inject constructor( .size(96, 96) .allowHardware(false) .target( - onSuccess = { drawable -> - val bitmap = (drawable as BitmapDrawable).bitmap + onSuccess = { image -> + val bitmap = image.toBitmap() baseMarker.icon = addRedBorder(bitmap, 6, context) baseMarkerList.add(baseMarker) if (baseMarkerList.size == placeList.size) { 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 dd14e41aec0..0293ce86392 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 @@ -2,7 +2,7 @@ package fr.free.nrw.commons.explore.media import android.view.View import android.view.ViewGroup -import coil.load +import coil3.load import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import fr.free.nrw.commons.Media 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 c77dc7add78..15d76b5a15d 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 @@ -6,7 +6,9 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration -import coil.load +import coil3.load +import coil3.request.placeholder +import coil3.request.error import android.net.Uri import android.os.Bundle import android.text.Editable @@ -720,10 +722,12 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) listener( - onSuccess = { _, result -> - val drawable = result.drawable - cachedImageWidth = drawable.intrinsicWidth - cachedImageHeight = drawable.intrinsicHeight + onSuccess = { _, _ -> + val d = binding.mediaDetailImageView.drawable + if (d != null) { + cachedImageWidth = d.intrinsicWidth + cachedImageHeight = d.intrinsicHeight + } updateAspectRatio(binding.mediaDetailScrollView.width) } ) diff --git a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt index cfdc3e81d7c..59e72a02bd6 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt @@ -14,7 +14,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.ViewModelProvider -import coil.load +import coil3.load import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.database.NotForUploadStatus import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt index 02da83dd922..4fa9aed214b 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt @@ -45,8 +45,10 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager -import coil.load -import coil.dispose +import coil3.load +import coil3.request.placeholder +import coil3.request.error +import coil3.dispose import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback import com.google.android.material.snackbar.Snackbar diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt index 6cf3588a328..4539a95ea69 100644 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt @@ -8,8 +8,9 @@ import android.widget.ImageView import android.widget.TextView import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.RecyclerView -import coil.load -import coil.transform.CircleCropTransformation +import coil3.load +import coil3.request.transformations +import coil3.transform.CircleCropTransformation import fr.free.nrw.commons.R import fr.free.nrw.commons.profile.ProfileActivity import fr.free.nrw.commons.profile.leaderboard.LeaderboardList.Companion.DIFF_CALLBACK diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt index 74a45612a9c..28390b5772d 100644 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt @@ -8,8 +8,9 @@ import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.recyclerview.widget.RecyclerView -import coil.load -import coil.transform.CircleCropTransformation +import coil3.load +import coil3.request.transformations +import coil3.transform.CircleCropTransformation import fr.free.nrw.commons.BuildConfig import fr.free.nrw.commons.R import fr.free.nrw.commons.profile.leaderboard.UserDetailAdapter.DataViewHolder diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.kt b/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.kt index b09b765ebc7..be10cef0aff 100644 --- a/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.kt @@ -8,7 +8,8 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat -import coil.load +import coil3.load +import coil3.request.error import fr.free.nrw.commons.databinding.ActivityQuizBinding import java.util.ArrayList diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt index 0230d442e32..6c6ed49e42e 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt @@ -1,7 +1,7 @@ package fr.free.nrw.commons.review import android.annotation.SuppressLint -import coil.load +import coil3.load import android.content.Context import android.content.Intent import android.graphics.PorterDuff diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt index 4d8d46c063a..a75abfb639c 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt @@ -15,7 +15,9 @@ import android.widget.TextView import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import coil.load +import coil3.load +import coil3.request.placeholder +import coil3.request.error import com.google.android.material.snackbar.Snackbar import fr.free.nrw.commons.R import fr.free.nrw.commons.contributions.Contribution diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt index 5c6f1a9b4e8..29a88c9f5ab 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt @@ -15,7 +15,9 @@ import android.widget.TextView import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import coil.load +import coil3.load +import coil3.request.placeholder +import coil3.request.error import com.google.android.material.snackbar.Snackbar import fr.free.nrw.commons.R import fr.free.nrw.commons.contributions.Contribution diff --git a/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageDialogFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageDialogFragment.kt index 99734b7dd28..486e408ad22 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageDialogFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageDialogFragment.kt @@ -8,7 +8,9 @@ import android.view.View import android.view.ViewGroup import android.view.Window import androidx.fragment.app.DialogFragment -import coil.load +import coil3.load +import coil3.request.placeholder +import coil3.request.error import fr.free.nrw.commons.R import fr.free.nrw.commons.databinding.FragmentSimilarImageDialogBinding import java.io.File diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailsAdapter.kt index 902ea76d204..fd0813c21d3 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailsAdapter.kt @@ -7,7 +7,7 @@ import android.widget.ImageView import android.widget.RelativeLayout import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView -import coil.load +import coil3.load import fr.free.nrw.commons.R import fr.free.nrw.commons.databinding.ItemUploadThumbnailBinding import fr.free.nrw.commons.filepicker.UploadableFile diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoryAdapterDelegates.kt b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoryAdapterDelegates.kt index c7ceae4e414..fd8e24256ca 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoryAdapterDelegates.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoryAdapterDelegates.kt @@ -1,7 +1,8 @@ package fr.free.nrw.commons.upload.categories import android.view.View -import coil.load +import coil3.load +import coil3.request.placeholder import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import fr.free.nrw.commons.R import fr.free.nrw.commons.category.CategoryItem diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/UploadDepictsAdapterDelegates.kt b/app/src/main/java/fr/free/nrw/commons/upload/depicts/UploadDepictsAdapterDelegates.kt index 43b6c24c113..ad7443d4f40 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/UploadDepictsAdapterDelegates.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/UploadDepictsAdapterDelegates.kt @@ -2,7 +2,8 @@ package fr.free.nrw.commons.upload.depicts import android.text.TextUtils import android.view.View -import coil.load +import coil3.load +import coil3.request.placeholder import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import fr.free.nrw.commons.R import fr.free.nrw.commons.databinding.LayoutUploadDepictsItemBinding diff --git a/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt b/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt index fc7673c1415..987d9149484 100644 --- a/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt +++ b/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt @@ -5,13 +5,14 @@ import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context import android.content.Intent -import android.graphics.drawable.BitmapDrawable import android.net.Uri import android.os.Build import android.widget.RemoteViews -import coil.ImageLoader -import coil.request.ImageRequest -import coil.request.SuccessResult +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +import coil3.toBitmap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -136,7 +137,7 @@ class PicOfDayAppWidget : AppWidgetProvider() { .build() val result = imageLoader.execute(request) if (result is SuccessResult) { - val bitmap = (result.drawable as BitmapDrawable).bitmap + val bitmap = result.image.toBitmap() views.setImageViewBitmap(R.id.appwidget_image, bitmap) appWidgetManager.updateAppWidget(appWidgetId, views) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6304fe50731..f9544f5746d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ dexterVersion = "5.0.0" espresso = "3.6.1" exifinterface = "1.3.7" fragmentTesting = "1.6.2" -coil = "2.7.0" +coil = "3.1.0" commonsLang3Version = "3.8.1" gson = "2.8.5" junit = "4.13.2" @@ -123,7 +123,8 @@ dagger-android = { module = "com.google.dagger:dagger-android", version.ref = "d dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } # Image loading -coil = { module = "io.coil-kt:coil", version.ref = "coil" } +coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } kotlinx-coroutines-rx2 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx2", version.ref = "kotlinxCoroutinesRx2" } photoview = { module = "com.github.chrisbanes:PhotoView", version.ref = "photoviewVersion" } From 1360b746616ac281a992e46894ca653b512b22ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:32:27 +0000 Subject: [PATCH 15/41] Remove .kotlin error logs from tracking and add to .gitignore Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/a901a8e6-19f7-446d-a0ee-ff5ee05ad16b Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .gitignore | 1 + .kotlin/errors/errors-1775895276596.log | 81 ------------------------- 2 files changed, 1 insertion(+), 81 deletions(-) delete mode 100644 .kotlin/errors/errors-1775895276596.log diff --git a/.gitignore b/.gitignore index 9ddb37550b1..62e6bd2aa8a 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ app/jacoco.exec app/CommonsContributions app/.* local.properties +.kotlin/ diff --git a/.kotlin/errors/errors-1775895276596.log b/.kotlin/errors/errors-1775895276596.log deleted file mode 100644 index 293baafbdda..00000000000 --- a/.kotlin/errors/errors-1775895276596.log +++ /dev/null @@ -1,81 +0,0 @@ -kotlin version: 2.1.0 -error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing /home/runner/work/apps-android-commons/apps-android-commons/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt:48:5: java.lang.IllegalArgumentException: source must not be null - at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57) - at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249) - at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:68) - at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77) - at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirFromKtFiles(firUtils.kt:64) - at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelinePsiKt.compileSourceFilesToAnalyzedFirViaPsi(jvmCompilerPipelinePsi.kt:190) - at org.jetbrains.kotlin.cli.jvm.compiler.FirKotlinToJvmBytecodeCompiler.runFrontendForKapt(FirKotlinToJvmBytecodeCompiler.kt:66) - at org.jetbrains.kotlin.kapt4.FirKaptAnalysisHandlerExtension.contextForStubGeneration(FirKaptAnalysisHandlerExtension.kt:211) - at org.jetbrains.kotlin.kapt4.FirKaptAnalysisHandlerExtension.doAnalysis(FirKaptAnalysisHandlerExtension.kt:116) - at org.jetbrains.kotlin.fir.extensions.FirAnalysisHandlerExtension$Companion.analyze(FirAnalysisHandlerExtension.kt:29) - at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineLightTreeKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipelineLightTree.kt:72) - at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:146) - at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43) - at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:102) - at org.jetbrains.kotlin.cli.common.CLICompiler.exec(CLICompiler.kt:316) - at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464) - at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73) - at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506) - at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423) - at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301) - at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129) - at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:674) - at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:91) - at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1659) - at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) - at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) - at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) - at java.base/java.lang.reflect.Method.invoke(Method.java:569) - at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360) - at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200) - at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197) - at java.base/java.security.AccessController.doPrivileged(AccessController.java:712) - at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196) - at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:587) - at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:828) - at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:705) - at java.base/java.security.AccessController.doPrivileged(AccessController.java:399) - at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:704) - at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136) - at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635) - at java.base/java.lang.Thread.run(Thread.java:840) -Caused by: java.lang.IllegalArgumentException: source must not be null - at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68) - at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39) - at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31) - at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50) - at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42) - at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17) - at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:15) - at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:99) - at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19) - at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28) - at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30) - at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28) - at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30) - at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51) - at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30) - at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48) - at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30) - at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42) - at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36) - at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:37) - ... 38 more - - From 1d97b5671181d47cc5784dea57a075164e00d42b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:34:03 +0000 Subject: [PATCH 16/41] Clean up unused imports and duplicate .gitignore entry Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/a901a8e6-19f7-446d-a0ee-ff5ee05ad16b Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .gitignore | 1 - .../fr/free/nrw/commons/explore/map/ExploreMapController.kt | 3 --- 2 files changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index 62e6bd2aa8a..9554676d903 100644 --- a/.gitignore +++ b/.gitignore @@ -48,5 +48,4 @@ captures/* app/jacoco.exec app/CommonsContributions app/.* -local.properties .kotlin/ diff --git a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt index 8f62b36059c..e8e5e9a4794 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt @@ -8,9 +8,6 @@ import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.allowHardware import coil3.request.target -import coil3.size.Size -import coil3.target.Target -import coil3.Image import coil3.toBitmap import fr.free.nrw.commons.BaseMarker import fr.free.nrw.commons.MapController From 17227b456ed41bd72b56082f9fe234a26e9e20c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:01:52 +0000 Subject: [PATCH 17/41] Fix images not loading: configure Coil with app's OkHttpClient for User-Agent header The original Fresco setup used CustomOkHttpNetworkFetcher with the app's DI-provided OkHttpClient, which included CommonHeaderRequestInterceptor to add the required User-Agent header for Wikimedia servers. The Coil migration was using Coil's default OkHttpClient (via service loader auto-registration) which lacked the User-Agent header, causing Wikimedia servers to reject image requests. Fix: - Configure Coil's ImageLoader with OkHttpNetworkFetcherFactory using the app's injected OkHttpClient (has User-Agent, logging, timeouts) - Change SetWallpaperWorker, PicOfDayAppWidget, and ExploreMapController to use SingletonImageLoader.get() instead of creating new ImageLoader instances, ensuring they all use the properly configured client Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/cda5b12d-6f19-41fe-9cbe-a693187bc39a Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../java/fr/free/nrw/commons/CommonsApplication.kt | 10 +++++++++- .../nrw/commons/contributions/SetWallpaperWorker.kt | 4 ++-- .../nrw/commons/explore/map/ExploreMapController.kt | 5 ++--- .../fr/free/nrw/commons/widget/PicOfDayAppWidget.kt | 4 ++-- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt index d94297e99be..bdecc484f69 100644 --- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt +++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt @@ -16,6 +16,8 @@ import coil3.request.crossfade import coil3.SingletonImageLoader import coil3.disk.DiskCache import coil3.memory.MemoryCache +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import okhttp3.OkHttpClient import okio.Path.Companion.toPath import fr.free.nrw.commons.auth.LoginActivity import fr.free.nrw.commons.auth.SessionManager @@ -85,6 +87,9 @@ class CommonsApplication : MultiDexApplication() { @Inject lateinit var cookieJar: CommonsCookieJar + @Inject + lateinit var okHttpClient: OkHttpClient + var languageLookUpTable: AppLanguageLookUpTable? = null private set @@ -116,9 +121,12 @@ class CommonsApplication : MultiDexApplication() { defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet) } - // Initialize Coil image loader + // Initialize Coil image loader with the app's OkHttpClient for proper User-Agent header val imageLoader = ImageLoader.Builder(this) .crossfade(true) + .components { + add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) + } .memoryCache { MemoryCache.Builder() .maxSizePercent(this, 0.25) diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt index fe6c7747683..23dea5ff7d9 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt @@ -9,7 +9,7 @@ import android.os.Build import androidx.core.app.NotificationCompat import androidx.work.Worker import androidx.work.WorkerParameters -import coil3.ImageLoader +import coil3.SingletonImageLoader import coil3.request.ImageRequest import coil3.request.SuccessResult import coil3.request.allowHardware @@ -29,7 +29,7 @@ class SetWallpaperWorker(context: Context, params: WorkerParameters) : return runBlocking { try { - val imageLoader = ImageLoader(context) + val imageLoader = SingletonImageLoader.get(context) val request = ImageRequest.Builder(context) .data(imageUrl) .allowHardware(false) diff --git a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt index e8e5e9a4794..6d443d30389 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapController.kt @@ -4,7 +4,7 @@ import android.content.Context import android.content.res.Resources import android.graphics.Bitmap import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat -import coil3.ImageLoader +import coil3.SingletonImageLoader import coil3.request.ImageRequest import coil3.request.allowHardware import coil3.request.target @@ -173,8 +173,7 @@ class ExploreMapController @Inject constructor( ) baseMarker.place = explorePlace - val imageLoader = ImageLoader.Builder(context) - .build() + val imageLoader = SingletonImageLoader.get(context) val request = ImageRequest.Builder(context) .data(explorePlace.thumb) .size(96, 96) diff --git a/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt b/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt index 987d9149484..9582243103d 100644 --- a/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt +++ b/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt @@ -8,7 +8,7 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.widget.RemoteViews -import coil3.ImageLoader +import coil3.SingletonImageLoader import coil3.request.ImageRequest import coil3.request.SuccessResult import coil3.request.allowHardware @@ -130,7 +130,7 @@ class PicOfDayAppWidget : AppWidgetProvider() { CoroutineScope(Dispatchers.IO).launch { try { - val imageLoader = ImageLoader(context) + val imageLoader = SingletonImageLoader.get(context) val request = ImageRequest.Builder(context) .data(imageUrl) .allowHardware(false) From 57e96baf049e83d4f3938d7f650e54f31ddb9876 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:29:38 +0000 Subject: [PATCH 18/41] Fix media details page not loading + revert unrelated changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix mediaDetailImageView layout_width="0dp" → "match_parent" with scaleType (Fresco's SimpleDraweeView handled 0dp internally; standard ImageView needs actual dimensions for Coil's ViewSizeResolver to determine target size) - Create dedicated OkHttpClient for Coil via newBuilder() to share connection pool while stripping HTTP cache (Coil manages its own disk cache) - Add LimitedConnectionModeInterceptor (replaces CustomOkHttpNetworkFetcher behavior) - Revert unrelated changes: CHANGELOG.md, .gitignore, CryptoUtils.kt, CommonsCookieStorage.kt Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/55655df2-6dc5-47a1-abfb-b2315605f7d8 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .gitignore | 1 - CHANGELOG.md | 11 +++ .../fr/free/nrw/commons/CommonsApplication.kt | 45 ++++++++++- .../fr/free/nrw/commons/crypto/CryptoUtils.kt | 81 +++++++++++++++++++ .../wikidata/cookies/CommonsCookieStorage.kt | 17 +++- .../main/res/layout/fragment_media_detail.xml | 5 +- 6 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/fr/free/nrw/commons/crypto/CryptoUtils.kt diff --git a/.gitignore b/.gitignore index 9554676d903..7fa4767a7fb 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,3 @@ captures/* app/jacoco.exec app/CommonsContributions app/.* -.kotlin/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2e54d816b..0787592cba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Wikimedia Commons for Android +## v6.5.0 + +### What's changed +* Redesigned license selection for a simpler, more intuitive experience +* Users can now depictions directly from an interactive map view during uploads +* Upload HEIC images directly from supported devices without conversion +* Deletion requests now require specific reasons, reducing vague or invalid nominations +* Bug fixes and security improvements + +**Full Changelog**: https://github.com/commons-app/apps-android-commons/compare/v6.4.0...v6.5.0 + ## v6.4.0 ### What's changed diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt index bdecc484f69..b0b8bc025d1 100644 --- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt +++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt @@ -17,6 +17,9 @@ import coil3.SingletonImageLoader import coil3.disk.DiskCache import coil3.memory.MemoryCache import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.intercept.Interceptor as CoilInterceptor +import coil3.request.ErrorResult +import coil3.request.ImageResult import okhttp3.OkHttpClient import okio.Path.Companion.toPath import fr.free.nrw.commons.auth.LoginActivity @@ -121,11 +124,22 @@ class CommonsApplication : MultiDexApplication() { defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet) } - // Initialize Coil image loader with the app's OkHttpClient for proper User-Agent header + // Create a dedicated OkHttpClient for image loading: + // - Shares connection pool with the DI client via newBuilder() + // - Inherits User-Agent header from CommonHeaderRequestInterceptor + // - Removes OkHttp's HTTP cache (Coil manages its own disk cache) + val imageHttpClient = okHttpClient.newBuilder() + .cache(null) + .build() + + // Initialize Coil image loader val imageLoader = ImageLoader.Builder(this) .crossfade(true) .components { - add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) + add(OkHttpNetworkFetcherFactory(callFactory = { imageHttpClient })) + // Skip image loading when limited connection mode is enabled + // (replaces the original CustomOkHttpNetworkFetcher behavior) + add(LimitedConnectionModeInterceptor(defaultPrefs)) } .memoryCache { MemoryCache.Builder() @@ -430,3 +444,30 @@ class CommonsApplication : MultiDexApplication() { } } +/** + * Coil interceptor that skips network image loading when limited connection mode is enabled. + * This replaces the original CustomOkHttpNetworkFetcher behavior from the Fresco setup. + */ +class LimitedConnectionModeInterceptor( + private val defaultKvStore: JsonKvStore +) : CoilInterceptor { + + override suspend fun intercept(chain: CoilInterceptor.Chain): ImageResult { + val isLimitedConnectionMode = defaultKvStore.getBoolean( + CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false + ) + if (isLimitedConnectionMode && chain.request.data is String) { + val url = chain.request.data as String + if (url.startsWith("http://") || url.startsWith("https://")) { + Timber.d("Skipping image load in limited connection mode: %s", url) + return ErrorResult( + image = null, + request = chain.request, + throwable = Exception("Limited connection mode enabled") + ) + } + } + return chain.proceed() + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/crypto/CryptoUtils.kt b/app/src/main/java/fr/free/nrw/commons/crypto/CryptoUtils.kt new file mode 100644 index 00000000000..4a097fa2d36 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/crypto/CryptoUtils.kt @@ -0,0 +1,81 @@ +package fr.free.nrw.commons.crypto + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import timber.log.Timber +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Utility class for encrypting and decrypting data using the Android Keystore. + * Uses AES-GCM (Advanced Encryption Standard with Galois/Counter Mode). + */ +object CryptoUtils { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val KEY_ALIAS = "commons_cookie_store_key" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_LENGTH = 12 + + private fun getOrGenerateKey(): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey } + + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + val spec = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + keyGenerator.init(spec) + return keyGenerator.generateKey() + } + + /** + * Encrypts the given plaintext string using AES-GCM and Android KeyStore. + */ + fun encrypt(plaintext: String?): String? { + if (plaintext == null) return null + return try { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrGenerateKey()) + val iv = cipher.iv + val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + val combined = iv + ciphertext + Base64.encodeToString(combined, Base64.DEFAULT) + } catch (e: Exception) { + Timber.e(e, "Error encrypting data") + null + } + } + + /** + * Decrypts the given base64 encrypted string using AES-GCM and Android KeyStore. + * Returns the input string if decryption fails (useful for migration from plaintext). + */ + fun decrypt(encryptedBase64: String?): String? { + if (encryptedBase64 == null) return null + return try { + val combined = Base64.decode(encryptedBase64, Base64.DEFAULT) + if (combined.size <= IV_LENGTH) return encryptedBase64 + + val iv = combined.copyOfRange(0, IV_LENGTH) + val ciphertext = combined.copyOfRange(IV_LENGTH, combined.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, getOrGenerateKey(), spec) + val plaintext = cipher.doFinal(ciphertext) + String(plaintext, Charsets.UTF_8) + } catch (e: Exception) { + Timber.w(e, "Decryption failed, assuming plaintext (migration)") + // It might be plaintext (before migration), so return it directly + encryptedBase64 + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/cookies/CommonsCookieStorage.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/cookies/CommonsCookieStorage.kt index 2b0e7807040..d5ae95a04f7 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/cookies/CommonsCookieStorage.kt +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/cookies/CommonsCookieStorage.kt @@ -4,6 +4,7 @@ import com.google.gson.GsonBuilder import com.google.gson.TypeAdapter import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonWriter +import fr.free.nrw.commons.crypto.CryptoUtils import fr.free.nrw.commons.kvstore.JsonKvStore import fr.free.nrw.commons.wikidata.model.WikiSite import okhttp3.Cookie @@ -39,14 +40,26 @@ class CommonsCookieStorage( fun load() { cookieMap.clear() - val json = preferences!!.getString(COOKIE_STORE, null) + val encryptedJson = preferences!!.getString(COOKIE_STORE, null) + val json = CryptoUtils.decrypt(encryptedJson) + if (!json.isNullOrEmpty()) { val serializedData = gson.fromJson(json, CommonsCookieStorage::class.java) cookieMap.putAll(serializedData.cookieMap) + + // If the loaded json wasn't encrypted (e.g. migrating from older version) + // or if it was successfully decrypted, let's re-save it to ensure it's encrypted on disk. + if (encryptedJson == json) { + save() + } } } - fun save() = preferences!!.putString(COOKIE_STORE, gson.toJson(this)) + fun save() { + val json = gson.toJson(this) + val encryptedJson = CryptoUtils.encrypt(json) + preferences!!.putString(COOKIE_STORE, encryptedJson ?: json) + } fun contains(domainSpec: String): Boolean = cookieMap.containsKey(domainSpec) diff --git a/app/src/main/res/layout/fragment_media_detail.xml b/app/src/main/res/layout/fragment_media_detail.xml index 547a29f4ab2..0b0eef3527d 100644 --- a/app/src/main/res/layout/fragment_media_detail.xml +++ b/app/src/main/res/layout/fragment_media_detail.xml @@ -26,9 +26,10 @@ + android:layout_gravity="center_horizontal" + android:scaleType="fitCenter" /> Date: Sat, 11 Apr 2026 11:25:09 +0000 Subject: [PATCH 19/41] Fix image caching flicker in media details screen Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/9e921489-e056-41ad-932e-604e9765d03a Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../fr/free/nrw/commons/media/MediaDetailFragment.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 15d76b5a15d..b06f1c7eb83 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 @@ -7,6 +7,7 @@ import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration import coil3.load +import coil3.request.crossfade import coil3.request.placeholder import coil3.request.error import android.net.Uri @@ -708,6 +709,8 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C /** * Uses Coil to load the media image with a placeholder. + * Clears any previously displayed image first to avoid showing a stale + * cached image before the placeholder appears. */ private fun setupImageView() { val imageBackgroundColor: Int = imageBackgroundColor @@ -715,10 +718,17 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor) } + // Immediately show the placeholder so the previous media item's image + // is not briefly visible while Coil resolves the new request. + binding.mediaDetailImageView.setImageResource(R.drawable.image_placeholder) + val imageUrl = if (media != null) media!!.imageUrl else null val thumbUrl = if (media != null) media!!.thumbUrl else null binding.mediaDetailImageView.load(imageUrl ?: thumbUrl) { + // Disable crossfade so Coil does not animate from the old cached + // image to the placeholder and then to the actual image. + crossfade(false) placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) listener( From e4151db825ed5230aeefb27618e9f6f2c9528786 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:13:44 +0000 Subject: [PATCH 20/41] Fix media details: show thumbnail preview while full image loads Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/d9883823-7e79-4d2b-90e0-90f31c610857 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../nrw/commons/media/MediaDetailFragment.kt | 66 ++++++++++++------- 1 file changed, 43 insertions(+), 23 deletions(-) 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 b06f1c7eb83..f6ce959c5f2 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 @@ -7,7 +7,6 @@ import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration import coil3.load -import coil3.request.crossfade import coil3.request.placeholder import coil3.request.error import android.net.Uri @@ -708,9 +707,9 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } /** - * Uses Coil to load the media image with a placeholder. - * Clears any previously displayed image first to avoid showing a stale - * cached image before the placeholder appears. + * Uses Coil to load the media image. + * Loads the thumbnail first as a quick preview, then replaces it with the + * full-resolution image once available. */ private fun setupImageView() { val imageBackgroundColor: Int = imageBackgroundColor @@ -718,29 +717,50 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor) } - // Immediately show the placeholder so the previous media item's image - // is not briefly visible while Coil resolves the new request. - binding.mediaDetailImageView.setImageResource(R.drawable.image_placeholder) - val imageUrl = if (media != null) media!!.imageUrl else null val thumbUrl = if (media != null) media!!.thumbUrl else null - binding.mediaDetailImageView.load(imageUrl ?: thumbUrl) { - // Disable crossfade so Coil does not animate from the old cached - // image to the placeholder and then to the actual image. - crossfade(false) - placeholder(R.drawable.image_placeholder) - error(R.drawable.image_placeholder) - listener( - onSuccess = { _, _ -> - val d = binding.mediaDetailImageView.drawable - if (d != null) { - cachedImageWidth = d.intrinsicWidth - cachedImageHeight = d.intrinsicHeight + // Load thumbnail first so the user sees a quick preview instead of a + // placeholder, then load the full-resolution image on top of it. + if (!thumbUrl.isNullOrEmpty() && !imageUrl.isNullOrEmpty() && thumbUrl != imageUrl) { + binding.mediaDetailImageView.load(thumbUrl) { + placeholder(R.drawable.image_placeholder) + error(R.drawable.image_placeholder) + listener( + onSuccess = { _, _ -> + // Thumbnail is now visible — load the full image over it. + binding.mediaDetailImageView.load(imageUrl) { + placeholder(binding.mediaDetailImageView.drawable) + error(R.drawable.image_placeholder) + listener( + onSuccess = { _, _ -> + val d = binding.mediaDetailImageView.drawable + if (d != null) { + cachedImageWidth = d.intrinsicWidth + cachedImageHeight = d.intrinsicHeight + } + updateAspectRatio(binding.mediaDetailScrollView.width) + } + ) + } } - updateAspectRatio(binding.mediaDetailScrollView.width) - } - ) + ) + } + } else { + binding.mediaDetailImageView.load(imageUrl ?: thumbUrl) { + placeholder(R.drawable.image_placeholder) + error(R.drawable.image_placeholder) + listener( + onSuccess = { _, _ -> + val d = binding.mediaDetailImageView.drawable + if (d != null) { + cachedImageWidth = d.intrinsicWidth + cachedImageHeight = d.intrinsicHeight + } + updateAspectRatio(binding.mediaDetailScrollView.width) + } + ) + } } } From 25bbc4780a7937b735cde02b53a1c404627e0493 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:24:51 +0000 Subject: [PATCH 21/41] Fix stale image: clear old image before loading thumbnail in setupImageView Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/0dce2265-b7a2-4a13-bf17-7d0f21fe7604 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../nrw/commons/media/MediaDetailFragment.kt | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) 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 f6ce959c5f2..353c80f0a4e 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 @@ -707,9 +707,13 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } /** - * Uses Coil to load the media image. - * Loads the thumbnail first as a quick preview, then replaces it with the - * full-resolution image once available. + * Uses two image sources via Coil, mirroring the original Fresco behaviour: + * - low resolution thumbnail is shown initially + * - when the high resolution image is available, it replaces the low resolution image + * + * The previous image is cleared immediately so that a stale image from the + * previously viewed media item is never visible (equivalent to Fresco's + * {@code setOldController}). */ private fun setupImageView() { val imageBackgroundColor: Int = imageBackgroundColor @@ -717,6 +721,10 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor) } + // Clear any stale image from the previous media item immediately, + // equivalent to Fresco's setOldController() behaviour. + binding.mediaDetailImageView.setImageDrawable(null) + val imageUrl = if (media != null) media!!.imageUrl else null val thumbUrl = if (media != null) media!!.thumbUrl else null @@ -728,6 +736,15 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C error(R.drawable.image_placeholder) listener( onSuccess = { _, _ -> + // Update aspect ratio for the intermediate (thumbnail) image, + // mirroring Fresco's onIntermediateImageSet callback. + val d = binding.mediaDetailImageView.drawable + if (d != null) { + cachedImageWidth = d.intrinsicWidth + cachedImageHeight = d.intrinsicHeight + } + updateAspectRatio(binding.mediaDetailScrollView.width) + // Thumbnail is now visible — load the full image over it. binding.mediaDetailImageView.load(imageUrl) { placeholder(binding.mediaDetailImageView.drawable) From 29b07a9df31d65eba78a611d05ade38e3582005b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:37:49 +0000 Subject: [PATCH 22/41] Fix stale/cached image issues following Coil best practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MediaDetailFragment: use placeholderMemoryCacheKey for thumbnail→full-res transition instead of nested load() anti-pattern - All list adapters: disable crossfade and add placeholder/error to prevent stale recycled images (matching Fresco's fadeDuration=0 behavior) - GridViewAdapter, PagedMediaAdapter, ReviewActivity: add missing placeholder/error drawables Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/75545353-720d-4e84-9e72-7b4d6d905afd Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../nrw/commons/category/GridViewAdapter.kt | 9 +++++- .../contributions/ContributionViewHolder.kt | 2 ++ .../explore/media/PagedMediaAdapter.kt | 9 +++++- .../nrw/commons/media/MediaDetailFragment.kt | 28 +++++++++++++------ .../free/nrw/commons/review/ReviewActivity.kt | 9 +++++- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt index 187ea1bf140..6b43ba21e90 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt @@ -9,6 +9,9 @@ import android.widget.ArrayAdapter import android.widget.ImageView import android.widget.TextView import coil3.load +import coil3.request.crossfade +import coil3.request.placeholder +import coil3.request.error import fr.free.nrw.commons.Media import fr.free.nrw.commons.R @@ -79,7 +82,11 @@ class GridViewAdapter( item?.let { fileName.text = it.mostRelevantCaption setUploaderView(it, uploader) - imageView.load(it.thumbUrl) + imageView.load(it.thumbUrl) { + crossfade(false) + placeholder(R.drawable.image_placeholder) + error(R.drawable.image_placeholder) + } } return view 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 1e4eb645e69..7229277e89d 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 @@ -7,6 +7,7 @@ import android.webkit.URLUtil import androidx.appcompat.app.AlertDialog import androidx.recyclerview.widget.RecyclerView import coil3.load +import coil3.request.crossfade import coil3.request.placeholder import coil3.request.error import fr.free.nrw.commons.Media @@ -73,6 +74,7 @@ an upload might take a dozen seconds. */ else -> R.drawable.image_placeholder } binding.contributionImage.load(data) { + crossfade(false) placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) } 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 0293ce86392..99ad7b0a433 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 @@ -3,6 +3,9 @@ package fr.free.nrw.commons.explore.media import android.view.View import android.view.ViewGroup import coil3.load +import coil3.request.crossfade +import coil3.request.placeholder +import coil3.request.error import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import fr.free.nrw.commons.Media @@ -79,7 +82,11 @@ class SearchImagesViewHolder( val media = item.first binding.categoryImageView.setOnClickListener { onImageClicked(item.second) } binding.categoryImageTitle.text = media.mostRelevantCaption - binding.categoryImageView.load(media.thumbUrl) + binding.categoryImageView.load(media.thumbUrl) { + crossfade(false) + placeholder(R.drawable.image_placeholder) + error(R.drawable.image_placeholder) + } setAuthorText(media) } 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 353c80f0a4e..71a5f515878 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 @@ -7,6 +7,7 @@ import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration import coil3.load +import coil3.request.crossfade import coil3.request.placeholder import coil3.request.error import android.net.Uri @@ -711,9 +712,14 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C * - low resolution thumbnail is shown initially * - when the high resolution image is available, it replaces the low resolution image * - * The previous image is cleared immediately so that a stale image from the - * previously viewed media item is never visible (equivalent to Fresco's - * {@code setOldController}). + * Following Coil best practices: + * 1. Load the thumbnail first — this populates the memory cache. + * 2. Load the full-resolution image using {@code placeholderMemoryCacheKey} + * so the cached thumbnail is displayed instantly as a placeholder while + * the full image loads (see Coil recipes: "Using a Memory Cache Key as + * a Placeholder"). + * 3. Disable crossfade so the user never sees the stale image from a + * previously viewed media item. */ private fun setupImageView() { val imageBackgroundColor: Int = imageBackgroundColor @@ -728,14 +734,16 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C val imageUrl = if (media != null) media!!.imageUrl else null val thumbUrl = if (media != null) media!!.thumbUrl else null - // Load thumbnail first so the user sees a quick preview instead of a - // placeholder, then load the full-resolution image on top of it. + // Load thumbnail first so the user sees a quick preview, then load + // the full-resolution image using the thumbnail's memory cache key as + // a synchronous placeholder. if (!thumbUrl.isNullOrEmpty() && !imageUrl.isNullOrEmpty() && thumbUrl != imageUrl) { binding.mediaDetailImageView.load(thumbUrl) { + crossfade(false) placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) listener( - onSuccess = { _, _ -> + onSuccess = { _, result -> // Update aspect ratio for the intermediate (thumbnail) image, // mirroring Fresco's onIntermediateImageSet callback. val d = binding.mediaDetailImageView.drawable @@ -745,9 +753,12 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } updateAspectRatio(binding.mediaDetailScrollView.width) - // Thumbnail is now visible — load the full image over it. + // Now load the full image, using the thumbnail's memory + // cache key so it's shown instantly as a placeholder + // (Coil best practice for thumbnail → full-res transitions). binding.mediaDetailImageView.load(imageUrl) { - placeholder(binding.mediaDetailImageView.drawable) + crossfade(true) + placeholderMemoryCacheKey(result.memoryCacheKey) error(R.drawable.image_placeholder) listener( onSuccess = { _, _ -> @@ -765,6 +776,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } } else { binding.mediaDetailImageView.load(imageUrl ?: thumbUrl) { + crossfade(false) placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) listener( diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt index 6c6ed49e42e..0954f8d623a 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt @@ -2,6 +2,9 @@ package fr.free.nrw.commons.review import android.annotation.SuppressLint import coil3.load +import coil3.request.crossfade +import coil3.request.placeholder +import coil3.request.error import android.content.Context import android.content.Intent import android.graphics.PorterDuff @@ -189,7 +192,11 @@ class ReviewActivity : BaseActivity() { return } - binding.reviewImageView.load(media.thumbUrl) + binding.reviewImageView.load(media.thumbUrl) { + crossfade(false) + placeholder(R.drawable.image_placeholder) + error(R.drawable.image_placeholder) + } reviewController.onImageRefreshed(media) // filename is updated compositeDisposable.add( From 62a687d32f41ce99eb6782d756ed5a1aff61b26d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:49:37 +0000 Subject: [PATCH 23/41] Plan: remove imageLoadGeneration, add loading spinner for media detail Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/2eda072c-bf1f-467f-9cce-7b5604adb36f Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../nrw/commons/media/MediaDetailFragment.kt | 86 ++++++++++++------- 1 file changed, 53 insertions(+), 33 deletions(-) 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 71a5f515878..1b567423d24 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 @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration +import coil3.dispose import coil3.load import coil3.request.crossfade import coil3.request.placeholder @@ -213,6 +214,15 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C private var heightVerifyingBoolean: Boolean = true // helps in maintaining aspect ratio private var layoutListener: OnGlobalLayoutListener? = null // for layout stuff, only used once! + /** + * Monotonically increasing counter that is bumped every time [setupImageView] + * is called. Callbacks captured by a previous invocation compare their + * captured value against the current field — if they differ, the callback + * is stale and must be ignored. This prevents wrong-image flashes when the + * user swipes quickly between items in the ViewPager. + */ + private var imageLoadGeneration: Int = 0 + //Had to make this class variable, to implement various onClicks, which access the media, // also I fell why make separate variables when one can serve the purpose private var media: Media? = null @@ -712,14 +722,14 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C * - low resolution thumbnail is shown initially * - when the high resolution image is available, it replaces the low resolution image * - * Following Coil best practices: - * 1. Load the thumbnail first — this populates the memory cache. - * 2. Load the full-resolution image using {@code placeholderMemoryCacheKey} - * so the cached thumbnail is displayed instantly as a placeholder while - * the full image loads (see Coil recipes: "Using a Memory Cache Key as - * a Placeholder"). - * 3. Disable crossfade so the user never sees the stale image from a - * previously viewed media item. + * Coil best-practice notes (coil-kt.github.io/coil/recipes): + * • Call {@code dispose()} before every new load to cancel any in-flight + * request and avoid stale callbacks from a previous media item. + * • Use a per-call "generation" counter ({@link #imageLoadGeneration}) so + * that delayed success callbacks whose generation doesn't match the + * current one are silently dropped. + * • Use {@code placeholderMemoryCacheKey} for a smooth thumbnail → + * full-resolution transition without a flash of a placeholder. */ private fun setupImageView() { val imageBackgroundColor: Int = imageBackgroundColor @@ -727,46 +737,46 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor) } - // Clear any stale image from the previous media item immediately, - // equivalent to Fresco's setOldController() behaviour. + // Cancel any in-flight Coil request for this ImageView. + // Coil best practice: always dispose() before starting a new load on + // the same view, especially when the view is reused (ViewPager / RecyclerView). + binding.mediaDetailImageView.dispose() + + // Clear the previous drawable so the user never sees a stale image. binding.mediaDetailImageView.setImageDrawable(null) + // Bump the generation so that any stale callback from a previous + // setupImageView() invocation is ignored. + val currentGeneration = ++imageLoadGeneration + val imageUrl = if (media != null) media!!.imageUrl else null val thumbUrl = if (media != null) media!!.thumbUrl else null - // Load thumbnail first so the user sees a quick preview, then load - // the full-resolution image using the thumbnail's memory cache key as - // a synchronous placeholder. if (!thumbUrl.isNullOrEmpty() && !imageUrl.isNullOrEmpty() && thumbUrl != imageUrl) { + // Step 1: Load the thumbnail for a fast preview. binding.mediaDetailImageView.load(thumbUrl) { crossfade(false) placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) listener( onSuccess = { _, result -> - // Update aspect ratio for the intermediate (thumbnail) image, - // mirroring Fresco's onIntermediateImageSet callback. - val d = binding.mediaDetailImageView.drawable - if (d != null) { - cachedImageWidth = d.intrinsicWidth - cachedImageHeight = d.intrinsicHeight - } + // Guard: ignore if a newer setupImageView() was called. + if (currentGeneration != imageLoadGeneration) return@listener + + updateImageDimensions() updateAspectRatio(binding.mediaDetailScrollView.width) - // Now load the full image, using the thumbnail's memory - // cache key so it's shown instantly as a placeholder - // (Coil best practice for thumbnail → full-res transitions). + // Step 2: Load the full-resolution image, using the + // thumbnail's memory-cache key as an instant placeholder + // (Coil recipe: "Using a Memory Cache Key as a Placeholder"). binding.mediaDetailImageView.load(imageUrl) { crossfade(true) placeholderMemoryCacheKey(result.memoryCacheKey) error(R.drawable.image_placeholder) listener( onSuccess = { _, _ -> - val d = binding.mediaDetailImageView.drawable - if (d != null) { - cachedImageWidth = d.intrinsicWidth - cachedImageHeight = d.intrinsicHeight - } + if (currentGeneration != imageLoadGeneration) return@listener + updateImageDimensions() updateAspectRatio(binding.mediaDetailScrollView.width) } ) @@ -781,11 +791,8 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C error(R.drawable.image_placeholder) listener( onSuccess = { _, _ -> - val d = binding.mediaDetailImageView.drawable - if (d != null) { - cachedImageWidth = d.intrinsicWidth - cachedImageHeight = d.intrinsicHeight - } + if (currentGeneration != imageLoadGeneration) return@listener + updateImageDimensions() updateAspectRatio(binding.mediaDetailScrollView.width) } ) @@ -793,6 +800,15 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } } + /** Reads the current drawable dimensions into the cached size fields. */ + private fun updateImageDimensions() { + val d = binding.mediaDetailImageView.drawable + if (d != null) { + cachedImageWidth = d.intrinsicWidth + cachedImageHeight = d.intrinsicHeight + } + } + private fun updateToDoWarning() { var toDoMessage = "" var toDoNeeded = false @@ -834,6 +850,10 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C layoutListener = null } + // Cancel any in-flight Coil request so stale callbacks can't fire + // after the view is destroyed (Coil best practice). + binding.mediaDetailImageView.dispose() + compositeDisposable.clear() super.onDestroyView() From 6b6a2dc3d424a12894dbc41f6b48fda669d6bc56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:55:13 +0000 Subject: [PATCH 24/41] =?UTF-8?q?Remove=20all=20workarounds=20=E2=80=94=20?= =?UTF-8?q?use=20only=20standard=20Coil=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dispose() calls (load() auto-cancels previous requests) - Remove setImageDrawable(null) (placeholder() clears stale images) - Remove imageLoadGeneration counter (unnecessary with Coil) - Remove crossfade(false) overrides (placeholder() handles stale images) - Add ProgressBar loading spinner to match Fresco's built-in indicator - Keep only the documented Coil recipe: placeholderMemoryCacheKey Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/2eda072c-bf1f-467f-9cce-7b5604adb36f Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../nrw/commons/category/GridViewAdapter.kt | 2 - .../contributions/ContributionViewHolder.kt | 2 - .../explore/media/PagedMediaAdapter.kt | 2 - .../nrw/commons/media/MediaDetailFragment.kt | 70 ++++++------------- .../free/nrw/commons/review/ReviewActivity.kt | 2 - .../main/res/layout/fragment_media_detail.xml | 25 +++++-- 6 files changed, 42 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt index 6b43ba21e90..f5ec401fe00 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt @@ -9,7 +9,6 @@ import android.widget.ArrayAdapter import android.widget.ImageView import android.widget.TextView import coil3.load -import coil3.request.crossfade import coil3.request.placeholder import coil3.request.error import fr.free.nrw.commons.Media @@ -83,7 +82,6 @@ class GridViewAdapter( fileName.text = it.mostRelevantCaption setUploaderView(it, uploader) imageView.load(it.thumbUrl) { - crossfade(false) placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) } 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 7229277e89d..1e4eb645e69 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 @@ -7,7 +7,6 @@ import android.webkit.URLUtil import androidx.appcompat.app.AlertDialog import androidx.recyclerview.widget.RecyclerView import coil3.load -import coil3.request.crossfade import coil3.request.placeholder import coil3.request.error import fr.free.nrw.commons.Media @@ -74,7 +73,6 @@ an upload might take a dozen seconds. */ else -> R.drawable.image_placeholder } binding.contributionImage.load(data) { - crossfade(false) placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) } 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 99ad7b0a433..9424591aa8f 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 @@ -3,7 +3,6 @@ package fr.free.nrw.commons.explore.media import android.view.View import android.view.ViewGroup import coil3.load -import coil3.request.crossfade import coil3.request.placeholder import coil3.request.error import androidx.paging.PagedListAdapter @@ -83,7 +82,6 @@ class SearchImagesViewHolder( binding.categoryImageView.setOnClickListener { onImageClicked(item.second) } binding.categoryImageTitle.text = media.mostRelevantCaption binding.categoryImageView.load(media.thumbUrl) { - crossfade(false) placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) } 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 1b567423d24..465bd227dff 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 @@ -6,9 +6,7 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration -import coil3.dispose import coil3.load -import coil3.request.crossfade import coil3.request.placeholder import coil3.request.error import android.net.Uri @@ -214,15 +212,6 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C private var heightVerifyingBoolean: Boolean = true // helps in maintaining aspect ratio private var layoutListener: OnGlobalLayoutListener? = null // for layout stuff, only used once! - /** - * Monotonically increasing counter that is bumped every time [setupImageView] - * is called. Callbacks captured by a previous invocation compare their - * captured value against the current field — if they differ, the callback - * is stale and must be ignored. This prevents wrong-image flashes when the - * user swipes quickly between items in the ViewPager. - */ - private var imageLoadGeneration: Int = 0 - //Had to make this class variable, to implement various onClicks, which access the media, // also I fell why make separate variables when one can serve the purpose private var media: Media? = null @@ -722,14 +711,14 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C * - low resolution thumbnail is shown initially * - when the high resolution image is available, it replaces the low resolution image * - * Coil best-practice notes (coil-kt.github.io/coil/recipes): - * • Call {@code dispose()} before every new load to cancel any in-flight - * request and avoid stale callbacks from a previous media item. - * • Use a per-call "generation" counter ({@link #imageLoadGeneration}) so - * that delayed success callbacks whose generation doesn't match the - * current one are silently dropped. - * • Use {@code placeholderMemoryCacheKey} for a smooth thumbnail → - * full-resolution transition without a flash of a placeholder. + * Follows the documented Coil recipe "Using a Memory Cache Key as a Placeholder": + * https://coil-kt.github.io/coil/recipes/#using-a-memory-cache-key-as-a-placeholder + * + * No manual dispose() or setImageDrawable(null) is needed: + * • Calling load() auto-cancels any previous request on the same ImageView. + * • Setting a placeholder() immediately replaces the previous drawable, + * so stale images from a prior page are never visible. + * • Coil auto-cancels requests when the view detaches from the window. */ private fun setupImageView() { val imageBackgroundColor: Int = imageBackgroundColor @@ -737,63 +726,52 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor) } - // Cancel any in-flight Coil request for this ImageView. - // Coil best practice: always dispose() before starting a new load on - // the same view, especially when the view is reused (ViewPager / RecyclerView). - binding.mediaDetailImageView.dispose() + binding.mediaDetailImageProgress.visibility = View.VISIBLE - // Clear the previous drawable so the user never sees a stale image. - binding.mediaDetailImageView.setImageDrawable(null) - - // Bump the generation so that any stale callback from a previous - // setupImageView() invocation is ignored. - val currentGeneration = ++imageLoadGeneration - - val imageUrl = if (media != null) media!!.imageUrl else null - val thumbUrl = if (media != null) media!!.thumbUrl else null + val imageUrl = media?.imageUrl + val thumbUrl = media?.thumbUrl if (!thumbUrl.isNullOrEmpty() && !imageUrl.isNullOrEmpty() && thumbUrl != imageUrl) { - // Step 1: Load the thumbnail for a fast preview. + // Load thumbnail first for a fast preview. binding.mediaDetailImageView.load(thumbUrl) { - crossfade(false) placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) listener( onSuccess = { _, result -> - // Guard: ignore if a newer setupImageView() was called. - if (currentGeneration != imageLoadGeneration) return@listener - + binding.mediaDetailImageProgress.visibility = View.GONE updateImageDimensions() updateAspectRatio(binding.mediaDetailScrollView.width) - // Step 2: Load the full-resolution image, using the - // thumbnail's memory-cache key as an instant placeholder - // (Coil recipe: "Using a Memory Cache Key as a Placeholder"). + // Load full-resolution image, using the thumbnail's memory + // cache key as a synchronous placeholder (Coil recipe). binding.mediaDetailImageView.load(imageUrl) { - crossfade(true) placeholderMemoryCacheKey(result.memoryCacheKey) error(R.drawable.image_placeholder) listener( onSuccess = { _, _ -> - if (currentGeneration != imageLoadGeneration) return@listener updateImageDimensions() updateAspectRatio(binding.mediaDetailScrollView.width) } ) } + }, + onError = { _, _ -> + binding.mediaDetailImageProgress.visibility = View.GONE } ) } } else { binding.mediaDetailImageView.load(imageUrl ?: thumbUrl) { - crossfade(false) placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) listener( onSuccess = { _, _ -> - if (currentGeneration != imageLoadGeneration) return@listener + binding.mediaDetailImageProgress.visibility = View.GONE updateImageDimensions() updateAspectRatio(binding.mediaDetailScrollView.width) + }, + onError = { _, _ -> + binding.mediaDetailImageProgress.visibility = View.GONE } ) } @@ -850,10 +828,6 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C layoutListener = null } - // Cancel any in-flight Coil request so stale callbacks can't fire - // after the view is destroyed (Coil best practice). - binding.mediaDetailImageView.dispose() - compositeDisposable.clear() super.onDestroyView() diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt index 0954f8d623a..1d028f4365e 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt @@ -2,7 +2,6 @@ package fr.free.nrw.commons.review import android.annotation.SuppressLint import coil3.load -import coil3.request.crossfade import coil3.request.placeholder import coil3.request.error import android.content.Context @@ -193,7 +192,6 @@ class ReviewActivity : BaseActivity() { } binding.reviewImageView.load(media.thumbUrl) { - crossfade(false) placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) } diff --git a/app/src/main/res/layout/fragment_media_detail.xml b/app/src/main/res/layout/fragment_media_detail.xml index 0b0eef3527d..44e57f2f976 100644 --- a/app/src/main/res/layout/fragment_media_detail.xml +++ b/app/src/main/res/layout/fragment_media_detail.xml @@ -24,12 +24,27 @@ layout="@layout/show_captions_descriptions" /> - + android:layout_height="wrap_content"> + + + + + + Date: Sat, 11 Apr 2026 13:56:31 +0000 Subject: [PATCH 25/41] Address code review: fallback to full image on thumbnail error, remove unnecessary null-safety Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/2eda072c-bf1f-467f-9cce-7b5604adb36f Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../nrw/commons/media/MediaDetailFragment.kt | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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 465bd227dff..bac120f6b54 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 @@ -728,8 +728,8 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C binding.mediaDetailImageProgress.visibility = View.VISIBLE - val imageUrl = media?.imageUrl - val thumbUrl = media?.thumbUrl + val imageUrl = media!!.imageUrl + val thumbUrl = media!!.thumbUrl if (!thumbUrl.isNullOrEmpty() && !imageUrl.isNullOrEmpty() && thumbUrl != imageUrl) { // Load thumbnail first for a fast preview. @@ -756,7 +756,21 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } }, onError = { _, _ -> - binding.mediaDetailImageProgress.visibility = View.GONE + // Thumbnail failed — fall back to loading the full image directly. + binding.mediaDetailImageView.load(imageUrl) { + placeholder(R.drawable.image_placeholder) + error(R.drawable.image_placeholder) + listener( + onSuccess = { _, _ -> + binding.mediaDetailImageProgress.visibility = View.GONE + updateImageDimensions() + updateAspectRatio(binding.mediaDetailScrollView.width) + }, + onError = { _, _ -> + binding.mediaDetailImageProgress.visibility = View.GONE + } + ) + } } ) } From e0958606527a3a1d34a1177ed529813047dfc91e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:07:10 +0000 Subject: [PATCH 26/41] Fix wrong image flash: remove static placeholder, use spinner only The image_placeholder.png (a 1000x776 grayscale PNG) was being used as Coil's placeholder(), causing it to flash as a "weird image" in the media detail view before every real image loaded. Since the ProgressBar spinner already provides loading feedback, the static placeholder is unnecessary here. error() still uses image_placeholder as a fallback. Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/d8242772-3957-464f-9a98-81388c3ed3f8 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../fr/free/nrw/commons/media/MediaDetailFragment.kt | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) 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 bac120f6b54..c5a87080933 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 @@ -7,7 +7,6 @@ import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration import coil3.load -import coil3.request.placeholder import coil3.request.error import android.net.Uri import android.os.Bundle @@ -714,11 +713,8 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C * Follows the documented Coil recipe "Using a Memory Cache Key as a Placeholder": * https://coil-kt.github.io/coil/recipes/#using-a-memory-cache-key-as-a-placeholder * - * No manual dispose() or setImageDrawable(null) is needed: - * • Calling load() auto-cancels any previous request on the same ImageView. - * • Setting a placeholder() immediately replaces the previous drawable, - * so stale images from a prior page are never visible. - * • Coil auto-cancels requests when the view detaches from the window. + * A ProgressBar spinner is shown while loading. No static placeholder image + * is used because it would flash as a "wrong image" before the real one loads. */ private fun setupImageView() { val imageBackgroundColor: Int = imageBackgroundColor @@ -734,7 +730,6 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C if (!thumbUrl.isNullOrEmpty() && !imageUrl.isNullOrEmpty() && thumbUrl != imageUrl) { // Load thumbnail first for a fast preview. binding.mediaDetailImageView.load(thumbUrl) { - placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) listener( onSuccess = { _, result -> @@ -758,7 +753,6 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C onError = { _, _ -> // Thumbnail failed — fall back to loading the full image directly. binding.mediaDetailImageView.load(imageUrl) { - placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) listener( onSuccess = { _, _ -> @@ -776,7 +770,6 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } } else { binding.mediaDetailImageView.load(imageUrl ?: thumbUrl) { - placeholder(R.drawable.image_placeholder) error(R.drawable.image_placeholder) listener( onSuccess = { _, _ -> From f2322bc71588dd935b9e128a907e147dfc7e67e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:14:48 +0000 Subject: [PATCH 27/41] Clear stale drawable before Coil load to prevent old image flash When navigating to media detail, the ImageView retained the drawable from the previous screen (e.g. first contribution thumbnail). Without an explicit clear, Coil keeps the existing drawable visible while loading. Adding setImageDrawable(null) ensures only the ProgressBar spinner is shown during load, matching Fresco's setOldController behavior. Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/6987b50c-c42b-436e-b2ab-3054a46e7773 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt | 1 + 1 file changed, 1 insertion(+) 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 c5a87080933..cb1742e7054 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 @@ -722,6 +722,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor) } + binding.mediaDetailImageView.setImageDrawable(null) binding.mediaDetailImageProgress.visibility = View.VISIBLE val imageUrl = media!!.imageUrl From 3c3310b344147fb29dcf6855a09af1b85c883d2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:26:27 +0000 Subject: [PATCH 28/41] Fix stale image flash: disable crossfade on initial load in MediaDetailFragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global crossfade(true) on the Coil ImageLoader causes a CrossfadeDrawable that transitions FROM the ImageView's previous drawable. Even after setImageDrawable(null), the crossfade animation could pick up a stale reference from the RecyclerView item that was visible before navigating to the detail view. Fix: add crossfade(false) on initial loads (where there is nothing valid to crossfade from). The thumb→full-res transition keeps its default crossfade via placeholderMemoryCacheKey, which is correct because the previous drawable IS the thumbnail. Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/f9a00b14-72f7-499a-b957-917612678300 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../java/fr/free/nrw/commons/media/MediaDetailFragment.kt | 8 ++++++++ 1 file changed, 8 insertions(+) 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 cb1742e7054..6c6ba8d7440 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 @@ -7,6 +7,7 @@ import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration import coil3.load +import coil3.request.crossfade import coil3.request.error import android.net.Uri import android.os.Bundle @@ -731,6 +732,11 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C if (!thumbUrl.isNullOrEmpty() && !imageUrl.isNullOrEmpty() && thumbUrl != imageUrl) { // Load thumbnail first for a fast preview. binding.mediaDetailImageView.load(thumbUrl) { + // Disable crossfade for the initial load: the global crossfade(true) + // on the ImageLoader would otherwise transition from whatever stale + // drawable was previously on this ImageView (e.g. the first + // RecyclerView item), causing a brief wrong-image flash. + crossfade(false) error(R.drawable.image_placeholder) listener( onSuccess = { _, result -> @@ -754,6 +760,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C onError = { _, _ -> // Thumbnail failed — fall back to loading the full image directly. binding.mediaDetailImageView.load(imageUrl) { + crossfade(false) error(R.drawable.image_placeholder) listener( onSuccess = { _, _ -> @@ -771,6 +778,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } } else { binding.mediaDetailImageView.load(imageUrl ?: thumbUrl) { + crossfade(false) error(R.drawable.image_placeholder) listener( onSuccess = { _, _ -> From 2a5b48a3f7659b0af1bed179a9d1d621a785a53f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:10:45 +0000 Subject: [PATCH 29/41] Simplify setupImageView: remove nested Coil loads and placeholderMemoryCacheKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nested thumbnail→full-res loading with placeholderMemoryCacheKey caused the first recycler view image to flash before the real image loaded. This was fundamentally different from main's Fresco behavior, which shows a black screen with a spinner. Simplified to a single Coil load call: show spinner, load the full-resolution URL (falling back to thumb URL), hide spinner on completion. This matches main's behavior exactly. Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/f11c3136-e5fc-4f5b-82fb-8871a15df9e3 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../nrw/commons/media/MediaDetailFragment.kt | 84 +++++-------------- 1 file changed, 19 insertions(+), 65 deletions(-) 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 6c6ba8d7440..b7ad8232383 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 @@ -7,7 +7,6 @@ import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration import coil3.load -import coil3.request.crossfade import coil3.request.error import android.net.Uri import android.os.Bundle @@ -717,6 +716,12 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C * A ProgressBar spinner is shown while loading. No static placeholder image * is used because it would flash as a "wrong image" before the real one loads. */ + /** + * Loads the media image into the detail ImageView. + * + * Mirrors the original Fresco behaviour: show a spinner while loading, + * prefer the full-resolution URL, fall back to the thumbnail URL. + */ private fun setupImageView() { val imageBackgroundColor: Int = imageBackgroundColor if (imageBackgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { @@ -726,71 +731,20 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C binding.mediaDetailImageView.setImageDrawable(null) binding.mediaDetailImageProgress.visibility = View.VISIBLE - val imageUrl = media!!.imageUrl - val thumbUrl = media!!.thumbUrl - - if (!thumbUrl.isNullOrEmpty() && !imageUrl.isNullOrEmpty() && thumbUrl != imageUrl) { - // Load thumbnail first for a fast preview. - binding.mediaDetailImageView.load(thumbUrl) { - // Disable crossfade for the initial load: the global crossfade(true) - // on the ImageLoader would otherwise transition from whatever stale - // drawable was previously on this ImageView (e.g. the first - // RecyclerView item), causing a brief wrong-image flash. - crossfade(false) - error(R.drawable.image_placeholder) - listener( - onSuccess = { _, result -> - binding.mediaDetailImageProgress.visibility = View.GONE - updateImageDimensions() - updateAspectRatio(binding.mediaDetailScrollView.width) + val url = media!!.imageUrl ?: media!!.thumbUrl - // Load full-resolution image, using the thumbnail's memory - // cache key as a synchronous placeholder (Coil recipe). - binding.mediaDetailImageView.load(imageUrl) { - placeholderMemoryCacheKey(result.memoryCacheKey) - error(R.drawable.image_placeholder) - listener( - onSuccess = { _, _ -> - updateImageDimensions() - updateAspectRatio(binding.mediaDetailScrollView.width) - } - ) - } - }, - onError = { _, _ -> - // Thumbnail failed — fall back to loading the full image directly. - binding.mediaDetailImageView.load(imageUrl) { - crossfade(false) - error(R.drawable.image_placeholder) - listener( - onSuccess = { _, _ -> - binding.mediaDetailImageProgress.visibility = View.GONE - updateImageDimensions() - updateAspectRatio(binding.mediaDetailScrollView.width) - }, - onError = { _, _ -> - binding.mediaDetailImageProgress.visibility = View.GONE - } - ) - } - } - ) - } - } else { - binding.mediaDetailImageView.load(imageUrl ?: thumbUrl) { - crossfade(false) - error(R.drawable.image_placeholder) - listener( - onSuccess = { _, _ -> - binding.mediaDetailImageProgress.visibility = View.GONE - updateImageDimensions() - updateAspectRatio(binding.mediaDetailScrollView.width) - }, - onError = { _, _ -> - binding.mediaDetailImageProgress.visibility = View.GONE - } - ) - } + binding.mediaDetailImageView.load(url) { + error(R.drawable.image_placeholder) + listener( + onSuccess = { _, _ -> + binding.mediaDetailImageProgress.visibility = View.GONE + updateImageDimensions() + updateAspectRatio(binding.mediaDetailScrollView.width) + }, + onError = { _, _ -> + binding.mediaDetailImageProgress.visibility = View.GONE + } + ) } } From 047baa2dc2e41cb4165c62455f4a7c96e0ae15c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:11:25 +0000 Subject: [PATCH 30/41] Remove duplicate stale Javadoc comment on setupImageView Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/f11c3136-e5fc-4f5b-82fb-8871a15df9e3 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../fr/free/nrw/commons/media/MediaDetailFragment.kt | 10 ---------- 1 file changed, 10 deletions(-) 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 b7ad8232383..822487a2c2c 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 @@ -706,16 +706,6 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } /** - * Uses two image sources via Coil, mirroring the original Fresco behaviour: - * - low resolution thumbnail is shown initially - * - when the high resolution image is available, it replaces the low resolution image - * - * Follows the documented Coil recipe "Using a Memory Cache Key as a Placeholder": - * https://coil-kt.github.io/coil/recipes/#using-a-memory-cache-key-as-a-placeholder - * - * A ProgressBar spinner is shown while loading. No static placeholder image - * is used because it would flash as a "wrong image" before the real one loads. - */ /** * Loads the media image into the detail ImageView. * From f202e4b5169583d8aa1db11f11ea2c8ef6ef2b0b Mon Sep 17 00:00:00 2001 From: Ritika Pahwa Date: Sat, 11 Apr 2026 20:56:25 +0530 Subject: [PATCH 31/41] Code cleanup --- .../fr/free/nrw/commons/media/MediaDetailFragment.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 822487a2c2c..bf1f9650d3f 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 @@ -1,5 +1,6 @@ package fr.free.nrw.commons.media +import android.R.attr.thumbnail import android.annotation.SuppressLint import android.app.AlertDialog import android.content.Context @@ -65,6 +66,8 @@ import androidx.compose.ui.unit.sp import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.viewModels +import coil3.request.placeholder +import coil3.result import fr.free.nrw.commons.BuildConfig import fr.free.nrw.commons.CameraPosition import fr.free.nrw.commons.CommonsApplication @@ -705,7 +708,6 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } } - /** /** * Loads the media image into the detail ImageView. * @@ -721,9 +723,8 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C binding.mediaDetailImageView.setImageDrawable(null) binding.mediaDetailImageProgress.visibility = View.VISIBLE - val url = media!!.imageUrl ?: media!!.thumbUrl - - binding.mediaDetailImageView.load(url) { + // TODO: load low-resolution image until the full-resolution image is loaded. + binding.mediaDetailImageView.load(media!!.imageUrl) { error(R.drawable.image_placeholder) listener( onSuccess = { _, _ -> From a9cb8e48c118c26071923709002f61fa19736d34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:08:37 +0000 Subject: [PATCH 32/41] upload: simplify Coil setup and centralize upload thumbnails Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/3a97340e-7a3a-4c50-8958-e46dc2cbf567 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../fr/free/nrw/commons/CommonsApplication.kt | 14 ++------ .../commons/upload/FailedUploadsAdapter.kt | 20 +---------- .../commons/upload/PendingUploadsAdapter.kt | 20 +---------- .../commons/upload/UploadItemImageLoader.kt | 36 +++++++++++++++++++ 4 files changed, 41 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt index b0b8bc025d1..af023bb6073 100644 --- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt +++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt @@ -124,19 +124,12 @@ class CommonsApplication : MultiDexApplication() { defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet) } - // Create a dedicated OkHttpClient for image loading: - // - Shares connection pool with the DI client via newBuilder() - // - Inherits User-Agent header from CommonHeaderRequestInterceptor - // - Removes OkHttp's HTTP cache (Coil manages its own disk cache) - val imageHttpClient = okHttpClient.newBuilder() - .cache(null) - .build() - - // Initialize Coil image loader + // Initialize Coil with the shared app OkHttpClient so image requests keep the + // existing headers, logging, and timeout configuration. val imageLoader = ImageLoader.Builder(this) .crossfade(true) .components { - add(OkHttpNetworkFetcherFactory(callFactory = { imageHttpClient })) + add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) // Skip image loading when limited connection mode is enabled // (replaces the original CustomOkHttpNetworkFetcher behavior) add(LimitedConnectionModeInterceptor(defaultPrefs)) @@ -470,4 +463,3 @@ class LimitedConnectionModeInterceptor( return chain.proceed() } } - diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt index a75abfb639c..801a343e1b4 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt @@ -3,25 +3,18 @@ package fr.free.nrw.commons.upload import android.content.ClipData import android.content.ClipboardManager import android.content.Context.CLIPBOARD_SERVICE -import android.net.Uri -import android.text.TextUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.webkit.URLUtil import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import coil3.load -import coil3.request.placeholder -import coil3.request.error import com.google.android.material.snackbar.Snackbar import fr.free.nrw.commons.R import fr.free.nrw.commons.contributions.Contribution -import java.io.File /** * Adapter for displaying failed uploads in a paginated list in FailedUploadsFragment. This adapter @@ -78,18 +71,7 @@ class FailedUploadsAdapter( if (item != null) { holder.titleTextView.setText(item.media.displayTitle) } - val imageSource: String = item?.localUri.toString() - - if (!TextUtils.isEmpty(imageSource)) { - val data: Any = when { - URLUtil.isFileUrl(imageSource) -> Uri.parse(imageSource) - else -> File(imageSource) - } - holder.itemImage.load(data) { - placeholder(R.drawable.ic_image_black_24dp) - error(R.drawable.ic_image_black_24dp) - } - } + holder.itemImage.loadUploadItemImage(item?.localUri?.toString()) if (item != null) { if (item.state == Contribution.STATE_FAILED) { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt index 29a88c9f5ab..3c295e4a826 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt @@ -3,25 +3,18 @@ package fr.free.nrw.commons.upload import android.content.ClipData import android.content.ClipboardManager import android.content.Context.CLIPBOARD_SERVICE -import android.net.Uri -import android.text.TextUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.webkit.URLUtil import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import coil3.load -import coil3.request.placeholder -import coil3.request.error import com.google.android.material.snackbar.Snackbar import fr.free.nrw.commons.R import fr.free.nrw.commons.contributions.Contribution -import java.io.File /** * Adapter for displaying pending uploads in a paginated list in PendingUploadsFragment. This adapter @@ -122,18 +115,7 @@ class PendingUploadsAdapter( true } - val imageSource: String = contribution.localUri.toString() - - if (!TextUtils.isEmpty(imageSource)) { - val data: Any = when { - URLUtil.isFileUrl(imageSource) -> Uri.parse(imageSource) - else -> File(imageSource) - } - itemImage.load(data) { - placeholder(R.drawable.ic_image_black_24dp) - error(R.drawable.ic_image_black_24dp) - } - } + itemImage.loadUploadItemImage(contribution.localUri?.toString()) bindState(contribution.state) bindProgress(contribution.transferred, contribution.dataLength, contribution.state) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt new file mode 100644 index 00000000000..134867e6494 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt @@ -0,0 +1,36 @@ +package fr.free.nrw.commons.upload + +import android.net.Uri +import android.webkit.URLUtil +import android.widget.ImageView +import coil3.load +import coil3.request.crossfade +import coil3.request.error +import coil3.request.placeholder +import fr.free.nrw.commons.R +import timber.log.Timber +import java.io.File + +internal fun ImageView.loadUploadItemImage(imageSource: String?) { + if (imageSource.isNullOrBlank()) { + setImageResource(R.drawable.ic_image_black_24dp) + return + } + + val data: Any = when { + URLUtil.isHttpUrl(imageSource) || URLUtil.isHttpsUrl(imageSource) -> imageSource + URLUtil.isFileUrl(imageSource) -> Uri.parse(imageSource) + else -> File(imageSource) + } + + try { + load(data) { + crossfade(true) + placeholder(R.drawable.ic_image_black_24dp) + error(R.drawable.ic_image_black_24dp) + } + } catch (throwable: Throwable) { + Timber.e(throwable, "Unable to load upload item image: %s", imageSource) + setImageResource(R.drawable.ic_image_black_24dp) + } +} From 45e0a2e68353855e02395b17503d810312f3b4a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:16:17 +0000 Subject: [PATCH 33/41] upload: remove redundant helper crossfade handling Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/3a97340e-7a3a-4c50-8958-e46dc2cbf567 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../commons/upload/UploadItemImageLoader.kt | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt index 134867e6494..d3ab8fbcd76 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt @@ -4,7 +4,6 @@ import android.net.Uri import android.webkit.URLUtil import android.widget.ImageView import coil3.load -import coil3.request.crossfade import coil3.request.error import coil3.request.placeholder import fr.free.nrw.commons.R @@ -23,14 +22,13 @@ internal fun ImageView.loadUploadItemImage(imageSource: String?) { else -> File(imageSource) } - try { - load(data) { - crossfade(true) - placeholder(R.drawable.ic_image_black_24dp) - error(R.drawable.ic_image_black_24dp) - } - } catch (throwable: Throwable) { - Timber.e(throwable, "Unable to load upload item image: %s", imageSource) - setImageResource(R.drawable.ic_image_black_24dp) + load(data) { + placeholder(R.drawable.ic_image_black_24dp) + error(R.drawable.ic_image_black_24dp) + listener( + onError = { _, result -> + Timber.e(result.throwable, "Unable to load upload item image: %s", imageSource) + } + ) } } From 36632f197e5145f5a5c33832c83d9d377cba774c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:21:26 +0000 Subject: [PATCH 34/41] upload: parameterize placeholder handling in helper Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/3a97340e-7a3a-4c50-8958-e46dc2cbf567 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../commons/upload/FailedUploadsAdapter.kt | 5 +- .../commons/upload/PendingUploadsAdapter.kt | 5 +- .../commons/upload/UploadItemImageLoader.kt | 50 +++++++++++-------- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt index 801a343e1b4..7061cd85c3d 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsAdapter.kt @@ -71,7 +71,10 @@ class FailedUploadsAdapter( if (item != null) { holder.titleTextView.setText(item.media.displayTitle) } - holder.itemImage.loadUploadItemImage(item?.localUri?.toString()) + holder.itemImage.loadUploadItemImage( + item?.localUri?.toString(), + R.drawable.ic_image_black_24dp + ) if (item != null) { if (item.state == Contribution.STATE_FAILED) { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt index 3c295e4a826..d9ab73d4117 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsAdapter.kt @@ -115,7 +115,10 @@ class PendingUploadsAdapter( true } - itemImage.loadUploadItemImage(contribution.localUri?.toString()) + itemImage.loadUploadItemImage( + contribution.localUri?.toString(), + R.drawable.ic_image_black_24dp + ) bindState(contribution.state) bindProgress(contribution.transferred, contribution.dataLength, contribution.state) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt index d3ab8fbcd76..c724f95031d 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt @@ -6,29 +6,39 @@ import android.widget.ImageView import coil3.load import coil3.request.error import coil3.request.placeholder -import fr.free.nrw.commons.R import timber.log.Timber import java.io.File -internal fun ImageView.loadUploadItemImage(imageSource: String?) { - if (imageSource.isNullOrBlank()) { - setImageResource(R.drawable.ic_image_black_24dp) - return - } - - val data: Any = when { - URLUtil.isHttpUrl(imageSource) || URLUtil.isHttpsUrl(imageSource) -> imageSource - URLUtil.isFileUrl(imageSource) -> Uri.parse(imageSource) - else -> File(imageSource) - } - - load(data) { - placeholder(R.drawable.ic_image_black_24dp) - error(R.drawable.ic_image_black_24dp) - listener( - onError = { _, result -> - Timber.e(result.throwable, "Unable to load upload item image: %s", imageSource) +internal fun ImageView.loadUploadItemImage( + imageSource: String?, + placeholderResId: Int, +) { + val data: Any? = + imageSource + ?.takeUnless { it.isBlank() } + ?.let { + when { + URLUtil.isHttpUrl(it) || URLUtil.isHttpsUrl(it) -> it + URLUtil.isFileUrl(it) -> Uri.parse(it) + else -> File(it) + } } - ) + + try { + load(data) { + placeholder(placeholderResId) + error(placeholderResId) + listener( + onError = { _, result -> + Timber.e(result.throwable, "Unable to load upload item image: %s", imageSource) + } + ) + } + } catch (outOfMemoryError: OutOfMemoryError) { + Timber.e(outOfMemoryError, "Out of memory while loading upload item image: %s", imageSource) + setImageResource(placeholderResId) + } catch (exception: Exception) { + Timber.e(exception, "Unable to start loading upload item image: %s", imageSource) + setImageResource(placeholderResId) } } From 2bf581d11143b702ad03756e5aa855c092bcd608 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:28:43 +0000 Subject: [PATCH 35/41] upload: polish helper error handling messages Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/3a97340e-7a3a-4c50-8958-e46dc2cbf567 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../fr/free/nrw/commons/upload/UploadItemImageLoader.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt index c724f95031d..452f4a04093 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt @@ -34,11 +34,11 @@ internal fun ImageView.loadUploadItemImage( } ) } - } catch (outOfMemoryError: OutOfMemoryError) { - Timber.e(outOfMemoryError, "Out of memory while loading upload item image: %s", imageSource) + } catch (error: OutOfMemoryError) { + Timber.e(error, "Out of memory while preparing upload item image load: %s", imageSource) setImageResource(placeholderResId) - } catch (exception: Exception) { - Timber.e(exception, "Unable to start loading upload item image: %s", imageSource) + } catch (error: Exception) { + Timber.e(error, "Unable to start upload item image load: %s", imageSource) setImageResource(placeholderResId) } } From a47121167a4e605f0b6e0d225d1ae1e735b019eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:34:03 +0000 Subject: [PATCH 36/41] commons: clarify shared Coil client cache behavior Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/3a97340e-7a3a-4c50-8958-e46dc2cbf567 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .kotlin/sessions/kotlin-compiler-12856372254371876145.salive | 0 app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 .kotlin/sessions/kotlin-compiler-12856372254371876145.salive diff --git a/.kotlin/sessions/kotlin-compiler-12856372254371876145.salive b/.kotlin/sessions/kotlin-compiler-12856372254371876145.salive new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt index af023bb6073..e34c9f34b94 100644 --- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt +++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt @@ -125,7 +125,7 @@ class CommonsApplication : MultiDexApplication() { } // Initialize Coil with the shared app OkHttpClient so image requests keep the - // existing headers, logging, and timeout configuration. + // existing headers, logging, timeout, and HTTP cache configuration. val imageLoader = ImageLoader.Builder(this) .crossfade(true) .components { From 362fe0d0967c308f7ad22daa83ce5cd7319b92fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:34:24 +0000 Subject: [PATCH 37/41] build: remove accidental Kotlin session artifact Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/3a97340e-7a3a-4c50-8958-e46dc2cbf567 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .kotlin/sessions/kotlin-compiler-12856372254371876145.salive | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .kotlin/sessions/kotlin-compiler-12856372254371876145.salive diff --git a/.kotlin/sessions/kotlin-compiler-12856372254371876145.salive b/.kotlin/sessions/kotlin-compiler-12856372254371876145.salive deleted file mode 100644 index e69de29bb2d..00000000000 From 7b6f9dc62317058e5ed9a620df804c59f7d193ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:01:56 +0000 Subject: [PATCH 38/41] image: downsample upload fallback and remove runBlocking Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/7756e9c5-e457-4a3d-9c6a-699c49aec3b3 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../contributions/SetWallpaperWorker.kt | 43 +++--- .../commons/upload/UploadItemImageLoader.kt | 144 +++++++++++++++++- 2 files changed, 159 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt index 23dea5ff7d9..74a0fa4aa43 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt @@ -7,7 +7,7 @@ import android.content.Context import android.graphics.Bitmap import android.os.Build import androidx.core.app.NotificationCompat -import androidx.work.Worker +import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import coil3.SingletonImageLoader import coil3.request.ImageRequest @@ -15,39 +15,36 @@ import coil3.request.SuccessResult import coil3.request.allowHardware import coil3.toBitmap import fr.free.nrw.commons.R -import kotlinx.coroutines.runBlocking import timber.log.Timber class SetWallpaperWorker(context: Context, params: WorkerParameters) : - Worker(context, params) { - override fun doWork(): Result { + CoroutineWorker(context, params) { + override suspend fun doWork(): Result { val context = applicationContext createNotificationChannel(context) showProgressNotification(context) val imageUrl = inputData.getString("imageUrl") ?: return Result.failure() - return runBlocking { - try { - val imageLoader = SingletonImageLoader.get(context) - val request = ImageRequest.Builder(context) - .data(imageUrl) - .allowHardware(false) - .build() - val result = imageLoader.execute(request) - if (result is SuccessResult) { - val bitmap = result.image.toBitmap() - setWallpaper(context, Bitmap.createBitmap(bitmap)) - } else { - Timber.d("Error getting bitmap from image url %s", imageUrl) - showNotification(context, "Setting Wallpaper Failed", "Failed to download image.") - } - Result.success() - } catch (e: Exception) { - Timber.e(e, "Error getting bitmap from image url %s", imageUrl) + return try { + val imageLoader = SingletonImageLoader.get(context) + val request = ImageRequest.Builder(context) + .data(imageUrl) + .allowHardware(false) + .build() + val result = imageLoader.execute(request) + if (result is SuccessResult) { + val bitmap = result.image.toBitmap() + setWallpaper(context, Bitmap.createBitmap(bitmap)) + } else { + Timber.d("Error getting bitmap from image url %s", imageUrl) showNotification(context, "Setting Wallpaper Failed", "Failed to download image.") - Result.failure() } + Result.success() + } catch (e: Exception) { + Timber.e(e, "Error getting bitmap from image url %s", imageUrl) + showNotification(context, "Setting Wallpaper Failed", "Failed to download image.") + Result.failure() } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt index 452f4a04093..74a07ef0050 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt @@ -1,5 +1,7 @@ package fr.free.nrw.commons.upload +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import android.webkit.URLUtil import android.widget.ImageView @@ -8,6 +10,7 @@ import coil3.request.error import coil3.request.placeholder import timber.log.Timber import java.io.File +import kotlin.math.max internal fun ImageView.loadUploadItemImage( imageSource: String?, @@ -17,9 +20,10 @@ internal fun ImageView.loadUploadItemImage( imageSource ?.takeUnless { it.isBlank() } ?.let { + val uri = Uri.parse(it) when { URLUtil.isHttpUrl(it) || URLUtil.isHttpsUrl(it) -> it - URLUtil.isFileUrl(it) -> Uri.parse(it) + !uri.scheme.isNullOrBlank() -> uri else -> File(it) } } @@ -30,15 +34,145 @@ internal fun ImageView.loadUploadItemImage( error(placeholderResId) listener( onError = { _, result -> - Timber.e(result.throwable, "Unable to load upload item image: %s", imageSource) + if (!tryLoadDownsampledImage(imageSource, placeholderResId, result.throwable)) { + Timber.e(result.throwable, "Unable to load upload item image: %s", imageSource) + } } ) } - } catch (error: OutOfMemoryError) { - Timber.e(error, "Out of memory while preparing upload item image load: %s", imageSource) - setImageResource(placeholderResId) } catch (error: Exception) { Timber.e(error, "Unable to start upload item image load: %s", imageSource) setImageResource(placeholderResId) } } + +private fun ImageView.tryLoadDownsampledImage( + imageSource: String?, + placeholderResId: Int, + error: Throwable?, +): Boolean { + if (imageSource.isNullOrBlank() || !isOutOfMemory(error)) { + return false + } + + return try { + val downsampledBitmap = + decodeDownsampledBitmap( + imageSource, + requestedWidth = measuredWidth.takeIf { it > 0 } ?: width, + requestedHeight = measuredHeight.takeIf { it > 0 } ?: height + ) ?: return false + + load(downsampledBitmap) { + placeholder(placeholderResId) + error(placeholderResId) + } + true + } catch (oom: OutOfMemoryError) { + Timber.e(oom, "Out of memory while downsampling upload item image: %s", imageSource) + setImageResource(placeholderResId) + true + } catch (exception: Exception) { + Timber.e(exception, "Unable to downsample upload item image: %s", imageSource) + setImageResource(placeholderResId) + true + } +} + +private fun ImageView.decodeDownsampledBitmap( + imageSource: String, + requestedWidth: Int, + requestedHeight: Int, +): Bitmap? { + val targetWidth = requestedWidth.takeIf { it > 0 } ?: DEFAULT_DOWNSAMPLED_IMAGE_SIZE_PX + val targetHeight = requestedHeight.takeIf { it > 0 } ?: DEFAULT_DOWNSAMPLED_IMAGE_SIZE_PX + val uri = Uri.parse(imageSource) + + return when { + !uri.scheme.isNullOrBlank() && !URLUtil.isHttpUrl(imageSource) && !URLUtil.isHttpsUrl(imageSource) -> + decodeDownsampledBitmapFromUri(uri, targetWidth, targetHeight) + + else -> decodeDownsampledBitmapFromFile(File(imageSource), targetWidth, targetHeight) + } +} + +private fun ImageView.decodeDownsampledBitmapFromUri( + uri: Uri, + requestedWidth: Int, + requestedHeight: Int, +): Bitmap? { + val bounds = + BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, bounds) + } ?: return null + + val options = + BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(bounds, requestedWidth, requestedHeight) + } + return context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, options) + } +} + +private fun decodeDownsampledBitmapFromFile( + file: File, + requestedWidth: Int, + requestedHeight: Int, +): Bitmap? { + if (!file.exists()) { + return null + } + + val bounds = + BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.path, bounds) + + val options = + BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(bounds, requestedWidth, requestedHeight) + } + return BitmapFactory.decodeFile(file.path, options) +} + +private fun calculateInSampleSize( + options: BitmapFactory.Options, + requestedWidth: Int, + requestedHeight: Int, +): Int { + val safeRequestedWidth = max(requestedWidth, 1) + val safeRequestedHeight = max(requestedHeight, 1) + val height = options.outHeight + val width = options.outWidth + var inSampleSize = 1 + + if (height > safeRequestedHeight || width > safeRequestedWidth) { + var halfHeight = height / 2 + var halfWidth = width / 2 + + while (halfHeight / inSampleSize >= safeRequestedHeight && + halfWidth / inSampleSize >= safeRequestedWidth + ) { + inSampleSize *= 2 + } + } + return inSampleSize +} + +private fun isOutOfMemory(error: Throwable?): Boolean { + var current = error + while (current != null) { + if (current is OutOfMemoryError) { + return true + } + current = current.cause + } + return false +} + +private const val DEFAULT_DOWNSAMPLED_IMAGE_SIZE_PX = 1024 From ac23ab1fea82d0e210214f07a179a38dbd650e65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:11:42 +0000 Subject: [PATCH 39/41] upload: refine downsample size calculation Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/7756e9c5-e457-4a3d-9c6a-699c49aec3b3 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../fr/free/nrw/commons/upload/UploadItemImageLoader.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt index 74a07ef0050..20489c6b1fa 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt @@ -152,11 +152,11 @@ private fun calculateInSampleSize( var inSampleSize = 1 if (height > safeRequestedHeight || width > safeRequestedWidth) { - var halfHeight = height / 2 - var halfWidth = width / 2 + val currentHeight = height / 2 + val currentWidth = width / 2 - while (halfHeight / inSampleSize >= safeRequestedHeight && - halfWidth / inSampleSize >= safeRequestedWidth + while (currentHeight / inSampleSize > safeRequestedHeight && + currentWidth / inSampleSize > safeRequestedWidth ) { inSampleSize *= 2 } From 4e1fc080683a688e83ea576b7244f57a5d515e4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:13:14 +0000 Subject: [PATCH 40/41] upload: fix downsample loop condition Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/7756e9c5-e457-4a3d-9c6a-699c49aec3b3 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../fr/free/nrw/commons/upload/UploadItemImageLoader.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt index 20489c6b1fa..b4b6466f362 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt @@ -152,11 +152,8 @@ private fun calculateInSampleSize( var inSampleSize = 1 if (height > safeRequestedHeight || width > safeRequestedWidth) { - val currentHeight = height / 2 - val currentWidth = width / 2 - - while (currentHeight / inSampleSize > safeRequestedHeight && - currentWidth / inSampleSize > safeRequestedWidth + while (height / inSampleSize > safeRequestedHeight || + width / inSampleSize > safeRequestedWidth ) { inSampleSize *= 2 } From fc0ef279eb2bbbfe72f69a8aa56c585e7895ade9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:15:01 +0000 Subject: [PATCH 41/41] upload: polish fallback load handling Agent-Logs-Url: https://github.com/commons-app/apps-android-commons/sessions/7756e9c5-e457-4a3d-9c6a-699c49aec3b3 Co-authored-by: RitikaPahwa4444 <83745993+RitikaPahwa4444@users.noreply.github.com> --- .../nrw/commons/contributions/SetWallpaperWorker.kt | 2 +- .../nrw/commons/upload/UploadItemImageLoader.kt | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt index 74a0fa4aa43..30503ed28e3 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt @@ -35,7 +35,7 @@ class SetWallpaperWorker(context: Context, params: WorkerParameters) : val result = imageLoader.execute(request) if (result is SuccessResult) { val bitmap = result.image.toBitmap() - setWallpaper(context, Bitmap.createBitmap(bitmap)) + setWallpaper(context, bitmap) } else { Timber.d("Error getting bitmap from image url %s", imageUrl) showNotification(context, "Setting Wallpaper Failed", "Failed to download image.") diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt index b4b6466f362..293accdbc6d 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadItemImageLoader.kt @@ -66,6 +66,12 @@ private fun ImageView.tryLoadDownsampledImage( load(downsampledBitmap) { placeholder(placeholderResId) error(placeholderResId) + listener( + onError = { _, result -> + Timber.e(result.throwable, "Unable to load downsampled upload item image: %s", imageSource) + setImageResource(placeholderResId) + } + ) } true } catch (oom: OutOfMemoryError) { @@ -152,8 +158,11 @@ private fun calculateInSampleSize( var inSampleSize = 1 if (height > safeRequestedHeight || width > safeRequestedWidth) { - while (height / inSampleSize > safeRequestedHeight || - width / inSampleSize > safeRequestedWidth + val halfHeight = height / 2 + val halfWidth = width / 2 + + while (halfHeight / inSampleSize >= safeRequestedHeight && + halfWidth / inSampleSize >= safeRequestedWidth ) { inSampleSize *= 2 }