diff --git a/build.gradle b/build.gradle index b4f5b8e20e..26b81f9175 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { 'targetSdk': 31, 'sourceCompatibility': JavaVersion.VERSION_1_8, 'targetCompatibility': JavaVersion.VERSION_1_8, - 'kotlin': '1.5.21', + 'kotlin': '1.6.10', 'okhttp': '4.9.3', 'okio': '3.0.0', ] @@ -28,6 +28,9 @@ buildscript { truth: 'com.google.truth:truth:1.1.3', robolectric: 'org.robolectric:robolectric:4.6.1', mockito: 'org.mockito:mockito-core:3.0.0', + drawablePainter: 'com.google.accompanist:accompanist-drawablepainter:0.23.1', + composeUi: 'androidx.compose.ui:ui:1.1.1', + foundation: 'androidx.compose.foundation:foundation:1.1.1' ] ext.isCi = "true" == System.getenv('CI') diff --git a/picasso-compose/README.md b/picasso-compose/README.md new file mode 100644 index 0000000000..9fa845eb43 --- /dev/null +++ b/picasso-compose/README.md @@ -0,0 +1,16 @@ +Picasso Compose Ui +==================================== + +A [Painter] which wraps a [RequestCreator] + +Usage +----- + +Create a `Painter` using the rememberPainter extension on a Picasso instance. + +```kotlin +val picasso = Picasso.Builder(context).build() +val painter = picasso.rememberPainter(key = url) { + it.load(url).placeholder(placeholderDrawable).error(errorDrawable) +} +``` \ No newline at end of file diff --git a/picasso-compose/api/picasso-compose.api b/picasso-compose/api/picasso-compose.api new file mode 100644 index 0000000000..73c88ddd88 --- /dev/null +++ b/picasso-compose/api/picasso-compose.api @@ -0,0 +1,4 @@ +public final class com/squareup/picasso3/compose/PicassoPainterKt { + public static final fun rememberPainter (Lcom/squareup/picasso3/Picasso;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)Landroidx/compose/ui/graphics/painter/Painter; +} + diff --git a/picasso-compose/build.gradle b/picasso-compose/build.gradle new file mode 100644 index 0000000000..09b0dd0a6e --- /dev/null +++ b/picasso-compose/build.gradle @@ -0,0 +1,40 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'com.vanniktech.maven.publish' + +android { + compileSdkVersion versions.compileSdk + + defaultConfig { + minSdkVersion versions.minSdk + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion "1.1.1" + } + + compileOptions { + sourceCompatibility versions.sourceCompatibility + targetCompatibility versions.targetCompatibility + } + + lintOptions { + textOutput 'stdout' + textReport true + lintConfig rootProject.file('lint.xml') + } +} + +dependencies { + api project(':picasso') + + implementation deps.drawablePainter + implementation deps.composeUi + implementation deps.foundation + + compileOnly deps.androidxAnnotations +} diff --git a/picasso-compose/gradle.properties b/picasso-compose/gradle.properties new file mode 100644 index 0000000000..eef2b86bcc --- /dev/null +++ b/picasso-compose/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=picasso-compose +POM_NAME=Picasso Compose +POM_DESCRIPTION=Compose UI support for Picasso. +POM_PACKAGING=aar diff --git a/picasso-compose/src/main/AndroidManifest.xml b/picasso-compose/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..f62947b282 --- /dev/null +++ b/picasso-compose/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/picasso-compose/src/main/java/com/squareup/picasso3/compose/PicassoPainter.kt b/picasso-compose/src/main/java/com/squareup/picasso3/compose/PicassoPainter.kt new file mode 100644 index 0000000000..82502200ae --- /dev/null +++ b/picasso-compose/src/main/java/com/squareup/picasso3/compose/PicassoPainter.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2022 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.compose + +import android.graphics.drawable.Drawable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.RememberObserver +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter +import com.google.accompanist.drawablepainter.DrawablePainter +import com.squareup.picasso3.DrawableTarget +import com.squareup.picasso3.Picasso +import com.squareup.picasso3.Picasso.LoadedFrom +import com.squareup.picasso3.RequestCreator + +@Composable +fun Picasso.rememberPainter( + key: Any? = null, + onError: ((Exception) -> Unit)? = null, + request: (Picasso) -> RequestCreator, +): Painter { + return remember(key) { PicassoPainter(this, request, onError) } +} + +internal class PicassoPainter( + private val picasso: Picasso, + private val request: (Picasso) -> RequestCreator, + private val onError: ((Exception) -> Unit)? = null +) : Painter(), RememberObserver, DrawableTarget { + + private var painter: Painter by mutableStateOf(EmptyPainter) + private var alpha: Float by mutableStateOf(DefaultAlpha) + private var colorFilter: ColorFilter? by mutableStateOf(null) + + override val intrinsicSize: Size + get() = painter.intrinsicSize + + override fun applyAlpha(alpha: Float): Boolean { + this.alpha = alpha + return true + } + + override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { + this.colorFilter = colorFilter + return true + } + + override fun DrawScope.onDraw() { + with(painter) { + draw(size, alpha, colorFilter) + } + } + + override fun onRemembered() { + request.invoke(picasso).into(this) + } + + override fun onAbandoned() { + (painter as? RememberObserver)?.onAbandoned() + painter = EmptyPainter + picasso.cancelRequest(this) + } + + override fun onForgotten() { + (painter as? RememberObserver)?.onForgotten() + painter = EmptyPainter + picasso.cancelRequest(this) + } + + override fun onPrepareLoad(placeHolderDrawable: Drawable?) { + placeHolderDrawable?.let(::setPainter) + } + + override fun onDrawableLoaded(drawable: Drawable, from: LoadedFrom) { + setPainter(drawable) + } + + override fun onDrawableFailed(e: Exception, errorDrawable: Drawable?) { + onError?.invoke(e) + errorDrawable?.let(::setPainter) + } + + private fun setPainter(drawable: Drawable) { + (painter as? RememberObserver)?.onForgotten() + painter = DrawablePainter(drawable).apply(DrawablePainter::onRemembered) + } +} + +private object EmptyPainter : Painter() { + override val intrinsicSize = Size.Zero + override fun DrawScope.onDraw() = Unit +} diff --git a/picasso-sample/build.gradle b/picasso-sample/build.gradle index a0841be563..422ff7fe4f 100644 --- a/picasso-sample/build.gradle +++ b/picasso-sample/build.gradle @@ -10,6 +10,14 @@ android { applicationId 'com.example.picasso' } + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion "1.1.1" + } + compileOptions { sourceCompatibility versions.sourceCompatibility targetCompatibility versions.targetCompatibility @@ -32,6 +40,11 @@ dependencies { implementation deps.androidxFragment implementation deps.androidxStartup + implementation deps.drawablePainter + implementation deps.composeUi + implementation deps.foundation + implementation project(':picasso') implementation project(':picasso-stats') + implementation project(':picasso-compose') } diff --git a/picasso-sample/src/main/AndroidManifest.xml b/picasso-sample/src/main/AndroidManifest.xml index 300e3350b2..63f3944b53 100644 --- a/picasso-sample/src/main/AndroidManifest.xml +++ b/picasso-sample/src/main/AndroidManifest.xml @@ -34,6 +34,7 @@ + diff --git a/picasso-sample/src/main/java/com/example/picasso/PicassoSampleAdapter.kt b/picasso-sample/src/main/java/com/example/picasso/PicassoSampleAdapter.kt index c65c20bb70..cd4fca9c8d 100644 --- a/picasso-sample/src/main/java/com/example/picasso/PicassoSampleAdapter.kt +++ b/picasso-sample/src/main/java/com/example/picasso/PicassoSampleAdapter.kt @@ -38,6 +38,7 @@ internal class PicassoSampleAdapter(context: Context?) : BaseAdapter() { private val activityClass: Class? ) { GRID_VIEW("Image Grid View", SampleGridViewActivity::class.java), + COMPOSE_UI("Compose UI", SampleComposeActivity::class.java), GALLERY("Load from Gallery", SampleGalleryActivity::class.java), CONTACTS("Contact Photos", SampleContactsActivity::class.java), LIST_DETAIL("List / Detail View", SampleListDetailActivity::class.java), diff --git a/picasso-sample/src/main/java/com/example/picasso/SampleComposeActivity.kt b/picasso-sample/src/main/java/com/example/picasso/SampleComposeActivity.kt new file mode 100644 index 0000000000..a8b18ef5f6 --- /dev/null +++ b/picasso-sample/src/main/java/com/example/picasso/SampleComposeActivity.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2022 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.example.picasso + +import android.os.Bundle +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.GridCells.Adaptive +import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale.Companion.Crop +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.dp +import com.squareup.picasso3.Picasso +import com.squareup.picasso3.compose.rememberPainter + +class SampleComposeActivity : PicassoSampleActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val composeView = ComposeView(this) + + val urls = Data.URLS.toMutableList().shuffled() + + Data.URLS.toMutableList().shuffled() + + Data.URLS.toMutableList().shuffled() + + composeView.setContent { + ImageGrid(urls = urls) + } + + setContentView(composeView) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ImageGrid( + modifier: Modifier = Modifier, + urls: List, + picasso: Picasso = PicassoInitializer.get() +) { + LazyVerticalGrid( + cells = Adaptive(150.dp), + modifier = modifier, + ) { + items(urls.size) { + val url = urls[it] + Image( + painter = picasso.rememberPainter(key = url) { + it.load(url).placeholder(R.drawable.placeholder).error(R.drawable.error) + }, + contentDescription = null, + contentScale = Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + ) + } + } +} diff --git a/picasso/api/picasso.api b/picasso/api/picasso.api index 3af2ac5bf9..a38eee1ba6 100644 --- a/picasso/api/picasso.api +++ b/picasso/api/picasso.api @@ -15,6 +15,12 @@ public class com/squareup/picasso3/Callback$EmptyCallback : com/squareup/picasso public fun onSuccess ()V } +public abstract interface class com/squareup/picasso3/DrawableTarget { + public abstract fun onDrawableFailed (Ljava/lang/Exception;Landroid/graphics/drawable/Drawable;)V + public abstract fun onDrawableLoaded (Landroid/graphics/drawable/Drawable;Lcom/squareup/picasso3/Picasso$LoadedFrom;)V + public abstract fun onPrepareLoad (Landroid/graphics/drawable/Drawable;)V +} + public abstract interface class com/squareup/picasso3/EventListener : java/io/Closeable { public abstract fun bitmapDecoded (Landroid/graphics/Bitmap;)V public abstract fun bitmapTransformed (Landroid/graphics/Bitmap;)V @@ -72,6 +78,7 @@ public final class com/squareup/picasso3/Picasso : androidx/lifecycle/LifecycleO public final fun cancelRequest (Landroid/widget/ImageView;)V public final fun cancelRequest (Landroid/widget/RemoteViews;I)V public final fun cancelRequest (Lcom/squareup/picasso3/BitmapTarget;)V + public final fun cancelRequest (Lcom/squareup/picasso3/DrawableTarget;)V public final fun cancelTag (Ljava/lang/Object;)V public final fun evictAll ()V public final fun getIndicatorsEnabled ()Z @@ -272,6 +279,7 @@ public final class com/squareup/picasso3/RequestCreator { public final fun into (Landroid/widget/RemoteViews;I[I)V public final fun into (Landroid/widget/RemoteViews;I[ILcom/squareup/picasso3/Callback;)V public final fun into (Lcom/squareup/picasso3/BitmapTarget;)V + public final fun into (Lcom/squareup/picasso3/DrawableTarget;)V public static synthetic fun into$default (Lcom/squareup/picasso3/RequestCreator;Landroid/widget/ImageView;Lcom/squareup/picasso3/Callback;ILjava/lang/Object;)V public static synthetic fun into$default (Lcom/squareup/picasso3/RequestCreator;Landroid/widget/RemoteViews;IILandroid/app/Notification;Ljava/lang/String;Lcom/squareup/picasso3/Callback;ILjava/lang/Object;)V public static synthetic fun into$default (Lcom/squareup/picasso3/RequestCreator;Landroid/widget/RemoteViews;IILcom/squareup/picasso3/Callback;ILjava/lang/Object;)V diff --git a/picasso/src/main/java/com/squareup/picasso3/DrawableTarget.kt b/picasso/src/main/java/com/squareup/picasso3/DrawableTarget.kt new file mode 100644 index 0000000000..78c774f409 --- /dev/null +++ b/picasso/src/main/java/com/squareup/picasso3/DrawableTarget.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 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.graphics.drawable.Drawable +import com.squareup.picasso3.Picasso.LoadedFrom + +/** + * Represents an arbitrary listener for image loading. + * + * Objects implementing this class **must** have a working implementation of + * [Object.equals] and [Object.hashCode] for proper storage internally. + * Instances of this interface will also be compared to determine if view recycling is occurring. + * It is recommended that you add this interface directly on to a custom view type when using in an + * adapter to ensure correct recycling behavior. + */ +interface DrawableTarget { + /** + * Callback when an image has been successfully loaded. + * + */ + fun onDrawableLoaded( + drawable: Drawable, + from: LoadedFrom + ) + + /** + * Callback indicating the image could not be successfully loaded. + * + * **Note:** The passed [Drawable] may be `null` if none has been + * specified via [RequestCreator.error]. + */ + fun onDrawableFailed( + e: Exception, + errorDrawable: Drawable? + ) + + /** + * Callback invoked right before your request is submitted. + * + * + * **Note:** The passed [Drawable] may be `null` if none has been + * specified via [RequestCreator.placeholder]. + */ + fun onPrepareLoad(placeHolderDrawable: Drawable?) +} diff --git a/picasso/src/main/java/com/squareup/picasso3/DrawableTargetAction.kt b/picasso/src/main/java/com/squareup/picasso3/DrawableTargetAction.kt new file mode 100644 index 0000000000..b9dea2cca6 --- /dev/null +++ b/picasso/src/main/java/com/squareup/picasso3/DrawableTargetAction.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2022 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.graphics.drawable.Drawable +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import com.squareup.picasso3.RequestHandler.Result +import com.squareup.picasso3.RequestHandler.Result.Bitmap + +internal class DrawableTargetAction( + picasso: Picasso, + private val target: DrawableTarget, + data: Request, + private val noFade: Boolean, + private val placeholderDrawable: Drawable?, + private val errorDrawable: Drawable?, + @DrawableRes val errorResId: Int +) : Action(picasso, data) { + override fun complete(result: Result) { + if (result is Bitmap) { + val bitmap = result.bitmap + target.onDrawableLoaded( + PicassoDrawable( + context = picasso.context, + bitmap = bitmap, + placeholder = placeholderDrawable, + loadedFrom = result.loadedFrom, + noFade = noFade, + debugging = picasso.indicatorsEnabled + ), + result.loadedFrom + ) + check(!bitmap.isRecycled) { "Target callback must not recycle bitmap!" } + } + } + + override fun error(e: Exception) { + val drawable = if (errorResId != 0) { + ContextCompat.getDrawable(picasso.context, errorResId) + } else { + errorDrawable + } + + target.onDrawableFailed(e, drawable) + } + + override fun getTarget(): Any { + return target + } +} diff --git a/picasso/src/main/java/com/squareup/picasso3/Picasso.kt b/picasso/src/main/java/com/squareup/picasso3/Picasso.kt index b9a5abca68..a86a58e6a4 100644 --- a/picasso/src/main/java/com/squareup/picasso3/Picasso.kt +++ b/picasso/src/main/java/com/squareup/picasso3/Picasso.kt @@ -149,6 +149,12 @@ class Picasso internal constructor( cancelExistingRequest(target) } + /** Cancel any existing requests for the specified [DrawableTarget] instance. */ + fun cancelRequest(target: DrawableTarget) { + // checkMain() is called from cancelExistingRequest() + cancelExistingRequest(target) + } + /** * Cancel any existing requests for the specified [RemoteViews] target with the given [viewId]. */ diff --git a/picasso/src/main/java/com/squareup/picasso3/RequestCreator.kt b/picasso/src/main/java/com/squareup/picasso3/RequestCreator.kt index 12adef8a23..1ba703e2a7 100644 --- a/picasso/src/main/java/com/squareup/picasso3/RequestCreator.kt +++ b/picasso/src/main/java/com/squareup/picasso3/RequestCreator.kt @@ -448,6 +448,48 @@ class RequestCreator internal constructor( picasso.enqueueAndSubmit(action) } + /** + * Asynchronously fulfills the request into the specified [DrawableTarget]. In most cases, you + * should use this when you are dealing with a custom [View][android.view.View] or view + * holder which should implement the [DrawableTarget] interface. + */ + fun into(target: DrawableTarget) { + val started = System.nanoTime() + checkMain() + check(!deferred) { "Fit cannot be used with a Target." } + + val placeHolderDrawable = if (setPlaceholder) getPlaceholderDrawable() else null + if (!data.hasImage()) { + picasso.cancelRequest(target) + target.onPrepareLoad(placeHolderDrawable) + return + } + + val request = createRequest(started) + if (shouldReadFromMemoryCache(request.memoryPolicy)) { + val bitmap = picasso.quickMemoryCacheCheck(request.key) + if (bitmap != null) { + picasso.cancelRequest(target) + target.onDrawableLoaded( + PicassoDrawable( + context = picasso.context, + bitmap = bitmap, + placeholder = null, + loadedFrom = LoadedFrom.MEMORY, + noFade = noFade, + debugging = picasso.indicatorsEnabled + ), + LoadedFrom.MEMORY + ) + return + } + } + + target.onPrepareLoad(placeHolderDrawable) + val action = DrawableTargetAction(picasso, target, request, noFade, placeHolderDrawable, errorDrawable, errorResId) + picasso.enqueueAndSubmit(action) + } + /** * Asynchronously fulfills the request into the specified [RemoteViews] object with the * given [viewId]. This is used for loading bitmaps into a [Notification]. diff --git a/picasso/src/test/java/com/squareup/picasso3/BitmapTargetActionTest.kt b/picasso/src/test/java/com/squareup/picasso3/BitmapTargetActionTest.kt index c463ca3127..f1c59968d0 100644 --- a/picasso/src/test/java/com/squareup/picasso3/BitmapTargetActionTest.kt +++ b/picasso/src/test/java/com/squareup/picasso3/BitmapTargetActionTest.kt @@ -27,8 +27,8 @@ import com.squareup.picasso3.TestUtils.RESOURCE_ID_1 import com.squareup.picasso3.TestUtils.SIMPLE_REQUEST import com.squareup.picasso3.TestUtils.UNUSED_CALL_FACTORY import com.squareup.picasso3.TestUtils.makeBitmap +import com.squareup.picasso3.TestUtils.mockBitmapTarget import com.squareup.picasso3.TestUtils.mockPicasso -import com.squareup.picasso3.TestUtils.mockTarget import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith @@ -43,7 +43,7 @@ class BitmapTargetActionTest { @Test fun invokesSuccessIfTargetIsNotNull() { val bitmap = makeBitmap() - val target = mockTarget() + val target = mockBitmapTarget() val request = BitmapTargetAction( picasso = mockPicasso(RuntimeEnvironment.application), target = target, @@ -57,7 +57,7 @@ class BitmapTargetActionTest { @Test fun invokesOnBitmapFailedIfTargetIsNotNullWithErrorDrawable() { val errorDrawable = mock(Drawable::class.java) - val target = mockTarget() + val target = mockBitmapTarget() val request = BitmapTargetAction( picasso = mockPicasso(RuntimeEnvironment.application), target = target, @@ -74,7 +74,7 @@ class BitmapTargetActionTest { @Test fun invokesOnBitmapFailedIfTargetIsNotNullWithErrorResourceId() { val errorDrawable = mock(Drawable::class.java) - val target = mockTarget() + val target = mockBitmapTarget() val context = mock(Context::class.java) val dispatcher = mock(Dispatcher::class.java) val cache = PlatformLruCache(0) diff --git a/picasso/src/test/java/com/squareup/picasso3/DispatcherTest.kt b/picasso/src/test/java/com/squareup/picasso3/DispatcherTest.kt index df5b7e4fa1..6c9bcdf907 100644 --- a/picasso/src/test/java/com/squareup/picasso3/DispatcherTest.kt +++ b/picasso/src/test/java/com/squareup/picasso3/DispatcherTest.kt @@ -40,11 +40,11 @@ import com.squareup.picasso3.TestUtils.URI_KEY_1 import com.squareup.picasso3.TestUtils.URI_KEY_2 import com.squareup.picasso3.TestUtils.makeBitmap import com.squareup.picasso3.TestUtils.mockAction +import com.squareup.picasso3.TestUtils.mockBitmapTarget import com.squareup.picasso3.TestUtils.mockCallback import com.squareup.picasso3.TestUtils.mockHunter import com.squareup.picasso3.TestUtils.mockNetworkInfo import com.squareup.picasso3.TestUtils.mockPicasso -import com.squareup.picasso3.TestUtils.mockTarget import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -156,7 +156,7 @@ class DispatcherTest { } @Test fun performCancelDetachesRequestAndCleansUp() { - val target = mockTarget() + val target = mockBitmapTarget() val action = mockAction(picasso, URI_KEY_1, URI_1, target) val hunter = mockHunter(picasso, RequestHandler.Result.Bitmap(bitmap1, MEMORY), action) hunter.future = FutureTask(mock(Runnable::class.java), mock(Any::class.java)) @@ -181,7 +181,7 @@ class DispatcherTest { } @Test fun performCancelUnqueuesAndDetachesPausedRequest() { - val action = mockAction(picasso, URI_KEY_1, URI_1, mockTarget(), tag = "tag") + val action = mockAction(picasso, URI_KEY_1, URI_1, mockBitmapTarget(), tag = "tag") val hunter = mockHunter(picasso, RequestHandler.Result.Bitmap(bitmap1, MEMORY), action) dispatcher.hunterMap[URI_KEY_1 + Request.KEY_SEPARATOR] = hunter dispatcher.pausedTags.add("tag") @@ -248,7 +248,7 @@ class DispatcherTest { } @Test fun performErrorCleansUpAndPostsToMain() { - val action = mockAction(picasso, URI_KEY_1, URI_1, mockTarget(), tag = "tag") + val action = mockAction(picasso, URI_KEY_1, URI_1, mockBitmapTarget(), tag = "tag") val hunter = mockHunter(picasso, RequestHandler.Result.Bitmap(bitmap1, MEMORY), action) dispatcher.hunterMap[hunter.key] = hunter dispatcher.performError(hunter) @@ -257,7 +257,7 @@ class DispatcherTest { } @Test fun performErrorCleansUpAndDoesNotPostToMainIfCancelled() { - val action = mockAction(picasso, URI_KEY_1, URI_1, mockTarget(), tag = "tag") + val action = mockAction(picasso, URI_KEY_1, URI_1, mockBitmapTarget(), tag = "tag") val hunter = mockHunter(picasso, RequestHandler.Result.Bitmap(bitmap1, MEMORY), action) hunter.future = FutureTask(mock(Runnable::class.java), mock(Any::class.java)) hunter.future!!.cancel(false) @@ -268,7 +268,7 @@ class DispatcherTest { } @Test fun performRetrySkipsIfHunterIsCancelled() { - val action = mockAction(picasso, URI_KEY_1, URI_1, mockTarget(), tag = "tag") + val action = mockAction(picasso, URI_KEY_1, URI_1, mockBitmapTarget(), tag = "tag") val hunter = mockHunter(picasso, RequestHandler.Result.Bitmap(bitmap1, MEMORY), action) hunter.future = FutureTask(mock(Runnable::class.java), mock(Any::class.java)) hunter.future!!.cancel(false) @@ -319,7 +319,7 @@ class DispatcherTest { @Test fun performRetryMarksForReplayIfSupportedScansNetworkChangesAndShouldNotRetry() { val networkInfo = mockNetworkInfo(true) - val action = mockAction(picasso, URI_KEY_1, URI_1, mockTarget()) + val action = mockAction(picasso, URI_KEY_1, URI_1, mockBitmapTarget()) val hunter = mockHunter( picasso, RequestHandler.Result.Bitmap(bitmap1, MEMORY), @@ -348,7 +348,7 @@ class DispatcherTest { } @Test fun performRetryMarksForReplayIfSupportsReplayAndShouldNotRetry() { - val action = mockAction(picasso, URI_KEY_1, URI_1, mockTarget()) + val action = mockAction(picasso, URI_KEY_1, URI_1, mockBitmapTarget()) val hunter = mockHunter( picasso, RequestHandler.Result.Bitmap(bitmap1, MEMORY), action, e = null, shouldRetry = false, supportsReplay = true @@ -360,7 +360,7 @@ class DispatcherTest { } @Test fun performRetryRetriesIfShouldRetry() { - val action = mockAction(picasso, URI_KEY_1, URI_1, mockTarget()) + val action = mockAction(picasso, URI_KEY_1, URI_1, mockBitmapTarget()) val hunter = mockHunter( picasso, RequestHandler.Result.Bitmap(bitmap1, MEMORY), action, e = null, shouldRetry = true @@ -372,7 +372,7 @@ class DispatcherTest { } @Test fun performRetrySkipIfServiceShutdown() { - val action = mockAction(picasso, URI_KEY_1, URI_1, mockTarget()) + val action = mockAction(picasso, URI_KEY_1, URI_1, mockBitmapTarget()) val hunter = mockHunter(picasso, RequestHandler.Result.Bitmap(bitmap1, MEMORY), action) service.shutdown() dispatcher.performRetry(hunter) @@ -418,7 +418,7 @@ class DispatcherTest { } @Test fun performPauseTagIsIdempotent() { - val action = mockAction(picasso, URI_KEY_1, URI_1, mockTarget(), tag = "tag") + val action = mockAction(picasso, URI_KEY_1, URI_1, mockBitmapTarget(), tag = "tag") val hunter = mockHunter(picasso, RequestHandler.Result.Bitmap(bitmap1, MEMORY), action) dispatcher.hunterMap[URI_KEY_1] = hunter assertThat(dispatcher.pausedActions).isEmpty() @@ -468,10 +468,10 @@ class DispatcherTest { @Test fun performPauseOnlyDetachesPausedRequest() { val action1 = mockAction( - picasso = picasso, key = URI_KEY_1, uri = URI_1, target = mockTarget(), tag = "tag1" + picasso = picasso, key = URI_KEY_1, uri = URI_1, target = mockBitmapTarget(), tag = "tag1" ) val action2 = mockAction( - picasso = picasso, key = URI_KEY_1, uri = URI_1, target = mockTarget(), tag = "tag2" + picasso = picasso, key = URI_KEY_1, uri = URI_1, target = mockBitmapTarget(), tag = "tag2" ) val hunter = mockHunter(picasso, RequestHandler.Result.Bitmap(bitmap1, MEMORY), action1) hunter.attach(action2) diff --git a/picasso/src/test/java/com/squareup/picasso3/DrawableTargetActionTest.kt b/picasso/src/test/java/com/squareup/picasso3/DrawableTargetActionTest.kt new file mode 100644 index 0000000000..6191054fd8 --- /dev/null +++ b/picasso/src/test/java/com/squareup/picasso3/DrawableTargetActionTest.kt @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2022 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.res.Resources +import android.graphics.Bitmap.Config.ARGB_8888 +import android.graphics.drawable.Drawable +import com.google.common.truth.Truth.assertThat +import com.squareup.picasso3.Picasso.LoadedFrom.MEMORY +import com.squareup.picasso3.Picasso.LoadedFrom.NETWORK +import com.squareup.picasso3.TestUtils.argumentCaptor +import com.squareup.picasso3.TestUtils.eq +import com.squareup.picasso3.TestUtils.makeBitmap +import com.squareup.picasso3.TestUtils.mockDrawableTarget +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DrawableTargetActionTest { + + @Test fun invokesSuccessIfTargetIsNotNull() { + val bitmap = makeBitmap() + val target = mockDrawableTarget() + val drawableCaptor = argumentCaptor() + val placeholder = mock(Drawable::class.java) + val action = DrawableTargetAction( + picasso = TestUtils.mockPicasso(RuntimeEnvironment.application), + target = target, + data = TestUtils.SIMPLE_REQUEST, + noFade = false, + placeholderDrawable = placeholder, + errorDrawable = null, + errorResId = 0 + ) + + action.complete(RequestHandler.Result.Bitmap(bitmap, NETWORK)) + + Mockito.verify(target).onDrawableLoaded(drawableCaptor.capture(), eq(NETWORK)) + with(drawableCaptor.value) { + assertThat(this.bitmap).isEqualTo(bitmap) + assertThat(this.placeholder).isEqualTo(placeholder) + assertThat(this.animating).isTrue() + } + } + + @Test fun invokesOnBitmapFailedIfTargetIsNotNullWithErrorDrawable() { + val errorDrawable = mock(Drawable::class.java) + val target = mockDrawableTarget() + val action = DrawableTargetAction( + picasso = TestUtils.mockPicasso(RuntimeEnvironment.application), + target = target, + data = TestUtils.SIMPLE_REQUEST, + noFade = true, + placeholderDrawable = null, + errorDrawable = errorDrawable, + errorResId = 0 + ) + val e = RuntimeException() + + action.error(e) + + Mockito.verify(target).onDrawableFailed(e, errorDrawable) + } + + @Test fun invokesOnBitmapFailedIfTargetIsNotNullWithErrorResourceId() { + val errorDrawable = mock(Drawable::class.java) + val context = mock(Context::class.java) + val dispatcher = mock(Dispatcher::class.java) + val cache = PlatformLruCache(0) + val picasso = Picasso( + context, dispatcher, + TestUtils.UNUSED_CALL_FACTORY, null, cache, null, + TestUtils.NO_TRANSFORMERS, + TestUtils.NO_HANDLERS, + TestUtils.NO_EVENT_LISTENERS, ARGB_8888, false, false + ) + val res = mock(Resources::class.java) + + val target = mockDrawableTarget() + val action = DrawableTargetAction( + picasso = picasso, + target = target, + data = TestUtils.SIMPLE_REQUEST, + noFade = true, + placeholderDrawable = null, + errorDrawable = null, + errorResId = TestUtils.RESOURCE_ID_1 + ) + + Mockito.`when`(context.getDrawable(TestUtils.RESOURCE_ID_1)).thenReturn(errorDrawable) + val e = RuntimeException() + + action.error(e) + + Mockito.verify(target).onDrawableFailed(e, errorDrawable) + } + + @Test fun recyclingInSuccessThrowsException() { + val picasso = TestUtils.mockPicasso(RuntimeEnvironment.application) + val bitmap = makeBitmap() + val action = DrawableTargetAction( + picasso = picasso, + target = object : DrawableTarget { + override fun onDrawableLoaded(drawable: Drawable, from: Picasso.LoadedFrom) = (drawable as PicassoDrawable).bitmap.recycle() + override fun onDrawableFailed(e: Exception, errorDrawable: Drawable?) = throw AssertionError() + override fun onPrepareLoad(placeHolderDrawable: Drawable?) = throw AssertionError() + }, + data = TestUtils.SIMPLE_REQUEST, + noFade = true, + placeholderDrawable = null, + errorDrawable = null, + errorResId = 0 + ) + + try { + action.complete(RequestHandler.Result.Bitmap(bitmap, MEMORY)) + Assert.fail() + } catch (ignored: IllegalStateException) { + } + } +} diff --git a/picasso/src/test/java/com/squareup/picasso3/PicassoTest.kt b/picasso/src/test/java/com/squareup/picasso3/PicassoTest.kt index 7cecea2674..7c13db3e99 100644 --- a/picasso/src/test/java/com/squareup/picasso3/PicassoTest.kt +++ b/picasso/src/test/java/com/squareup/picasso3/PicassoTest.kt @@ -37,12 +37,13 @@ import com.squareup.picasso3.TestUtils.URI_KEY_1 import com.squareup.picasso3.TestUtils.defaultPicasso import com.squareup.picasso3.TestUtils.makeBitmap import com.squareup.picasso3.TestUtils.mockAction +import com.squareup.picasso3.TestUtils.mockBitmapTarget import com.squareup.picasso3.TestUtils.mockDeferredRequestCreator +import com.squareup.picasso3.TestUtils.mockDrawableTarget import com.squareup.picasso3.TestUtils.mockHunter import com.squareup.picasso3.TestUtils.mockImageViewTarget import com.squareup.picasso3.TestUtils.mockPicasso import com.squareup.picasso3.TestUtils.mockRequestCreator -import com.squareup.picasso3.TestUtils.mockTarget import org.junit.Assert.fail import org.junit.Before import org.junit.Rule @@ -291,8 +292,20 @@ class PicassoTest { assertThat(picasso.targetToDeferredRequestCreator).containsKey(target) } - @Test fun cancelExistingRequestWithTarget() { - val target = mockTarget() + @Test fun cancelExistingRequestWithBitmapTarget() { + val target = mockBitmapTarget() + val action = mockAction(picasso, URI_KEY_1, URI_1, target) + picasso.enqueueAndSubmit(action) + assertThat(picasso.targetToAction).hasSize(1) + assertThat(action.cancelled).isFalse() + picasso.cancelRequest(target) + assertThat(picasso.targetToAction).isEmpty() + assertThat(action.cancelled).isTrue() + verify(dispatcher).dispatchCancel(action) + } + + @Test fun cancelExistingRequestWithDrawableTarget() { + val target = mockDrawableTarget() val action = mockAction(picasso, URI_KEY_1, URI_1, target) picasso.enqueueAndSubmit(action) assertThat(picasso.targetToAction).hasSize(1) diff --git a/picasso/src/test/java/com/squareup/picasso3/RequestCreatorTest.kt b/picasso/src/test/java/com/squareup/picasso3/RequestCreatorTest.kt index f925e4c608..2099d8ac22 100644 --- a/picasso/src/test/java/com/squareup/picasso3/RequestCreatorTest.kt +++ b/picasso/src/test/java/com/squareup/picasso3/RequestCreatorTest.kt @@ -41,14 +41,15 @@ import com.squareup.picasso3.TestUtils.any import com.squareup.picasso3.TestUtils.argumentCaptor import com.squareup.picasso3.TestUtils.eq import com.squareup.picasso3.TestUtils.makeBitmap +import com.squareup.picasso3.TestUtils.mockBitmapTarget import com.squareup.picasso3.TestUtils.mockCallback +import com.squareup.picasso3.TestUtils.mockDrawableTarget import com.squareup.picasso3.TestUtils.mockFitImageViewTarget import com.squareup.picasso3.TestUtils.mockImageViewTarget import com.squareup.picasso3.TestUtils.mockNotification import com.squareup.picasso3.TestUtils.mockPicasso import com.squareup.picasso3.TestUtils.mockRemoteViews import com.squareup.picasso3.TestUtils.mockRequestCreator -import com.squareup.picasso3.TestUtils.mockTarget import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith @@ -151,20 +152,20 @@ class RequestCreatorTest { @Test fun intoTargetWithFitThrows() { try { - RequestCreator(picasso, URI_1, 0).fit().into(mockTarget()) + RequestCreator(picasso, URI_1, 0).fit().into(mockBitmapTarget()) fail("Calling into() target with fit() should throw exception") } catch (ignored: IllegalStateException) { } } @Test fun intoTargetNoPlaceholderCallsWithNull() { - val target = mockTarget() + val target = mockBitmapTarget() RequestCreator(picasso, URI_1, 0).noPlaceholder().into(target) verify(target).onPrepareLoad(null) } @Test fun intoTargetWithNullUriAndResourceIdSkipsAndCancels() { - val target = mockTarget() + val target = mockBitmapTarget() val placeHolderDrawable = mock(Drawable::class.java) RequestCreator(picasso, null, 0).placeholder(placeHolderDrawable).into(target) verify(picasso).defaultBitmapConfig @@ -176,7 +177,7 @@ class RequestCreatorTest { @Test fun intoTargetWithQuickMemoryCacheCheckDoesNotSubmit() { `when`(picasso.quickMemoryCacheCheck(URI_KEY_1)).thenReturn(bitmap) - val target = mockTarget() + val target = mockBitmapTarget() RequestCreator(picasso, URI_1, 0).into(target) verify(target).onBitmapLoaded(bitmap, MEMORY) verify(picasso).cancelRequest(target) @@ -184,13 +185,13 @@ class RequestCreatorTest { } @Test fun intoTargetWithSkipMemoryPolicy() { - val target = mockTarget() + val target = mockBitmapTarget() RequestCreator(picasso, URI_1, 0).memoryPolicy(NO_CACHE).into(target) verify(picasso, never()).quickMemoryCacheCheck(URI_KEY_1) } @Test fun intoTargetAndNotInCacheSubmitsTargetRequest() { - val target = mockTarget() + val target = mockBitmapTarget() val placeHolderDrawable = mock(Drawable::class.java) RequestCreator(picasso, URI_1, 0).placeholder(placeHolderDrawable).into(target) verify(target).onPrepareLoad(placeHolderDrawable) @@ -199,29 +200,78 @@ class RequestCreatorTest { } @Test fun targetActionWithDefaultPriority() { - RequestCreator(picasso, URI_1, 0).into(mockTarget()) + RequestCreator(picasso, URI_1, 0).into(mockBitmapTarget()) verify(picasso).enqueueAndSubmit(actionCaptor.capture()) assertThat(actionCaptor.value.request.priority).isEqualTo(NORMAL) } @Test fun targetActionWithCustomPriority() { - RequestCreator(picasso, URI_1, 0).priority(HIGH).into(mockTarget()) + RequestCreator(picasso, URI_1, 0).priority(HIGH).into(mockBitmapTarget()) verify(picasso).enqueueAndSubmit(actionCaptor.capture()) assertThat(actionCaptor.value.request.priority).isEqualTo(HIGH) } @Test fun targetActionWithDefaultTag() { - RequestCreator(picasso, URI_1, 0).into(mockTarget()) + RequestCreator(picasso, URI_1, 0).into(mockBitmapTarget()) verify(picasso).enqueueAndSubmit(actionCaptor.capture()) assertThat(actionCaptor.value.tag).isEqualTo(actionCaptor.value) } @Test fun targetActionWithCustomTag() { - RequestCreator(picasso, URI_1, 0).tag("tag").into(mockTarget()) + RequestCreator(picasso, URI_1, 0).tag("tag").into(mockBitmapTarget()) verify(picasso).enqueueAndSubmit(actionCaptor.capture()) assertThat(actionCaptor.value.tag).isEqualTo("tag") } + @Test fun intoDrawableTargetWithFitThrows() { + try { + RequestCreator(picasso, URI_1, 0).fit().into(mockDrawableTarget()) + fail("Calling into() drawable target with fit() should throw exception") + } catch (ignored: IllegalStateException) { + } + } + + @Test fun intoDrawableTargetNoPlaceholderCallsWithNull() { + val target = mockDrawableTarget() + RequestCreator(picasso, URI_1, 0).noPlaceholder().into(target) + verify(target).onPrepareLoad(null) + } + + @Test fun intoDrawableTargetWithNullUriAndResourceIdSkipsAndCancels() { + val target = mockDrawableTarget() + val placeHolderDrawable = mock(Drawable::class.java) + RequestCreator(picasso, null, 0).placeholder(placeHolderDrawable).into(target) + verify(picasso).defaultBitmapConfig + verify(picasso).shutdown + verify(picasso).cancelRequest(target) + verify(target).onPrepareLoad(placeHolderDrawable) + verifyNoMoreInteractions(picasso) + } + + @Test fun intoDrawableTargetWithQuickMemoryCacheCheckDoesNotSubmit() { + `when`(picasso.quickMemoryCacheCheck(URI_KEY_1)).thenReturn(bitmap) + val target = mockDrawableTarget() + RequestCreator(picasso, URI_1, 0).into(target) + verify(target).onDrawableLoaded(any(PicassoDrawable::class.java), eq(MEMORY)) + verify(picasso).cancelRequest(target) + verify(picasso, never()).enqueueAndSubmit(any(Action::class.java)) + } + + @Test fun intoDrawableTargetWithSkipMemoryPolicy() { + val target = mockDrawableTarget() + RequestCreator(picasso, URI_1, 0).memoryPolicy(NO_CACHE).into(target) + verify(picasso, never()).quickMemoryCacheCheck(URI_KEY_1) + } + + @Test fun intoDrawableTargetAndNotInCacheSubmitsTargetRequest() { + val target = mockDrawableTarget() + val placeHolderDrawable = mock(Drawable::class.java) + RequestCreator(picasso, URI_1, 0).placeholder(placeHolderDrawable).into(target) + verify(target).onPrepareLoad(placeHolderDrawable) + verify(picasso).enqueueAndSubmit(actionCaptor.capture()) + assertThat(actionCaptor.value).isInstanceOf(DrawableTargetAction::class.java) + } + @Test fun intoImageViewWithNullUriAndResourceIdSkipsAndCancels() { val target = mockImageViewTarget() RequestCreator(picasso, null, 0).into(target) @@ -306,7 +356,7 @@ class RequestCreatorTest { val latch = CountDownLatch(1) Thread { try { - RequestCreator(picasso, null, 0).into(mockTarget()) + RequestCreator(picasso, null, 0).into(mockBitmapTarget()) fail("Should have thrown IllegalStateException") } catch (ignored: IllegalStateException) { } finally { @@ -463,12 +513,12 @@ class RequestCreatorTest { @Test fun intoTargetNoResizeWithCenterInsideOrCenterCropThrows() { try { - RequestCreator(picasso, URI_1, 0).centerInside().into(mockTarget()) + RequestCreator(picasso, URI_1, 0).centerInside().into(mockBitmapTarget()) fail("Center inside with unknown width should throw exception.") } catch (ignored: IllegalStateException) { } try { - RequestCreator(picasso, URI_1, 0).centerCrop().into(mockTarget()) + RequestCreator(picasso, URI_1, 0).centerCrop().into(mockBitmapTarget()) fail("Center inside with unknown height should throw exception.") } catch (ignored: IllegalStateException) { } diff --git a/picasso/src/test/java/com/squareup/picasso3/TestUtils.kt b/picasso/src/test/java/com/squareup/picasso3/TestUtils.kt index 26cd684569..1cad7e3f62 100644 --- a/picasso/src/test/java/com/squareup/picasso3/TestUtils.kt +++ b/picasso/src/test/java/com/squareup/picasso3/TestUtils.kt @@ -145,7 +145,7 @@ internal object TestUtils { picasso: Picasso, key: String, uri: Uri? = null, - target: Any = mockTarget(), + target: Any = mockBitmapTarget(), resourceId: Int = 0, priority: Priority? = null, tag: String? = null, @@ -165,7 +165,7 @@ internal object TestUtils { return mockAction(picasso, request, target) } - fun mockAction(picasso: Picasso, request: Request, target: Any = mockTarget()) = + fun mockAction(picasso: Picasso, request: Request, target: Any = mockBitmapTarget()) = FakeAction(picasso, request, target) fun mockImageViewTarget(): ImageView = mock(ImageView::class.java) @@ -183,7 +183,9 @@ internal object TestUtils { return mock } - fun mockTarget(): BitmapTarget = mock(BitmapTarget::class.java) + fun mockBitmapTarget(): BitmapTarget = mock(BitmapTarget::class.java) + + fun mockDrawableTarget(): DrawableTarget = mock(DrawableTarget::class.java) fun mockCallback(): Callback = mock(Callback::class.java) diff --git a/settings.gradle b/settings.gradle index 687bc5ddc3..1a1c0b607e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,7 @@ rootProject.name = 'picasso-root' include 'picasso' +include 'picasso-compose' include 'picasso-pollexor' include 'picasso-sample' include 'picasso-stats'