From 5126e7d3ad5997bee61b95ff79288010bbf41eb6 Mon Sep 17 00:00:00 2001 From: Christopher Drury Date: Wed, 14 Jun 2023 11:35:08 -0400 Subject: [PATCH] Add opt-in CoroutineDispatcher api for controlling Picasso's internal thread --- gradle/libs.versions.toml | 3 + .../picasso/paparazzi/PicassoPaparazziTest.kt | 7 +- .../example/picasso/SampleComposeActivity.kt | 7 +- picasso/api/picasso.api | 2 + picasso/build.gradle | 1 + .../com/squareup/picasso3/BaseDispatcher.kt | 454 +++++++++++ .../com/squareup/picasso3/BitmapHunter.kt | 26 +- .../java/com/squareup/picasso3/Dispatcher.kt | 468 +----------- .../squareup/picasso3/HandlerDispatcher.kt | 16 +- .../picasso3/InternalCoroutineDispatcher.kt | 136 ++++ .../java/com/squareup/picasso3/Picasso.kt | 32 +- .../squareup/picasso3/BaseDispatcherTest.kt | 83 ++ ...atcherTest.kt => HandlerDispatcherTest.kt} | 53 +- .../InternalCoroutineDispatcherTest.kt | 710 ++++++++++++++++++ .../java/com/squareup/picasso3/PicassoTest.kt | 16 +- 15 files changed, 1487 insertions(+), 527 deletions(-) create mode 100644 picasso/src/main/java/com/squareup/picasso3/BaseDispatcher.kt create mode 100644 picasso/src/main/java/com/squareup/picasso3/InternalCoroutineDispatcher.kt create mode 100644 picasso/src/test/java/com/squareup/picasso3/BaseDispatcherTest.kt rename picasso/src/test/java/com/squareup/picasso3/{DispatcherTest.kt => HandlerDispatcherTest.kt} (91%) create mode 100644 picasso/src/test/java/com/squareup/picasso3/InternalCoroutineDispatcherTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b1e0f7d236..7ba98ecaf6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] agp = '8.0.2' +coroutines = '1.7.1' composeCompiler = '1.4.7' composeUi = '1.4.3' javaTarget = '1.8' @@ -22,6 +23,8 @@ androidx-lifecycle = { module = 'androidx.lifecycle:lifecycle-common', version = androidx-startup = { module = 'androidx.startup:startup-runtime', version = '1.1.1' } androidx-testRunner = { module = 'androidx.test:runner', version = '1.5.2' } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = '1.7.1' } + composeUi = { module = 'androidx.compose.ui:ui', version.ref = 'composeUi' } composeRuntime = { module = 'androidx.compose.runtime:runtime', version.ref = 'composeUi' } composeUi-foundation = { module = 'androidx.compose.foundation:foundation', version.ref = 'composeUi' } diff --git a/picasso-paparazzi-sample/src/test/java/com/example/picasso/paparazzi/PicassoPaparazziTest.kt b/picasso-paparazzi-sample/src/test/java/com/example/picasso/paparazzi/PicassoPaparazziTest.kt index 20940bd4fa..0f54de50e7 100644 --- a/picasso-paparazzi-sample/src/test/java/com/example/picasso/paparazzi/PicassoPaparazziTest.kt +++ b/picasso-paparazzi-sample/src/test/java/com/example/picasso/paparazzi/PicassoPaparazziTest.kt @@ -23,9 +23,9 @@ import com.squareup.picasso3.Picasso import com.squareup.picasso3.Picasso.LoadedFrom.MEMORY import com.squareup.picasso3.Request import com.squareup.picasso3.RequestHandler -import com.squareup.picasso3.layoutlib.LayoutlibExecutorService import org.junit.Rule import org.junit.Test +import kotlinx.coroutines.Dispatchers class PicassoPaparazziTest { @get:Rule val paparazzi = Paparazzi() @@ -34,7 +34,10 @@ class PicassoPaparazziTest { fun loadsUrlIntoImageView() { val picasso = Picasso.Builder(paparazzi.context) .callFactory { throw AssertionError() } // Removes network - .executor(LayoutlibExecutorService()) + .dispatchers( + mainDispatcher = Dispatchers.Unconfined, + backgroundDispatcher = Dispatchers.Unconfined + ) .addRequestHandler(FakeRequestHandler()) .build() diff --git a/picasso-sample/src/main/java/com/example/picasso/SampleComposeActivity.kt b/picasso-sample/src/main/java/com/example/picasso/SampleComposeActivity.kt index ce605a8fc8..ce7f8e18a0 100644 --- a/picasso-sample/src/main/java/com/example/picasso/SampleComposeActivity.kt +++ b/picasso-sample/src/main/java/com/example/picasso/SampleComposeActivity.kt @@ -56,7 +56,7 @@ import com.squareup.picasso3.Picasso.LoadedFrom.MEMORY import com.squareup.picasso3.Request import com.squareup.picasso3.RequestHandler import com.squareup.picasso3.compose.rememberPainter -import com.squareup.picasso3.layoutlib.LayoutlibExecutorService +import kotlinx.coroutines.Dispatchers class SampleComposeActivity : PicassoSampleActivity() { @@ -241,7 +241,10 @@ private fun ContentPreview() { picasso = remember { Picasso.Builder(context) .callFactory { throw AssertionError() } // Removes network - .executor(LayoutlibExecutorService()) // Synchronously execute RequestHandler + .dispatchers( + mainDispatcher = Dispatchers.Unconfined, + backgroundDispatcher = Dispatchers.Unconfined + ) .addRequestHandler( object : RequestHandler() { override fun canHandleRequest(data: Request) = data.uri?.toString()?.run(images::containsKey) == true diff --git a/picasso/api/picasso.api b/picasso/api/picasso.api index a38eee1ba6..880ed41ca2 100644 --- a/picasso/api/picasso.api +++ b/picasso/api/picasso.api @@ -107,6 +107,8 @@ public final class com/squareup/picasso3/Picasso$Builder { public final fun callFactory (Lokhttp3/Call$Factory;)Lcom/squareup/picasso3/Picasso$Builder; public final fun client (Lokhttp3/OkHttpClient;)Lcom/squareup/picasso3/Picasso$Builder; public final fun defaultBitmapConfig (Landroid/graphics/Bitmap$Config;)Lcom/squareup/picasso3/Picasso$Builder; + public final fun dispatchers (Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;)Lcom/squareup/picasso3/Picasso$Builder; + public static synthetic fun dispatchers$default (Lcom/squareup/picasso3/Picasso$Builder;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lcom/squareup/picasso3/Picasso$Builder; public final fun executor (Ljava/util/concurrent/ExecutorService;)Lcom/squareup/picasso3/Picasso$Builder; public final fun indicatorsEnabled (Z)Lcom/squareup/picasso3/Picasso$Builder; public final fun listener (Lcom/squareup/picasso3/Picasso$Listener;)Lcom/squareup/picasso3/Picasso$Builder; diff --git a/picasso/build.gradle b/picasso/build.gradle index d5e124a00e..a1aa39f6f4 100644 --- a/picasso/build.gradle +++ b/picasso/build.gradle @@ -44,6 +44,7 @@ dependencies { implementation libs.androidx.core implementation libs.androidx.exifInterface + testImplementation libs.coroutines.test testImplementation libs.junit testImplementation libs.truth testImplementation libs.robolectric diff --git a/picasso/src/main/java/com/squareup/picasso3/BaseDispatcher.kt b/picasso/src/main/java/com/squareup/picasso3/BaseDispatcher.kt new file mode 100644 index 0000000000..7b962d53e6 --- /dev/null +++ b/picasso/src/main/java/com/squareup/picasso3/BaseDispatcher.kt @@ -0,0 +1,454 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso3 + +import android.Manifest.permission.ACCESS_NETWORK_STATE +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.Intent.ACTION_AIRPLANE_MODE_CHANGED +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.ConnectivityManager.CONNECTIVITY_ACTION +import android.net.NetworkInfo +import android.os.Handler +import android.util.Log +import androidx.annotation.CallSuper +import androidx.annotation.MainThread +import androidx.core.content.ContextCompat +import com.squareup.picasso3.BitmapHunter.Companion.forRequest +import com.squareup.picasso3.MemoryPolicy.Companion.shouldWriteToMemoryCache +import com.squareup.picasso3.NetworkPolicy.NO_CACHE +import com.squareup.picasso3.NetworkRequestHandler.ContentLengthException +import com.squareup.picasso3.RequestHandler.Result.Bitmap +import com.squareup.picasso3.Utils.OWNER_DISPATCHER +import com.squareup.picasso3.Utils.VERB_CANCELED +import com.squareup.picasso3.Utils.VERB_DELIVERED +import com.squareup.picasso3.Utils.VERB_ENQUEUED +import com.squareup.picasso3.Utils.VERB_IGNORED +import com.squareup.picasso3.Utils.VERB_PAUSED +import com.squareup.picasso3.Utils.VERB_REPLAYING +import com.squareup.picasso3.Utils.VERB_RETRYING +import com.squareup.picasso3.Utils.getLogIdsForHunter +import com.squareup.picasso3.Utils.hasPermission +import com.squareup.picasso3.Utils.isAirplaneModeOn +import com.squareup.picasso3.Utils.log +import java.util.WeakHashMap + +internal abstract class BaseDispatcher internal constructor( + private val context: Context, + private val mainThreadHandler: Handler, + private val cache: PlatformLruCache +) : Dispatcher { + @get:JvmName("-hunterMap") + internal val hunterMap = mutableMapOf() + + @get:JvmName("-failedActions") + internal val failedActions = WeakHashMap() + + @get:JvmName("-pausedActions") + internal val pausedActions = WeakHashMap() + + @get:JvmName("-pausedTags") + internal val pausedTags = mutableSetOf() + + @get:JvmName("-receiver") + internal val receiver: NetworkBroadcastReceiver + + @get:JvmName("-airplaneMode") + @set:JvmName("-airplaneMode") + internal var airplaneMode = isAirplaneModeOn(context) + + private val scansNetworkChanges: Boolean + + init { + scansNetworkChanges = hasPermission(context, ACCESS_NETWORK_STATE) + receiver = NetworkBroadcastReceiver(this) + receiver.register() + } + + @CallSuper override fun shutdown() { + // Unregister network broadcast receiver on the main thread. + mainThreadHandler.post { receiver.unregister() } + } + + fun performSubmit(action: Action, dismissFailed: Boolean = true) { + if (action.tag in pausedTags) { + pausedActions[action.getTarget()] = action + if (action.picasso.isLoggingEnabled) { + log( + owner = OWNER_DISPATCHER, + verb = VERB_PAUSED, + logId = action.request.logId(), + extras = "because tag '${action.tag}' is paused" + ) + } + return + } + + var hunter = hunterMap[action.request.key] + if (hunter != null) { + hunter.attach(action) + return + } + + if (isShutdown()) { + if (action.picasso.isLoggingEnabled) { + log( + owner = OWNER_DISPATCHER, + verb = VERB_IGNORED, + logId = action.request.logId(), + extras = "because shut down" + ) + } + return + } + + hunter = forRequest(action.picasso, this, cache, action) + dispatchSubmit(hunter) + hunterMap[action.request.key] = hunter + if (dismissFailed) { + failedActions.remove(action.getTarget()) + } + + if (action.picasso.isLoggingEnabled) { + log(owner = OWNER_DISPATCHER, verb = VERB_ENQUEUED, logId = action.request.logId()) + } + } + + fun performCancel(action: Action) { + val key = action.request.key + val hunter = hunterMap[key] + if (hunter != null) { + hunter.detach(action) + if (hunter.cancel()) { + hunterMap.remove(key) + if (action.picasso.isLoggingEnabled) { + log(OWNER_DISPATCHER, VERB_CANCELED, action.request.logId()) + } + } + } + + if (action.tag in pausedTags) { + pausedActions.remove(action.getTarget()) + if (action.picasso.isLoggingEnabled) { + log( + owner = OWNER_DISPATCHER, + verb = VERB_CANCELED, + logId = action.request.logId(), + extras = "because paused request got canceled" + ) + } + } + + val remove = failedActions.remove(action.getTarget()) + if (remove != null && remove.picasso.isLoggingEnabled) { + log(OWNER_DISPATCHER, VERB_CANCELED, remove.request.logId(), "from replaying") + } + } + + fun performPauseTag(tag: Any) { + // Trying to pause a tag that is already paused. + if (!pausedTags.add(tag)) { + return + } + + // Go through all active hunters and detach/pause the requests + // that have the paused tag. + val iterator = hunterMap.values.iterator() + while (iterator.hasNext()) { + val hunter = iterator.next() + val loggingEnabled = hunter.picasso.isLoggingEnabled + + val single = hunter.action + val joined = hunter.actions + val hasMultiple = !joined.isNullOrEmpty() + + // Hunter has no requests, bail early. + if (single == null && !hasMultiple) { + continue + } + + if (single != null && single.tag == tag) { + hunter.detach(single) + pausedActions[single.getTarget()] = single + if (loggingEnabled) { + log( + owner = OWNER_DISPATCHER, + verb = VERB_PAUSED, + logId = single.request.logId(), + extras = "because tag '$tag' was paused" + ) + } + } + + if (joined != null) { + for (i in joined.indices.reversed()) { + val action = joined[i] + if (action.tag != tag) { + continue + } + hunter.detach(action) + pausedActions[action.getTarget()] = action + if (loggingEnabled) { + log( + owner = OWNER_DISPATCHER, + verb = VERB_PAUSED, + logId = action.request.logId(), + extras = "because tag '$tag' was paused" + ) + } + } + } + + // Check if the hunter can be cancelled in case all its requests + // had the tag being paused here. + if (hunter.cancel()) { + iterator.remove() + if (loggingEnabled) { + log( + owner = OWNER_DISPATCHER, + verb = VERB_CANCELED, + logId = getLogIdsForHunter(hunter), + extras = "all actions paused" + ) + } + } + } + } + + fun performResumeTag(tag: Any) { + // Trying to resume a tag that is not paused. + if (!pausedTags.remove(tag)) { + return + } + + val batch = mutableListOf() + val iterator = pausedActions.values.iterator() + while (iterator.hasNext()) { + val action = iterator.next() + if (action.tag == tag) { + batch += action + iterator.remove() + } + } + + if (batch.isNotEmpty()) { + dispatchBatchResumeMain(batch) + } + } + + @SuppressLint("MissingPermission") + fun performRetry(hunter: BitmapHunter) { + if (hunter.isCancelled) return + + if (isShutdown()) { + performError(hunter) + return + } + + var networkInfo: NetworkInfo? = null + if (scansNetworkChanges) { + val connectivityManager = + ContextCompat.getSystemService(context, ConnectivityManager::class.java) + if (connectivityManager != null) { + networkInfo = connectivityManager.activeNetworkInfo + } + } + + if (hunter.shouldRetry(airplaneMode, networkInfo)) { + if (hunter.picasso.isLoggingEnabled) { + log( + owner = OWNER_DISPATCHER, + verb = VERB_RETRYING, + logId = getLogIdsForHunter(hunter) + ) + } + if (hunter.exception is ContentLengthException) { + hunter.data = hunter.data.newBuilder().networkPolicy(NO_CACHE).build() + } + dispatchSubmit(hunter) + } else { + performError(hunter) + // Mark for replay only if we observe network info changes and support replay. + if (scansNetworkChanges && hunter.supportsReplay()) { + markForReplay(hunter) + } + } + } + + fun performComplete(hunter: BitmapHunter) { + if (shouldWriteToMemoryCache(hunter.data.memoryPolicy)) { + val result = hunter.result + if (result != null) { + if (result is Bitmap) { + val bitmap = result.bitmap + cache[hunter.key] = bitmap + } + } + } + hunterMap.remove(hunter.key) + deliver(hunter) + } + + fun performError(hunter: BitmapHunter) { + hunterMap.remove(hunter.key) + deliver(hunter) + } + + fun performAirplaneModeChange(airplaneMode: Boolean) { + this.airplaneMode = airplaneMode + } + + fun performNetworkStateChange(info: NetworkInfo?) { + // Intentionally check only if isConnected() here before we flush out failed actions. + if (info != null && info.isConnected) { + flushFailedActions() + } + } + + @MainThread + fun performCompleteMain(hunter: BitmapHunter) { + hunter.picasso.complete(hunter) + } + + @MainThread + fun performBatchResumeMain(batch: List) { + for (i in batch.indices) { + val action = batch[i] + action.picasso.resumeAction(action) + } + } + + private fun flushFailedActions() { + if (failedActions.isNotEmpty()) { + val iterator = failedActions.values.iterator() + while (iterator.hasNext()) { + val action = iterator.next() + iterator.remove() + if (action.picasso.isLoggingEnabled) { + log( + owner = OWNER_DISPATCHER, + verb = VERB_REPLAYING, + logId = action.request.logId() + ) + } + performSubmit(action, false) + } + } + } + + private fun markForReplay(hunter: BitmapHunter) { + val action = hunter.action + action?.let { markForReplay(it) } + val joined = hunter.actions + if (joined != null) { + for (i in joined.indices) { + markForReplay(joined[i]) + } + } + } + + private fun markForReplay(action: Action) { + val target = action.getTarget() + action.willReplay = true + failedActions[target] = action + } + + private fun deliver(hunter: BitmapHunter) { + if (hunter.isCancelled) { + return + } + val result = hunter.result + if (result != null) { + if (result is Bitmap) { + val bitmap = result.bitmap + bitmap.prepareToDraw() + } + } + + dispatchCompleteMain(hunter) + logDelivery(hunter) + } + + private fun logDelivery(bitmapHunter: BitmapHunter) { + val picasso = bitmapHunter.picasso + if (picasso.isLoggingEnabled) { + log( + owner = OWNER_DISPATCHER, + verb = VERB_DELIVERED, + logId = getLogIdsForHunter(bitmapHunter) + ) + } + } + + internal class NetworkBroadcastReceiver( + private val dispatcher: BaseDispatcher + ) : BroadcastReceiver() { + fun register() { + val filter = IntentFilter() + filter.addAction(ACTION_AIRPLANE_MODE_CHANGED) + if (dispatcher.scansNetworkChanges) { + filter.addAction(CONNECTIVITY_ACTION) + } + dispatcher.context.registerReceiver(this, filter) + } + + fun unregister() { + dispatcher.context.unregisterReceiver(this) + } + + @SuppressLint("MissingPermission") + override fun onReceive(context: Context, intent: Intent?) { + // On some versions of Android this may be called with a null Intent, + // also without extras (getExtras() == null), in such case we use defaults. + if (intent == null) { + return + } + when (intent.action) { + ACTION_AIRPLANE_MODE_CHANGED -> { + if (!intent.hasExtra(EXTRA_AIRPLANE_STATE)) { + return // No airplane state, ignore it. Should we query Utils.isAirplaneModeOn? + } + dispatcher.dispatchAirplaneModeChange(intent.getBooleanExtra(EXTRA_AIRPLANE_STATE, false)) + } + CONNECTIVITY_ACTION -> { + val connectivityManager = + ContextCompat.getSystemService(context, ConnectivityManager::class.java) + val networkInfo = try { + connectivityManager!!.activeNetworkInfo + } catch (re: RuntimeException) { + Log.w(TAG, "System UI crashed, ignoring attempt to change network state.") + return + } + if (networkInfo == null) { + Log.w( + TAG, + "No default network is currently active, ignoring attempt to change network state." + ) + return + } + dispatcher.dispatchNetworkStateChange(networkInfo) + } + } + } + + internal companion object { + const val EXTRA_AIRPLANE_STATE = "state" + } + } +} diff --git a/picasso/src/main/java/com/squareup/picasso3/BitmapHunter.kt b/picasso/src/main/java/com/squareup/picasso3/BitmapHunter.kt index 776ec1ead1..ef7a1b89f0 100644 --- a/picasso/src/main/java/com/squareup/picasso3/BitmapHunter.kt +++ b/picasso/src/main/java/com/squareup/picasso3/BitmapHunter.kt @@ -34,6 +34,7 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.Future import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.Job internal open class BitmapHunter( val picasso: Picasso, @@ -54,18 +55,21 @@ internal open class BitmapHunter( private set var future: Future<*>? = null + + var job: Job? = null + var result: RequestHandler.Result? = null private set var exception: Exception? = null private set val isCancelled: Boolean - get() = future?.isCancelled ?: false + get() = future?.isCancelled ?: job?.isCancelled ?: false override fun run() { val originalName = Thread.currentThread().name try { - updateThreadName(data) + Thread.currentThread().name = getName() if (picasso.isLoggingEnabled) { log(OWNER_HUNTER, VERB_EXECUTING, getLogIdsForHunter(this)) @@ -88,6 +92,12 @@ internal open class BitmapHunter( } } + fun getName() = NAME_BUILDER.get()!!.also { + val name = data.name + it.ensureCapacity(THREAD_PREFIX.length + name.length) + it.replace(THREAD_PREFIX.length, it.length, name) + }.toString() + fun hunt(): Bitmap? { if (shouldReadFromMemoryCache(data.memoryPolicy)) { cache[key]?.let { bitmap -> @@ -212,7 +222,7 @@ internal open class BitmapHunter( } fun cancel(): Boolean = - action == null && actions.isNullOrEmpty() && future?.cancel(false) ?: false + action == null && actions.isNullOrEmpty() && future?.cancel(false) ?: job?.let { it.cancel(); true } ?: false fun shouldRetry(airplaneMode: Boolean, info: NetworkInfo?): Boolean { val hasRetries = retryCount > 0 @@ -283,16 +293,6 @@ internal open class BitmapHunter( return BitmapHunter(picasso, dispatcher, cache, action, ERRORING_HANDLER) } - fun updateThreadName(data: Request) { - val name = data.name - val builder = NAME_BUILDER.get()!!.also { - it.ensureCapacity(THREAD_PREFIX.length + name.length) - it.replace(THREAD_PREFIX.length, it.length, name) - } - - Thread.currentThread().name = builder.toString() - } - fun applyTransformations( picasso: Picasso, data: Request, diff --git a/picasso/src/main/java/com/squareup/picasso3/Dispatcher.kt b/picasso/src/main/java/com/squareup/picasso3/Dispatcher.kt index 3628b42264..583f5621d7 100644 --- a/picasso/src/main/java/com/squareup/picasso3/Dispatcher.kt +++ b/picasso/src/main/java/com/squareup/picasso3/Dispatcher.kt @@ -15,474 +15,38 @@ */ package com.squareup.picasso3 -import android.Manifest.permission.ACCESS_NETWORK_STATE -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.Intent.ACTION_AIRPLANE_MODE_CHANGED -import android.content.IntentFilter -import android.net.ConnectivityManager -import android.net.ConnectivityManager.CONNECTIVITY_ACTION import android.net.NetworkInfo -import android.os.Handler -import android.util.Log -import androidx.annotation.CallSuper -import androidx.annotation.MainThread -import androidx.core.content.ContextCompat -import com.squareup.picasso3.BitmapHunter.Companion.forRequest -import com.squareup.picasso3.MemoryPolicy.Companion.shouldWriteToMemoryCache -import com.squareup.picasso3.NetworkPolicy.NO_CACHE -import com.squareup.picasso3.NetworkRequestHandler.ContentLengthException -import com.squareup.picasso3.RequestHandler.Result.Bitmap -import com.squareup.picasso3.Utils.OWNER_DISPATCHER -import com.squareup.picasso3.Utils.VERB_CANCELED -import com.squareup.picasso3.Utils.VERB_DELIVERED -import com.squareup.picasso3.Utils.VERB_ENQUEUED -import com.squareup.picasso3.Utils.VERB_IGNORED -import com.squareup.picasso3.Utils.VERB_PAUSED -import com.squareup.picasso3.Utils.VERB_REPLAYING -import com.squareup.picasso3.Utils.VERB_RETRYING -import com.squareup.picasso3.Utils.getLogIdsForHunter -import com.squareup.picasso3.Utils.hasPermission -import com.squareup.picasso3.Utils.isAirplaneModeOn -import com.squareup.picasso3.Utils.log -import java.util.WeakHashMap -import java.util.concurrent.ExecutorService -internal abstract class Dispatcher internal constructor( - private val context: Context, - @get:JvmName("-service") internal val service: ExecutorService, - private val mainThreadHandler: Handler, - private val cache: PlatformLruCache -) { - @get:JvmName("-hunterMap") - internal val hunterMap = mutableMapOf() +internal interface Dispatcher { + fun shutdown() - @get:JvmName("-failedActions") - internal val failedActions = WeakHashMap() + fun dispatchSubmit(action: Action) - @get:JvmName("-pausedActions") - internal val pausedActions = WeakHashMap() + fun dispatchCancel(action: Action) - @get:JvmName("-pausedTags") - internal val pausedTags = mutableSetOf() + fun dispatchPauseTag(tag: Any) - @get:JvmName("-receiver") - internal val receiver: NetworkBroadcastReceiver + fun dispatchResumeTag(tag: Any) - @get:JvmName("-airplaneMode") - @set:JvmName("-airplaneMode") - internal var airplaneMode = isAirplaneModeOn(context) + fun dispatchComplete(hunter: BitmapHunter) - private val scansNetworkChanges: Boolean + fun dispatchRetry(hunter: BitmapHunter) - init { - scansNetworkChanges = hasPermission(context, ACCESS_NETWORK_STATE) - receiver = NetworkBroadcastReceiver(this) - receiver.register() - } - - @CallSuper open fun shutdown() { - // Shutdown the thread pool only if it is the one created by Picasso. - (service as? PicassoExecutorService)?.shutdown() - // Unregister network broadcast receiver on the main thread. - mainThreadHandler.post { receiver.unregister() } - } - - abstract fun dispatchSubmit(action: Action) - - abstract fun dispatchCancel(action: Action) - - abstract fun dispatchPauseTag(tag: Any) - - abstract fun dispatchResumeTag(tag: Any) - - abstract fun dispatchComplete(hunter: BitmapHunter) - - abstract fun dispatchRetry(hunter: BitmapHunter) - - abstract fun dispatchFailed(hunter: BitmapHunter) - - abstract fun dispatchNetworkStateChange(info: NetworkInfo) - - abstract fun dispatchAirplaneModeChange(airplaneMode: Boolean) - - abstract fun dispatchCompleteMain(hunter: BitmapHunter) - - abstract fun dispatchBatchResumeMain(batch: MutableList) - - fun performSubmit(action: Action, dismissFailed: Boolean = true) { - if (action.tag in pausedTags) { - pausedActions[action.getTarget()] = action - if (action.picasso.isLoggingEnabled) { - log( - owner = OWNER_DISPATCHER, - verb = VERB_PAUSED, - logId = action.request.logId(), - extras = "because tag '${action.tag}' is paused" - ) - } - return - } - - var hunter = hunterMap[action.request.key] - if (hunter != null) { - hunter.attach(action) - return - } - - if (service.isShutdown) { - if (action.picasso.isLoggingEnabled) { - log( - owner = OWNER_DISPATCHER, - verb = VERB_IGNORED, - logId = action.request.logId(), - extras = "because shut down" - ) - } - return - } - - hunter = forRequest(action.picasso, this, cache, action) - hunter.future = service.submit(hunter) - hunterMap[action.request.key] = hunter - if (dismissFailed) { - failedActions.remove(action.getTarget()) - } - - if (action.picasso.isLoggingEnabled) { - log(owner = OWNER_DISPATCHER, verb = VERB_ENQUEUED, logId = action.request.logId()) - } - } - - fun performCancel(action: Action) { - val key = action.request.key - val hunter = hunterMap[key] - if (hunter != null) { - hunter.detach(action) - if (hunter.cancel()) { - hunterMap.remove(key) - if (action.picasso.isLoggingEnabled) { - log(OWNER_DISPATCHER, VERB_CANCELED, action.request.logId()) - } - } - } - - if (action.tag in pausedTags) { - pausedActions.remove(action.getTarget()) - if (action.picasso.isLoggingEnabled) { - log( - owner = OWNER_DISPATCHER, - verb = VERB_CANCELED, - logId = action.request.logId(), - extras = "because paused request got canceled" - ) - } - } - - val remove = failedActions.remove(action.getTarget()) - if (remove != null && remove.picasso.isLoggingEnabled) { - log(OWNER_DISPATCHER, VERB_CANCELED, remove.request.logId(), "from replaying") - } - } - - fun performPauseTag(tag: Any) { - // Trying to pause a tag that is already paused. - if (!pausedTags.add(tag)) { - return - } - - // Go through all active hunters and detach/pause the requests - // that have the paused tag. - val iterator = hunterMap.values.iterator() - while (iterator.hasNext()) { - val hunter = iterator.next() - val loggingEnabled = hunter.picasso.isLoggingEnabled - - val single = hunter.action - val joined = hunter.actions - val hasMultiple = !joined.isNullOrEmpty() - - // Hunter has no requests, bail early. - if (single == null && !hasMultiple) { - continue - } - - if (single != null && single.tag == tag) { - hunter.detach(single) - pausedActions[single.getTarget()] = single - if (loggingEnabled) { - log( - owner = OWNER_DISPATCHER, - verb = VERB_PAUSED, - logId = single.request.logId(), - extras = "because tag '$tag' was paused" - ) - } - } - - if (joined != null) { - for (i in joined.indices.reversed()) { - val action = joined[i] - if (action.tag != tag) { - continue - } - hunter.detach(action) - pausedActions[action.getTarget()] = action - if (loggingEnabled) { - log( - owner = OWNER_DISPATCHER, - verb = VERB_PAUSED, - logId = action.request.logId(), - extras = "because tag '$tag' was paused" - ) - } - } - } - - // Check if the hunter can be cancelled in case all its requests - // had the tag being paused here. - if (hunter.cancel()) { - iterator.remove() - if (loggingEnabled) { - log( - owner = OWNER_DISPATCHER, - verb = VERB_CANCELED, - logId = getLogIdsForHunter(hunter), - extras = "all actions paused" - ) - } - } - } - } + fun dispatchFailed(hunter: BitmapHunter) - fun performResumeTag(tag: Any) { - // Trying to resume a tag that is not paused. - if (!pausedTags.remove(tag)) { - return - } + fun dispatchNetworkStateChange(info: NetworkInfo) - val batch = mutableListOf() - val iterator = pausedActions.values.iterator() - while (iterator.hasNext()) { - val action = iterator.next() - if (action.tag == tag) { - batch += action - iterator.remove() - } - } + fun dispatchAirplaneModeChange(airplaneMode: Boolean) - if (batch.isNotEmpty()) { - dispatchBatchResumeMain(batch) - } - } - - @SuppressLint("MissingPermission") - fun performRetry(hunter: BitmapHunter) { - if (hunter.isCancelled) return - - if (service.isShutdown) { - performError(hunter) - return - } - - var networkInfo: NetworkInfo? = null - if (scansNetworkChanges) { - val connectivityManager = - ContextCompat.getSystemService(context, ConnectivityManager::class.java) - if (connectivityManager != null) { - networkInfo = connectivityManager.activeNetworkInfo - } - } - - if (hunter.shouldRetry(airplaneMode, networkInfo)) { - if (hunter.picasso.isLoggingEnabled) { - log( - owner = OWNER_DISPATCHER, - verb = VERB_RETRYING, - logId = getLogIdsForHunter(hunter) - ) - } - if (hunter.exception is ContentLengthException) { - hunter.data = hunter.data.newBuilder().networkPolicy(NO_CACHE).build() - } - hunter.future = service.submit(hunter) - } else { - performError(hunter) - // Mark for replay only if we observe network info changes and support replay. - if (scansNetworkChanges && hunter.supportsReplay()) { - markForReplay(hunter) - } - } - } - - fun performComplete(hunter: BitmapHunter) { - if (shouldWriteToMemoryCache(hunter.data.memoryPolicy)) { - val result = hunter.result - if (result != null) { - if (result is Bitmap) { - val bitmap = result.bitmap - cache[hunter.key] = bitmap - } - } - } - hunterMap.remove(hunter.key) - deliver(hunter) - } - - fun performError(hunter: BitmapHunter) { - hunterMap.remove(hunter.key) - deliver(hunter) - } - - fun performAirplaneModeChange(airplaneMode: Boolean) { - this.airplaneMode = airplaneMode - } - - fun performNetworkStateChange(info: NetworkInfo?) { - // Intentionally check only if isConnected() here before we flush out failed actions. - if (info != null && info.isConnected) { - flushFailedActions() - } - } - - @MainThread - fun performCompleteMain(hunter: BitmapHunter) { - hunter.picasso.complete(hunter) - } + fun dispatchSubmit(hunter: BitmapHunter) - @MainThread - fun performBatchResumeMain(batch: List) { - for (i in batch.indices) { - val action = batch[i] - action.picasso.resumeAction(action) - } - } - - private fun flushFailedActions() { - if (failedActions.isNotEmpty()) { - val iterator = failedActions.values.iterator() - while (iterator.hasNext()) { - val action = iterator.next() - iterator.remove() - if (action.picasso.isLoggingEnabled) { - log( - owner = OWNER_DISPATCHER, - verb = VERB_REPLAYING, - logId = action.request.logId() - ) - } - performSubmit(action, false) - } - } - } - - private fun markForReplay(hunter: BitmapHunter) { - val action = hunter.action - action?.let { markForReplay(it) } - val joined = hunter.actions - if (joined != null) { - for (i in joined.indices) { - markForReplay(joined[i]) - } - } - } - - private fun markForReplay(action: Action) { - val target = action.getTarget() - action.willReplay = true - failedActions[target] = action - } - - private fun deliver(hunter: BitmapHunter) { - if (hunter.isCancelled) { - return - } - val result = hunter.result - if (result != null) { - if (result is Bitmap) { - val bitmap = result.bitmap - bitmap.prepareToDraw() - } - } + fun dispatchCompleteMain(hunter: BitmapHunter) - dispatchCompleteMain(hunter) + fun dispatchBatchResumeMain(batch: MutableList) - logDelivery(hunter) - } - - private fun logDelivery(bitmapHunter: BitmapHunter) { - val picasso = bitmapHunter.picasso - if (picasso.isLoggingEnabled) { - log( - owner = OWNER_DISPATCHER, - verb = VERB_DELIVERED, - logId = getLogIdsForHunter(bitmapHunter) - ) - } - } - - internal class NetworkBroadcastReceiver( - private val dispatcher: Dispatcher - ) : BroadcastReceiver() { - fun register() { - val filter = IntentFilter() - filter.addAction(ACTION_AIRPLANE_MODE_CHANGED) - if (dispatcher.scansNetworkChanges) { - filter.addAction(CONNECTIVITY_ACTION) - } - dispatcher.context.registerReceiver(this, filter) - } - - fun unregister() { - dispatcher.context.unregisterReceiver(this) - } - - @SuppressLint("MissingPermission") - override fun onReceive(context: Context, intent: Intent?) { - // On some versions of Android this may be called with a null Intent, - // also without extras (getExtras() == null), in such case we use defaults. - if (intent == null) { - return - } - when (intent.action) { - ACTION_AIRPLANE_MODE_CHANGED -> { - if (!intent.hasExtra(EXTRA_AIRPLANE_STATE)) { - return // No airplane state, ignore it. Should we query Utils.isAirplaneModeOn? - } - dispatcher.dispatchAirplaneModeChange(intent.getBooleanExtra(EXTRA_AIRPLANE_STATE, false)) - } - CONNECTIVITY_ACTION -> { - val connectivityManager = - ContextCompat.getSystemService(context, ConnectivityManager::class.java) - val networkInfo = try { - connectivityManager!!.activeNetworkInfo - } catch (re: RuntimeException) { - Log.w(TAG, "System UI crashed, ignoring attempt to change network state.") - return - } - if (networkInfo == null) { - Log.w( - TAG, - "No default network is currently active, ignoring attempt to change network state." - ) - return - } - dispatcher.dispatchNetworkStateChange(networkInfo) - } - } - } - - internal companion object { - const val EXTRA_AIRPLANE_STATE = "state" - } - } + fun isShutdown(): Boolean - internal companion object { + companion object { const val RETRY_DELAY = 500L - const val HUNTER_COMPLETE = 4 - const val NETWORK_STATE_CHANGE = 9 - const val REQUEST_BATCH_RESUME = 13 } } diff --git a/picasso/src/main/java/com/squareup/picasso3/HandlerDispatcher.kt b/picasso/src/main/java/com/squareup/picasso3/HandlerDispatcher.kt index 8e4a03ac0e..d4d48ac434 100644 --- a/picasso/src/main/java/com/squareup/picasso3/HandlerDispatcher.kt +++ b/picasso/src/main/java/com/squareup/picasso3/HandlerDispatcher.kt @@ -28,10 +28,10 @@ import java.util.concurrent.ExecutorService internal class HandlerDispatcher internal constructor( context: Context, - service: ExecutorService, + @get:JvmName("-service") val service: ExecutorService, mainThreadHandler: Handler, cache: PlatformLruCache -) : Dispatcher(context, service, mainThreadHandler, cache) { +) : BaseDispatcher(context, mainThreadHandler, cache) { private val dispatcherThread: DispatcherThread private val handler: Handler @@ -48,6 +48,8 @@ internal class HandlerDispatcher internal constructor( override fun shutdown() { super.shutdown() + // Shutdown the thread pool only if it is the one created by Picasso. + (service as? PicassoExecutorService)?.shutdown() dispatcherThread.quit() } @@ -94,6 +96,10 @@ internal class HandlerDispatcher internal constructor( ) } + override fun dispatchSubmit(hunter: BitmapHunter) { + hunter.future = service.submit(hunter) + } + override fun dispatchCompleteMain(hunter: BitmapHunter) { val message = mainHandler.obtainMessage(HUNTER_COMPLETE, hunter) if (hunter.priority == HIGH) { @@ -106,6 +112,7 @@ internal class HandlerDispatcher internal constructor( override fun dispatchBatchResumeMain(batch: MutableList) { mainHandler.sendMessage(mainHandler.obtainMessage(REQUEST_BATCH_RESUME, batch)) } + override fun isShutdown() = service.isShutdown private class DispatcherHandler( looper: Looper, @@ -159,7 +166,7 @@ internal class HandlerDispatcher internal constructor( private class MainDispatcherHandler( looper: Looper, - val dispatcher: Dispatcher + val dispatcher: HandlerDispatcher ) : Handler(looper) { override fun handleMessage(msg: Message) { when (msg.what) { @@ -186,11 +193,14 @@ internal class HandlerDispatcher internal constructor( private const val AIRPLANE_MODE_OFF = 0 private const val REQUEST_SUBMIT = 1 private const val REQUEST_CANCEL = 2 + private const val HUNTER_COMPLETE = 4 private const val HUNTER_RETRY = 5 private const val HUNTER_DECODE_FAILED = 6 + private const val NETWORK_STATE_CHANGE = 9 private const val AIRPLANE_MODE_CHANGE = 10 private const val TAG_PAUSE = 11 private const val TAG_RESUME = 12 + private const val REQUEST_BATCH_RESUME = 13 private const val DISPATCHER_THREAD_NAME = "Dispatcher" } } diff --git a/picasso/src/main/java/com/squareup/picasso3/InternalCoroutineDispatcher.kt b/picasso/src/main/java/com/squareup/picasso3/InternalCoroutineDispatcher.kt new file mode 100644 index 0000000000..9d6eb094f2 --- /dev/null +++ b/picasso/src/main/java/com/squareup/picasso3/InternalCoroutineDispatcher.kt @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso3 + +import android.content.Context +import android.net.NetworkInfo +import android.os.Handler +import com.squareup.picasso3.Dispatcher.Companion.RETRY_DELAY +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +internal class InternalCoroutineDispatcher internal constructor( + context: Context, + mainThreadHandler: Handler, + cache: PlatformLruCache, + val mainDispatcher: CoroutineDispatcher, + val backgroundDispatcher: CoroutineDispatcher +) : BaseDispatcher(context, mainThreadHandler, cache) { + + private val scope = CoroutineScope(SupervisorJob() + backgroundDispatcher) + private val channel = Channel<() -> Unit>(capacity = Channel.UNLIMITED) + + init { + // Using a channel to enforce sequential access for this class' internal state + scope.launch { + while (!channel.isClosedForReceive) { + channel.receive().invoke() + } + } + } + + override fun shutdown() { + super.shutdown() + channel.close() + scope.cancel() + } + + override fun dispatchSubmit(action: Action) { + channel.trySend { + performSubmit(action) + } + } + + override fun dispatchCancel(action: Action) { + channel.trySend { + performCancel(action) + } + } + + override fun dispatchPauseTag(tag: Any) { + channel.trySend { + performPauseTag(tag) + } + } + + override fun dispatchResumeTag(tag: Any) { + channel.trySend { + performResumeTag(tag) + } + } + + override fun dispatchComplete(hunter: BitmapHunter) { + channel.trySend { + performComplete(hunter) + } + } + + override fun dispatchRetry(hunter: BitmapHunter) { + scope.launch { + delay(RETRY_DELAY) + channel.send { + performRetry(hunter) + } + } + } + + override fun dispatchFailed(hunter: BitmapHunter) { + channel.trySend { + performError(hunter) + } + } + + override fun dispatchNetworkStateChange(info: NetworkInfo) { + channel.trySend { + performNetworkStateChange(info) + } + } + + override fun dispatchAirplaneModeChange(airplaneMode: Boolean) { + channel.trySend { + performAirplaneModeChange(airplaneMode) + } + } + + override fun dispatchCompleteMain(hunter: BitmapHunter) { + scope.launch(mainDispatcher) { + performCompleteMain(hunter) + } + } + + override fun dispatchBatchResumeMain(batch: MutableList) { + scope.launch(mainDispatcher) { + performBatchResumeMain(batch) + } + } + + override fun dispatchSubmit(hunter: BitmapHunter) { + hunter.job = scope.launch(CoroutineName(hunter.getName())) { + hunter.run() + } + } + + override fun isShutdown() = !scope.isActive +} diff --git a/picasso/src/main/java/com/squareup/picasso3/Picasso.kt b/picasso/src/main/java/com/squareup/picasso3/Picasso.kt index b2a61ea512..239a748d7e 100644 --- a/picasso/src/main/java/com/squareup/picasso3/Picasso.kt +++ b/picasso/src/main/java/com/squareup/picasso3/Picasso.kt @@ -51,6 +51,8 @@ import java.io.File import java.io.IOException import java.util.WeakHashMap import java.util.concurrent.ExecutorService +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers /** * Image downloading, transformation, and caching manager. @@ -537,6 +539,8 @@ class Picasso internal constructor( private val context: Context private var callFactory: Call.Factory? = null private var service: ExecutorService? = null + private var mainDispatcher: CoroutineDispatcher? = null + private var backgroundDispatcher: CoroutineDispatcher? = null private var cache: PlatformLruCache? = null private var listener: Listener? = null private val requestTransformers = mutableListOf() @@ -554,7 +558,9 @@ class Picasso internal constructor( internal constructor(picasso: Picasso) { context = picasso.context callFactory = picasso.callFactory - service = picasso.dispatcher.service + service = (picasso.dispatcher as? HandlerDispatcher)?.service + mainDispatcher = (picasso.dispatcher as? InternalCoroutineDispatcher)?.mainDispatcher + backgroundDispatcher = (picasso.dispatcher as? InternalCoroutineDispatcher)?.backgroundDispatcher cache = picasso.cache listener = picasso.listener requestTransformers += picasso.requestTransformers @@ -647,6 +653,17 @@ class Picasso internal constructor( loggingEnabled = enabled } + /** + * Sets the CoroutineDispatchers used internally + */ + fun dispatchers( + mainDispatcher: CoroutineDispatcher = Dispatchers.Main, + backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO + ) = apply { + this.mainDispatcher = mainDispatcher + this.backgroundDispatcher = backgroundDispatcher + } + /** Create the [Picasso] instance. */ fun build(): Picasso { var unsharedCache: okhttp3.Cache? = null @@ -661,11 +678,16 @@ class Picasso internal constructor( if (cache == null) { cache = PlatformLruCache(calculateMemoryCacheSize(context)) } - if (service == null) { - service = PicassoExecutorService() - } - val dispatcher = HandlerDispatcher(context, service!!, HANDLER, cache!!) + val dispatcher = if (backgroundDispatcher != null) { + InternalCoroutineDispatcher(context, HANDLER, cache!!, mainDispatcher!!, backgroundDispatcher!!) + } else { + if (service == null) { + service = PicassoExecutorService() + } + + HandlerDispatcher(context, service!!, HANDLER, cache!!) + } return Picasso( context, dispatcher, callFactory!!, unsharedCache, cache!!, listener, diff --git a/picasso/src/test/java/com/squareup/picasso3/BaseDispatcherTest.kt b/picasso/src/test/java/com/squareup/picasso3/BaseDispatcherTest.kt new file mode 100644 index 0000000000..93e94c6622 --- /dev/null +++ b/picasso/src/test/java/com/squareup/picasso3/BaseDispatcherTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso3 + +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import com.squareup.picasso3.BaseDispatcher.NetworkBroadcastReceiver +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class BaseDispatcherTest { + + @Mock lateinit var context: Context + + @Before fun setUp() { + MockitoAnnotations.initMocks(this) + } + + @Test fun nullIntentOnReceiveDoesNothing() { + val dispatcher = Mockito.mock(BaseDispatcher::class.java) + val receiver = NetworkBroadcastReceiver(dispatcher) + + receiver.onReceive(context, null) + + Mockito.verifyNoInteractions(dispatcher) + } + + @Test fun nullExtrasOnReceiveConnectivityAreOk() { + val connectivityManager = Mockito.mock(ConnectivityManager::class.java) + val networkInfo = TestUtils.mockNetworkInfo() + Mockito.`when`(connectivityManager.activeNetworkInfo).thenReturn(networkInfo) + Mockito.`when`(context.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn(connectivityManager) + val dispatcher = Mockito.mock(BaseDispatcher::class.java) + val receiver = NetworkBroadcastReceiver(dispatcher) + + receiver.onReceive(context, Intent(ConnectivityManager.CONNECTIVITY_ACTION)) + + Mockito.verify(dispatcher).dispatchNetworkStateChange(networkInfo) + } + + @Test fun nullExtrasOnReceiveAirplaneDoesNothing() { + val dispatcher = Mockito.mock(BaseDispatcher::class.java) + val receiver = NetworkBroadcastReceiver(dispatcher) + + receiver.onReceive(context, Intent(Intent.ACTION_AIRPLANE_MODE_CHANGED)) + + Mockito.verifyNoInteractions(dispatcher) + } + + @Test fun correctExtrasOnReceiveAirplaneDispatches() { + setAndVerifyAirplaneMode(false) + setAndVerifyAirplaneMode(true) + } + + private fun setAndVerifyAirplaneMode(airplaneOn: Boolean) { + val dispatcher = Mockito.mock(BaseDispatcher::class.java) + val receiver = NetworkBroadcastReceiver(dispatcher) + val intent = Intent(Intent.ACTION_AIRPLANE_MODE_CHANGED) + intent.putExtra(NetworkBroadcastReceiver.EXTRA_AIRPLANE_STATE, airplaneOn) + receiver.onReceive(context, intent) + Mockito.verify(dispatcher).dispatchAirplaneModeChange(airplaneOn) + } +} diff --git a/picasso/src/test/java/com/squareup/picasso3/DispatcherTest.kt b/picasso/src/test/java/com/squareup/picasso3/HandlerDispatcherTest.kt similarity index 91% rename from picasso/src/test/java/com/squareup/picasso3/DispatcherTest.kt rename to picasso/src/test/java/com/squareup/picasso3/HandlerDispatcherTest.kt index 61daa04834..0fba4d0d59 100644 --- a/picasso/src/test/java/com/squareup/picasso3/DispatcherTest.kt +++ b/picasso/src/test/java/com/squareup/picasso3/HandlerDispatcherTest.kt @@ -17,18 +17,13 @@ package com.squareup.picasso3 import android.content.Context import android.content.Context.CONNECTIVITY_SERVICE -import android.content.Intent -import android.content.Intent.ACTION_AIRPLANE_MODE_CHANGED import android.content.pm.PackageManager.PERMISSION_DENIED import android.content.pm.PackageManager.PERMISSION_GRANTED import android.net.ConnectivityManager -import android.net.ConnectivityManager.CONNECTIVITY_ACTION import android.net.NetworkInfo import android.os.Handler import android.os.Looper.getMainLooper import com.google.common.truth.Truth.assertThat -import com.squareup.picasso3.Dispatcher.NetworkBroadcastReceiver -import com.squareup.picasso3.Dispatcher.NetworkBroadcastReceiver.Companion.EXTRA_AIRPLANE_STATE import com.squareup.picasso3.MemoryPolicy.NO_STORE import com.squareup.picasso3.NetworkRequestHandler.ContentLengthException import com.squareup.picasso3.Picasso.LoadedFrom.MEMORY @@ -56,7 +51,6 @@ import org.mockito.Mockito.doReturn import org.mockito.Mockito.mock import org.mockito.Mockito.spy import org.mockito.Mockito.verify -import org.mockito.Mockito.verifyNoInteractions import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations.initMocks import org.robolectric.RobolectricTestRunner @@ -68,7 +62,7 @@ import java.util.concurrent.Future import java.util.concurrent.FutureTask @RunWith(RobolectricTestRunner::class) -class DispatcherTest { +class HandlerDispatcherTest { @Mock lateinit var context: Context @Mock lateinit var connectivityManager: ConnectivityManager @@ -76,7 +70,7 @@ class DispatcherTest { @Mock lateinit var serviceMock: ExecutorService private lateinit var picasso: Picasso - private lateinit var dispatcher: Dispatcher + private lateinit var dispatcher: HandlerDispatcher private val executorService = spy(PicassoExecutorService()) private val cache = PlatformLruCache(2048) @@ -556,53 +550,14 @@ class DispatcherTest { assertThat(dispatcher.failedActions).isEmpty() } - @Test fun nullIntentOnReceiveDoesNothing() { - val dispatcher = mock(Dispatcher::class.java) - val receiver = NetworkBroadcastReceiver(dispatcher) - receiver.onReceive(context, null) - verifyNoInteractions(dispatcher) - } - - @Test fun nullExtrasOnReceiveConnectivityAreOk() { - val connectivityManager = mock(ConnectivityManager::class.java) - val networkInfo = mockNetworkInfo() - `when`(connectivityManager.activeNetworkInfo).thenReturn(networkInfo) - `when`(context.getSystemService(CONNECTIVITY_SERVICE)).thenReturn(connectivityManager) - val dispatcher = mock(Dispatcher::class.java) - val receiver = NetworkBroadcastReceiver(dispatcher) - receiver.onReceive(context, Intent(CONNECTIVITY_ACTION)) - verify(dispatcher).dispatchNetworkStateChange(networkInfo) - } - - @Test fun nullExtrasOnReceiveAirplaneDoesNothing() { - val dispatcher = mock(Dispatcher::class.java) - val receiver = NetworkBroadcastReceiver(dispatcher) - receiver.onReceive(context, Intent(ACTION_AIRPLANE_MODE_CHANGED)) - verifyNoInteractions(dispatcher) - } - - @Test fun correctExtrasOnReceiveAirplaneDispatches() { - setAndVerifyAirplaneMode(false) - setAndVerifyAirplaneMode(true) - } - - private fun setAndVerifyAirplaneMode(airplaneOn: Boolean) { - val dispatcher = mock(Dispatcher::class.java) - val receiver = NetworkBroadcastReceiver(dispatcher) - val intent = Intent(ACTION_AIRPLANE_MODE_CHANGED) - intent.putExtra(EXTRA_AIRPLANE_STATE, airplaneOn) - receiver.onReceive(context, intent) - verify(dispatcher).dispatchAirplaneModeChange(airplaneOn) - } - - private fun createDispatcher(scansNetworkChanges: Boolean): Dispatcher { + private fun createDispatcher(scansNetworkChanges: Boolean): HandlerDispatcher { return createDispatcher(service, scansNetworkChanges) } private fun createDispatcher( service: ExecutorService, scansNetworkChanges: Boolean = true - ): Dispatcher { + ): HandlerDispatcher { `when`(connectivityManager.activeNetworkInfo).thenReturn( if (scansNetworkChanges) mock(NetworkInfo::class.java) else null ) diff --git a/picasso/src/test/java/com/squareup/picasso3/InternalCoroutineDispatcherTest.kt b/picasso/src/test/java/com/squareup/picasso3/InternalCoroutineDispatcherTest.kt new file mode 100644 index 0000000000..5187a6f4b5 --- /dev/null +++ b/picasso/src/test/java/com/squareup/picasso3/InternalCoroutineDispatcherTest.kt @@ -0,0 +1,710 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.picasso3 + +import android.content.Context +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkInfo +import android.os.Handler +import android.os.Looper +import com.google.common.truth.Truth.assertThat +import com.squareup.picasso3.MemoryPolicy.NO_STORE +import com.squareup.picasso3.NetworkRequestHandler.ContentLengthException +import com.squareup.picasso3.Picasso.LoadedFrom.MEMORY +import com.squareup.picasso3.Picasso.LoadedFrom.NETWORK +import com.squareup.picasso3.Request.Builder +import com.squareup.picasso3.RequestHandler.Result.Bitmap +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import java.lang.Exception +import java.lang.RuntimeException +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher + +@RunWith(RobolectricTestRunner::class) +class InternalCoroutineDispatcherTest { + + @Mock lateinit var context: Context + + @Mock lateinit var connectivityManager: ConnectivityManager + + private lateinit var picasso: Picasso + private lateinit var dispatcher: InternalCoroutineDispatcher + private lateinit var testDispatcher: TestDispatcher + + private val cache = PlatformLruCache(2048) + private val bitmap1 = TestUtils.makeBitmap() + + @Before fun setUp() { + MockitoAnnotations.initMocks(this) + Mockito.`when`(context.applicationContext).thenReturn(context) + dispatcher = createDispatcher() + } + + @Test fun shutdownCancelsRunningJob() { + createDispatcher(true) + val action = TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1) + dispatcher.dispatchSubmit(action) + + dispatcher.shutdown() + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.isShutdown()).isEqualTo(true) + assertThat(action.completedResult).isNull() + } + + @Test fun shutdownUnregistersReceiver() { + dispatcher.shutdown() + Shadows.shadowOf(Looper.getMainLooper()).idle() + Mockito.verify(context).unregisterReceiver(dispatcher.receiver) + } + + @Test fun dispatchSubmitWithNewRequestQueuesHunter() { + val action = TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1) + dispatcher.dispatchSubmit(action) + + testDispatcher.scheduler.runCurrent() + + assertThat(action.completedResult).isNotNull() + } + + @Test fun dispatchSubmitWithTwoDifferentRequestsQueuesHunters() { + val action1 = TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1) + val action2 = TestUtils.mockAction(picasso, TestUtils.URI_KEY_2, TestUtils.URI_2) + + dispatcher.dispatchSubmit(action1) + dispatcher.dispatchSubmit(action2) + + testDispatcher.scheduler.runCurrent() + + assertThat(action1.completedResult).isNotNull() + assertThat(action2.completedResult).isNotNull() + assertThat(action2.completedResult).isNotEqualTo(action1.completedResult) + } + + @Test fun performSubmitWithExistingRequestAttachesToHunter() { + val action1 = TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1) + val action2 = TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1) + + dispatcher.dispatchSubmit(action1) + dispatcher.dispatchSubmit(action2) + testDispatcher.scheduler.runCurrent() + + assertThat(action1.completedResult).isNotNull() + assertThat(action2.completedResult).isEqualTo(action1.completedResult) + } + + @Test fun dispatchSubmitWithShutdownServiceIgnoresRequest() { + dispatcher.shutdown() + + val action = TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1) + dispatcher.dispatchSubmit(action) + testDispatcher.scheduler.runCurrent() + + assertThat(action.completedResult).isNull() + } + + @Test fun dispatchSubmitWithFetchAction() { + val pausedTag = "pausedTag" + dispatcher.dispatchPauseTag(pausedTag) + testDispatcher.scheduler.runCurrent() + assertThat(dispatcher.pausedActions).isEmpty() + + var completed = false + val fetchAction1 = noopAction(Request.Builder(TestUtils.URI_1).tag(pausedTag).build(), { completed = true }) + val fetchAction2 = noopAction(Request.Builder(TestUtils.URI_1).tag(pausedTag).build(), { completed = true }) + dispatcher.dispatchSubmit(fetchAction1) + dispatcher.dispatchSubmit(fetchAction2) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.pausedActions).hasSize(2) + assertThat(completed).isFalse() + } + + @Test fun dispatchCancelWithFetchActionWithCallback() { + val pausedTag = "pausedTag" + dispatcher.dispatchPauseTag(pausedTag) + testDispatcher.scheduler.runCurrent() + assertThat(dispatcher.pausedActions).isEmpty() + + val callback = TestUtils.mockCallback() + + val fetchAction1 = FetchAction(picasso, Request.Builder(TestUtils.URI_1).tag(pausedTag).build(), callback) + dispatcher.dispatchSubmit(fetchAction1) + testDispatcher.scheduler.runCurrent() + assertThat(dispatcher.pausedActions).hasSize(1) + + dispatcher.dispatchCancel(fetchAction1) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.pausedActions).isEmpty() + } + + @Test fun dispatchCancelDetachesRequestAndCleansUp() { + val target = TestUtils.mockBitmapTarget() + val action = TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1, target) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action).apply { + job = Job() + } + dispatcher.hunterMap[TestUtils.URI_KEY_1 + Request.KEY_SEPARATOR] = hunter + dispatcher.failedActions[target] = action + + dispatcher.dispatchCancel(action) + testDispatcher.scheduler.runCurrent() + + assertThat(hunter.job!!.isCancelled).isTrue() + assertThat(hunter.action).isNull() + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(dispatcher.failedActions).isEmpty() + } + + @Test fun dispatchCancelMultipleRequestsDetachesOnly() { + val action1 = TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1) + val action2 = TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action1) + hunter.attach(action2) + dispatcher.hunterMap[TestUtils.URI_KEY_1 + Request.KEY_SEPARATOR] = hunter + + dispatcher.dispatchCancel(action1) + testDispatcher.scheduler.runCurrent() + + assertThat(hunter.action).isNull() + assertThat(hunter.actions).containsExactly(action2) + assertThat(dispatcher.hunterMap).hasSize(1) + } + + @Test fun dispatchCancelUnqueuesAndDetachesPausedRequest() { + val action = TestUtils.mockAction( + picasso, + TestUtils.URI_KEY_1, + TestUtils.URI_1, + TestUtils.mockBitmapTarget(), + tag = "tag" + ) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action) + dispatcher.dispatchSubmit(action) + dispatcher.dispatchPauseTag("tag") + testDispatcher.scheduler.runCurrent() + dispatcher.hunterMap[TestUtils.URI_KEY_1 + Request.KEY_SEPARATOR] = hunter + + dispatcher.dispatchCancel(action) + testDispatcher.scheduler.runCurrent() + + assertThat(hunter.action).isNull() + assertThat(dispatcher.pausedTags).containsExactly("tag") + assertThat(dispatcher.pausedActions).isEmpty() + } + + @Test fun dispatchCompleteSetsResultInCache() { + val data = Request.Builder(TestUtils.URI_1).build() + val action = noopAction(data) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action) + hunter.run() + assertThat(cache.size()).isEqualTo(0) + + dispatcher.dispatchComplete(hunter) + testDispatcher.scheduler.runCurrent() + + val result = hunter.result as Bitmap + assertThat(result.bitmap).isEqualTo(bitmap1) + assertThat(result.loadedFrom).isEqualTo(NETWORK) + assertThat(cache[hunter.key]).isSameInstanceAs(bitmap1) + } + + @Test fun dispatchCompleteWithNoStoreMemoryPolicy() { + val data = Request.Builder(TestUtils.URI_1).memoryPolicy(NO_STORE).build() + val action = noopAction(data) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action) + hunter.run() + assertThat(cache.size()).isEqualTo(0) + + dispatcher.dispatchComplete(hunter) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(cache.size()).isEqualTo(0) + } + + @Test fun dispatchCompleteCleansUpAndPostsToMain() { + val data = Request.Builder(TestUtils.URI_1).build() + var completed = false + val action = noopAction(data, onComplete = { completed = true }) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action) + hunter.run() + + dispatcher.dispatchComplete(hunter) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(completed).isTrue() + } + + @Test fun dispatchCompleteCleansUpAndDoesNotPostToMainIfCancelled() { + val data = Request.Builder(TestUtils.URI_1).build() + var completed = false + val action = noopAction(data, onComplete = { completed = true }) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action) + hunter.run() + hunter.job = Job().apply { cancel() } + + dispatcher.dispatchComplete(hunter) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(completed).isFalse() + } + + @Test fun dispatchErrorCleansUpAndPostsToMain() { + val exception = RuntimeException() + val action = TestUtils.mockAction( + picasso, + TestUtils.URI_KEY_1, + TestUtils.URI_1, + TestUtils.mockBitmapTarget(), + tag = "tag" + ) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action, exception) + hunter.run() + dispatcher.hunterMap[hunter.key] = hunter + + dispatcher.dispatchFailed(hunter) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(action.errorException).isEqualTo(exception) + } + + @Test fun dispatchErrorCleansUpAndDoesNotPostToMainIfCancelled() { + val exception = RuntimeException() + val action = TestUtils.mockAction( + picasso, + TestUtils.URI_KEY_1, + TestUtils.URI_1, + TestUtils.mockBitmapTarget(), + tag = "tag" + ) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action, exception) + hunter.run() + hunter.job = Job().apply { cancel() } + dispatcher.hunterMap[hunter.key] = hunter + + dispatcher.dispatchFailed(hunter) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(action.errorException).isNull() + } + + @Test fun dispatchRetrySkipsIfHunterIsCancelled() { + val action = TestUtils.mockAction( + picasso, + TestUtils.URI_KEY_1, + TestUtils.URI_1, + TestUtils.mockBitmapTarget(), + tag = "tag" + ) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action) + hunter.job = Job().apply { cancel() } + + dispatcher.dispatchRetry(hunter) + testDispatcher.scheduler.runCurrent() + + assertThat(hunter.isCancelled).isTrue() + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(dispatcher.failedActions).isEmpty() + } + + @Test fun dispatchRetryForContentLengthResetsNetworkPolicy() { + val networkInfo = TestUtils.mockNetworkInfo(true) + Mockito.`when`(connectivityManager.activeNetworkInfo).thenReturn(networkInfo) + val action = TestUtils.mockAction(picasso, TestUtils.URI_KEY_2, TestUtils.URI_2) + val e = ContentLengthException("304 error") + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action, e, true) + hunter.run() + + dispatcher.dispatchRetry(hunter) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(NetworkPolicy.shouldReadFromDiskCache(hunter.data.networkPolicy)).isFalse() + } + + @Test fun dispatchRetryDoesNotMarkForReplayIfNotSupported() { + val networkInfo = TestUtils.mockNetworkInfo(true) + val hunter = TestUtils.mockHunter( + picasso, + Bitmap(bitmap1, MEMORY), + TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1) + ) + Mockito.`when`(connectivityManager.activeNetworkInfo).thenReturn(networkInfo) + + dispatcher.dispatchRetry(hunter) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(dispatcher.failedActions).isEmpty() + } + + @Test fun dispatchRetryDoesNotMarkForReplayIfNoNetworkScanning() { + val hunter = TestUtils.mockHunter( + picasso, + Bitmap(bitmap1, MEMORY), + TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1), + e = null, + shouldRetry = false, + supportsReplay = true + ) + val dispatcher = createDispatcher(false) + + dispatcher.dispatchRetry(hunter) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(dispatcher.failedActions).isEmpty() + } + + @Test fun dispatchRetryMarksForReplayIfSupportedScansNetworkChangesAndShouldNotRetry() { + val networkInfo = TestUtils.mockNetworkInfo(true) + val action = TestUtils.mockAction( + picasso, + TestUtils.URI_KEY_1, + TestUtils.URI_1, + TestUtils.mockBitmapTarget() + ) + val hunter = TestUtils.mockHunter( + picasso, + Bitmap(bitmap1, MEMORY), + action, + e = null, + shouldRetry = false, + supportsReplay = true + ) + Mockito.`when`(connectivityManager.activeNetworkInfo).thenReturn(networkInfo) + + dispatcher.dispatchRetry(hunter) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(dispatcher.failedActions).hasSize(1) + assertThat(action.willReplay).isTrue() + } + + @Test fun dispatchRetryRetriesIfNoNetworkScanning() { + val dispatcher = createDispatcher(false) + val action = TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1) + val hunter = TestUtils.mockHunter( + picasso, + Bitmap(bitmap1, MEMORY), + action, + e = null, + shouldRetry = true, + dispatcher = dispatcher + ) + + dispatcher.dispatchRetry(hunter) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(dispatcher.failedActions).isEmpty() + assertThat(action.completedResult).isInstanceOf(Bitmap::class.java) + } + + @Test fun dispatchRetryMarksForReplayIfSupportsReplayAndShouldNotRetry() { + val action = TestUtils.mockAction( + picasso, + TestUtils.URI_KEY_1, + TestUtils.URI_1, + TestUtils.mockBitmapTarget() + ) + val hunter = TestUtils.mockHunter( + picasso, + Bitmap(bitmap1, MEMORY), + action, + e = null, + shouldRetry = false, + supportsReplay = true + ) + + dispatcher.dispatchRetry(hunter) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(dispatcher.failedActions).hasSize(1) + assertThat(action.willReplay).isTrue() + } + + @Test fun dispatchRetryRetriesIfShouldRetry() { + val action = TestUtils.mockAction( + picasso, + TestUtils.URI_KEY_1, + TestUtils.URI_1, + TestUtils.mockBitmapTarget() + ) + val hunter = TestUtils.mockHunter( + picasso, + Bitmap(bitmap1, MEMORY), + action, + e = null, + shouldRetry = true, + dispatcher = dispatcher + ) + + dispatcher.dispatchRetry(hunter) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(dispatcher.failedActions).isEmpty() + assertThat(action.completedResult).isInstanceOf(Bitmap::class.java) + } + + @Test fun dispatchRetrySkipIfServiceShutdown() { + val action = TestUtils.mockAction( + picasso, + TestUtils.URI_KEY_1, + TestUtils.URI_1, + TestUtils.mockBitmapTarget() + ) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action) + + dispatcher.shutdown() + dispatcher.dispatchRetry(hunter) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(dispatcher.failedActions).isEmpty() + assertThat(action.completedResult).isNull() + } + + @Test fun dispatchAirplaneModeChange() { + assertThat(dispatcher.airplaneMode).isFalse() + + dispatcher.dispatchAirplaneModeChange(true) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.airplaneMode).isTrue() + + dispatcher.dispatchAirplaneModeChange(false) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.airplaneMode).isFalse() + } + + @Test fun dispatchNetworkStateChangeWithDisconnectedInfoIgnores() { + val info = TestUtils.mockNetworkInfo() + Mockito.`when`(info.isConnectedOrConnecting).thenReturn(false) + + dispatcher.dispatchNetworkStateChange(info) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.failedActions).isEmpty() + } + + @Test fun dispatchNetworkStateChangeWithConnectedInfoDifferentInstanceIgnores() { + val info = TestUtils.mockNetworkInfo(true) + + dispatcher.dispatchNetworkStateChange(info) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.failedActions).isEmpty() + } + + @Test fun dispatchPauseAndResumeUpdatesListOfPausedTags() { + dispatcher.dispatchPauseTag("tag") + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.pausedTags).containsExactly("tag") + + dispatcher.dispatchResumeTag("tag") + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.pausedTags).isEmpty() + } + + @Test fun dispatchPauseTagIsIdempotent() { + val action = TestUtils.mockAction( + picasso, + TestUtils.URI_KEY_1, + TestUtils.URI_1, + TestUtils.mockBitmapTarget(), + tag = "tag" + ) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action) + dispatcher.hunterMap[TestUtils.URI_KEY_1] = hunter + assertThat(dispatcher.pausedActions).isEmpty() + + dispatcher.dispatchPauseTag("tag") + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.pausedActions).containsEntry(action.getTarget(), action) + + dispatcher.dispatchPauseTag("tag") + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.pausedActions).containsEntry(action.getTarget(), action) + } + + @Test fun dispatchPauseTagQueuesNewRequestDoesNotComplete() { + dispatcher.dispatchPauseTag("tag") + val action = TestUtils.mockAction( + picasso = picasso, + key = TestUtils.URI_KEY_1, + uri = TestUtils.URI_1, + tag = "tag" + ) + + dispatcher.dispatchSubmit(action) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(dispatcher.pausedActions).hasSize(1) + assertThat(dispatcher.pausedActions.containsValue(action)).isTrue() + assertThat(action.completedResult).isNull() + } + + @Test fun dispatchPauseTagDoesNotQueueUnrelatedRequest() { + dispatcher.dispatchPauseTag("tag") + testDispatcher.scheduler.runCurrent() + + val action = TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1, "anothertag") + dispatcher.dispatchSubmit(action) + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.pausedActions).isEmpty() + assertThat(action.completedResult).isNotNull() + } + + @Test fun dispatchPauseDetachesRequestAndCancelsHunter() { + val action = TestUtils.mockAction( + picasso = picasso, + key = TestUtils.URI_KEY_1, + uri = TestUtils.URI_1, + tag = "tag" + ) + val hunter = TestUtils.mockHunter( + picasso = picasso, + result = Bitmap(bitmap1, MEMORY), + action = action, + dispatcher = dispatcher + ) + hunter.job = Job() + + dispatcher.hunterMap[TestUtils.URI_KEY_1] = hunter + dispatcher.dispatchPauseTag("tag") + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.hunterMap).isEmpty() + assertThat(dispatcher.pausedActions).hasSize(1) + assertThat(dispatcher.pausedActions.containsValue(action)).isTrue() + assertThat(hunter.action).isNull() + assertThat(action.completedResult).isNull() + } + + @Test fun dispatchPauseOnlyDetachesPausedRequest() { + val action1 = TestUtils.mockAction( + picasso = picasso, + key = TestUtils.URI_KEY_1, + uri = TestUtils.URI_1, + target = TestUtils.mockBitmapTarget(), + tag = "tag1" + ) + val action2 = TestUtils.mockAction( + picasso = picasso, + key = TestUtils.URI_KEY_1, + uri = TestUtils.URI_1, + target = TestUtils.mockBitmapTarget(), + tag = "tag2" + ) + val hunter = TestUtils.mockHunter(picasso, Bitmap(bitmap1, MEMORY), action1) + hunter.attach(action2) + dispatcher.hunterMap[TestUtils.URI_KEY_1] = hunter + + dispatcher.dispatchPauseTag("tag1") + testDispatcher.scheduler.runCurrent() + + assertThat(dispatcher.hunterMap).hasSize(1) + assertThat(dispatcher.hunterMap.containsValue(hunter)).isTrue() + assertThat(dispatcher.pausedActions).hasSize(1) + assertThat(dispatcher.pausedActions.containsValue(action1)).isTrue() + assertThat(hunter.action).isNull() + assertThat(hunter.actions).containsExactly(action2) + } + + @Test fun dispatchResumeTagIsIdempotent() { + var completedCount = 0 + val action = noopAction(Builder(TestUtils.URI_1).tag("tag").build(), { completedCount++ }) + + dispatcher.dispatchPauseTag("tag") + dispatcher.dispatchSubmit(action) + dispatcher.dispatchResumeTag("tag") + dispatcher.dispatchResumeTag("tag") + testDispatcher.scheduler.runCurrent() + + assertThat(completedCount).isEqualTo(1) + } + + @Test fun dispatchNetworkStateChangeFlushesFailedHunters() { + val info = TestUtils.mockNetworkInfo(true) + val failedAction1 = TestUtils.mockAction(picasso, TestUtils.URI_KEY_1, TestUtils.URI_1) + val failedAction2 = TestUtils.mockAction(picasso, TestUtils.URI_KEY_2, TestUtils.URI_2) + dispatcher.failedActions[TestUtils.URI_KEY_1] = failedAction1 + dispatcher.failedActions[TestUtils.URI_KEY_2] = failedAction2 + + dispatcher.dispatchNetworkStateChange(info) + testDispatcher.scheduler.runCurrent() + + assertThat(failedAction1.completedResult).isNotNull() + assertThat(failedAction2.completedResult).isNotNull() + assertThat(dispatcher.failedActions).isEmpty() + } + + private fun createDispatcher( + scansNetworkChanges: Boolean = true + ): InternalCoroutineDispatcher { + Mockito.`when`(connectivityManager.activeNetworkInfo).thenReturn( + if (scansNetworkChanges) Mockito.mock(NetworkInfo::class.java) else null + ) + Mockito.`when`(context.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn(connectivityManager) + Mockito.`when`(context.checkCallingOrSelfPermission(ArgumentMatchers.anyString())).thenReturn( + if (scansNetworkChanges) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED + ) + + testDispatcher = StandardTestDispatcher() + picasso = TestUtils.mockPicasso(context).newBuilder().dispatchers(testDispatcher, testDispatcher).build() + return InternalCoroutineDispatcher( + context, + Handler(Looper.getMainLooper()), + cache, + testDispatcher, + testDispatcher + ) + } + + private fun noopAction(data: Request, onComplete: () -> Unit = { }): Action { + return object : Action(picasso, data) { + override fun complete(result: RequestHandler.Result) = onComplete() + override fun error(e: Exception) = Unit + override fun getTarget(): Any = this + } + } +} diff --git a/picasso/src/test/java/com/squareup/picasso3/PicassoTest.kt b/picasso/src/test/java/com/squareup/picasso3/PicassoTest.kt index bfe5b07515..e15a93ab80 100644 --- a/picasso/src/test/java/com/squareup/picasso3/PicassoTest.kt +++ b/picasso/src/test/java/com/squareup/picasso3/PicassoTest.kt @@ -509,7 +509,7 @@ class PicassoTest { assertThat(child.context).isEqualTo(parent.context) assertThat(child.callFactory).isEqualTo(parent.callFactory) - assertThat(child.dispatcher.service).isEqualTo(parent.dispatcher.service) + assertThat((child.dispatcher as HandlerDispatcher).service).isEqualTo((parent.dispatcher as HandlerDispatcher).service) assertThat(child.cache).isEqualTo(parent.cache) assertThat(child.listener).isEqualTo(parent.listener) assertThat(child.requestTransformers).isEqualTo(parent.requestTransformers) @@ -529,6 +529,20 @@ class PicassoTest { ) } + @Test fun cloneSharesCoroutineDispatchers() { + val parent = + defaultPicasso(RuntimeEnvironment.application, true, true) + .newBuilder() + .dispatchers() + .build() + val child = parent.newBuilder().build() + + val parentDispatcher = parent.dispatcher as InternalCoroutineDispatcher + val childDispatcher = child.dispatcher as InternalCoroutineDispatcher + assertThat(childDispatcher.mainDispatcher).isEqualTo(parentDispatcher.mainDispatcher) + assertThat(childDispatcher.backgroundDispatcher).isEqualTo(parentDispatcher.backgroundDispatcher) + } + private fun verifyActionComplete(action: FakeAction) { val result = action.completedResult assertThat(result).isNotNull()