Merge remote-tracking branch 'origin/master' into gpeal/compose-dynamic-gradients
diff --git a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt
index d6b8698..886747c 100644
--- a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt
+++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt
@@ -96,7 +96,6 @@
             matrix.preTranslate(translation.x.toFloat(), translation.y.toFloat())
             matrix.preScale(scale.scaleX, scale.scaleY)
 
-
             drawable.composition = composition
             if (dynamicProperties !== setDynamicProperties) {
                 setDynamicProperties?.removeFrom(drawable)
@@ -108,6 +107,7 @@
             drawable.enableMergePathsForKitKatAndAbove(enableMergePaths)
             drawable.useSoftwareRendering(useSoftwareRendering)
             drawable.progress = progress
+            drawable.setBounds(0, 0, composition.bounds.width(), composition.bounds.height())
             drawable.draw(canvas.nativeCanvas, matrix)
         }
     }
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
index fc7ee19..5882ef2 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
@@ -14,6 +14,7 @@
 import android.graphics.PorterDuff;
 import android.graphics.PorterDuffXfermode;
 import android.graphics.Rect;
+import android.graphics.RectF;
 import android.graphics.Typeface;
 import android.graphics.drawable.Animatable;
 import android.graphics.drawable.Drawable;
@@ -62,7 +63,6 @@
     void run(LottieComposition composition);
   }
 
-  private final Matrix matrix = new Matrix();
   private LottieComposition composition;
   private final LottieValueAnimator animator = new LottieValueAnimator();
   private float scale = 1f;
@@ -106,12 +106,18 @@
   private boolean outlineMasksAndMattes;
   private boolean isApplyingOpacityToLayersEnabled;
 
+  private final Matrix renderingMatrix = new Matrix();
   private boolean softwareRenderingEnabled = false;
   private Bitmap softwareRenderingBitmap;
   private final LPaint softwareRenderingClearPaint = new LPaint();
   private final Canvas softwareRenderingCanvas = new Canvas();
   private Paint softwareRenderingPaint;
-  private final Rect softwareRenderingBoundsRect = new Rect();
+  private Rect softwareRenderingSrcBoundsRect;
+  private Rect softwareRenderingDstBoundsRect;
+  private RectF softwareRenderingDstBoundsRectF;
+  private RectF softwareRenderingTransformedBounds;
+  private Matrix softwareRenderingOriginalCanvasMatrix;
+  private Matrix softwareRenderingOriginalCanvasMatrixInverse;
 
   /**
    * True if the drawable has not been drawn since the last invalidateSelf.
@@ -223,7 +229,7 @@
       return false;
     }
 
-    isDirty = false;
+    isDirty = true;
     clearComposition();
     this.composition = composition;
     buildCompositionLayer();
@@ -1139,10 +1145,16 @@
   @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
   public void draw(Canvas canvas, Matrix matrix) {
     CompositionLayer compositionLayer = this.compositionLayer;
-    if (compositionLayer == null) {
+    LottieComposition composition = this.composition;
+    if (compositionLayer == null || composition == null) {
       return;
     }
-    compositionLayer.draw(canvas, matrix, alpha);
+
+    if (softwareRenderingEnabled) {
+      renderAndDrawAsBitmap(canvas, compositionLayer, matrix);
+    } else {
+      compositionLayer.draw(canvas, matrix, alpha);
+    }
   }
 
   private void drawWithNewAspectRatio(Canvas canvas) {
@@ -1152,35 +1164,34 @@
       return;
     }
 
-    Rect bounds = getBounds();
-    // In fitXY mode, the scale doesn't take effect.
-    float scaleX = bounds.width() / (float) composition.getBounds().width();
-    float scaleY = bounds.height() / (float) composition.getBounds().height();
-
-    matrix.reset();
-    matrix.preScale(scaleX, scaleY);
     if (softwareRenderingEnabled) {
-      renderAndDrawAsBitmap(canvas, compositionLayer, matrix);
+      renderAndDrawAsBitmap(canvas, compositionLayer, null);
     } else {
-      compositionLayer.draw(canvas, matrix, alpha);
+      Rect bounds = getBounds();
+      // In fitXY mode, the scale doesn't take effect.
+      float scaleX = bounds.width() / (float) composition.getBounds().width();
+      float scaleY = bounds.height() / (float) composition.getBounds().height();
+
+      renderingMatrix.reset();
+      renderingMatrix.preScale(scaleX, scaleY);
+      compositionLayer.draw(canvas, renderingMatrix, alpha);
     }
   }
 
   private void drawWithOriginalAspectRatio(Canvas canvas) {
     CompositionLayer compositionLayer = this.compositionLayer;
     LottieComposition composition = this.composition;
+    float scale = this.scale;
     if (compositionLayer == null || composition == null) {
       return;
     }
 
-    float scale = this.scale;
-
-    matrix.reset();
-    matrix.preScale(scale, scale);
     if (softwareRenderingEnabled) {
-      renderAndDrawAsBitmap(canvas, compositionLayer, matrix);
+      renderAndDrawAsBitmap(canvas, compositionLayer, null);
     } else {
-      compositionLayer.draw(canvas, matrix, alpha);
+      renderingMatrix.reset();
+      renderingMatrix.preScale(scale, scale);
+      compositionLayer.draw(canvas, renderingMatrix, alpha);
     }
   }
 
@@ -1191,33 +1202,95 @@
    * @see LottieDrawable#useSoftwareRendering(boolean)
    * @see LottieAnimationView#setRenderMode(RenderMode)
    */
-  private void renderAndDrawAsBitmap(Canvas originalCanvas, CompositionLayer compositionLayer, Matrix matrix) {
-    if (softwareRenderingPaint == null) {
-      softwareRenderingPaint = new LPaint();
+  private void renderAndDrawAsBitmap(Canvas originalCanvas, CompositionLayer compositionLayer, @Nullable Matrix parentMatrix) {
+    ensureSoftwareRenderingObjectsInitialized();
+
+    //noinspection deprecation
+    originalCanvas.getMatrix(softwareRenderingOriginalCanvasMatrix);
+    softwareRenderingOriginalCanvasMatrix.invert(softwareRenderingOriginalCanvasMatrixInverse);
+    renderingMatrix.set(softwareRenderingOriginalCanvasMatrix);
+    if (parentMatrix != null) {
+      renderingMatrix.postConcat(parentMatrix);
     }
 
-    int intrinsicWidth = getIntrinsicWidth();
-    int intrinsicHeight = getIntrinsicHeight();
+    // Determine what bounds the animation will render to after taking into account the canvas and parent matrix.
+    softwareRenderingTransformedBounds.set(0f, 0f, getIntrinsicWidth(), getIntrinsicHeight());
+    renderingMatrix.mapRect(softwareRenderingTransformedBounds);
 
-    if (softwareRenderingBitmap == null ||
-        softwareRenderingBitmap.getWidth() < intrinsicWidth ||
-        softwareRenderingBitmap.getHeight() < intrinsicHeight) {
-      softwareRenderingBitmap = Bitmap.createBitmap(intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888);
-      softwareRenderingCanvas.setBitmap(softwareRenderingBitmap);
-      softwareRenderingClearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
-      softwareRenderingClearPaint.setColor(Color.BLACK);
+    // We only need to render the portion of the animation that intersects with the canvas's bounds.
+    softwareRenderingTransformedBounds.intersect(0f, 0f, originalCanvas.getWidth(), originalCanvas.getHeight());
+
+    int renderWidth = (int) Math.ceil(softwareRenderingTransformedBounds.width());
+    int renderHeight = (int) Math.ceil(softwareRenderingTransformedBounds.height());
+
+    if (renderWidth == 0 || renderHeight == 0) {
+      return;
     }
 
+    ensureSoftwareRenderingBitmap(renderWidth, renderHeight);
+
+    softwareRenderingSrcBoundsRect.set(0, 0, renderWidth, renderHeight);
+
     if (isDirty) {
-      if (softwareRenderingBitmap.getWidth() == intrinsicWidth && softwareRenderingBitmap.getHeight() == intrinsicHeight) {
-        // eraseColor is ~10% faster than drawRect when covering the entire bitmap.
-        softwareRenderingBitmap.eraseColor(0);
-      } else {
-        softwareRenderingCanvas.drawRect(0f, 0f, intrinsicWidth, intrinsicHeight, softwareRenderingClearPaint);
-      }
-      compositionLayer.draw(softwareRenderingCanvas, matrix, alpha);
-      softwareRenderingBoundsRect.set(0, 0, intrinsicWidth, intrinsicHeight);
+      softwareRenderingBitmap.eraseColor(0);
+      renderingMatrix.preScale(scale, scale);
+      // The bounds are usually intrinsicWidth x intrinsicHeight. If they are different, an external source is scaling this drawable.
+      // This is how ImageView.ScaleType.FIT_XY works.
+      renderingMatrix.preScale(getBounds().width() / (float) getIntrinsicWidth(), getBounds().height() / (float) getIntrinsicHeight());
+      // We want to render the smallest bitmap possible. If the animation doesn't start at the top left, we translate the canvas and shrink the
+      // bitmap to avoid allocating and copying the empty space on the left and top. renderWidth and renderHeight take this into account.
+      renderingMatrix.postTranslate(-softwareRenderingTransformedBounds.left, -softwareRenderingTransformedBounds.top);
+      compositionLayer.draw(softwareRenderingCanvas, renderingMatrix, alpha);
+
+      // Calculate the dst bounds.
+      // We need to map the rendered coordinates back to the canvas's coordinates. To do so, we need to invert the transform
+      // of the original canvas.
+      // Take the bounds of the rendered animation and map them to the canvas's coordinates.
+      // This is similar to the src rect above but the src bound may have a left and top offset.
+      softwareRenderingOriginalCanvasMatrixInverse.mapRect(softwareRenderingDstBoundsRectF, softwareRenderingTransformedBounds);
+      convertRect(softwareRenderingDstBoundsRectF, softwareRenderingDstBoundsRect);
     }
-    originalCanvas.drawBitmap(softwareRenderingBitmap, softwareRenderingBoundsRect, softwareRenderingBoundsRect, softwareRenderingPaint);
+    originalCanvas.drawBitmap(softwareRenderingBitmap, softwareRenderingSrcBoundsRect, softwareRenderingDstBoundsRect, softwareRenderingPaint);
+  }
+
+  private void ensureSoftwareRenderingObjectsInitialized() {
+    if (softwareRenderingPaint != null) {
+      return;
+    }
+    softwareRenderingPaint = new LPaint();
+    softwareRenderingClearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
+    softwareRenderingClearPaint.setColor(Color.BLACK);
+    softwareRenderingSrcBoundsRect = new Rect();
+    softwareRenderingDstBoundsRect = new Rect();
+    softwareRenderingDstBoundsRectF = new RectF();
+    softwareRenderingTransformedBounds = new RectF();
+    softwareRenderingOriginalCanvasMatrix = new Matrix();
+    softwareRenderingOriginalCanvasMatrixInverse = new Matrix();
+  }
+
+  private void ensureSoftwareRenderingBitmap(int renderWidth, int renderHeight) {
+    if (softwareRenderingBitmap == null ||
+        softwareRenderingBitmap.getWidth() < renderWidth ||
+        softwareRenderingBitmap.getHeight() < renderHeight) {
+      softwareRenderingBitmap = Bitmap.createBitmap(renderWidth, renderHeight, Bitmap.Config.ARGB_8888);
+      softwareRenderingCanvas.setBitmap(softwareRenderingBitmap);
+      isDirty = true;
+    } else if (softwareRenderingBitmap.getWidth() > renderWidth || softwareRenderingBitmap.getHeight() > renderHeight) {
+      softwareRenderingBitmap = Bitmap.createBitmap(softwareRenderingBitmap, 0, 0, renderWidth, renderHeight);
+      softwareRenderingCanvas.setBitmap(softwareRenderingBitmap);
+      isDirty = true;
+    }
+  }
+
+  /**
+   * Convert a RectF to a Rect
+   */
+  private void convertRect(RectF src, Rect dst) {
+    dst.set(
+        (int) Math.floor(src.left),
+        (int) Math.floor(src.top),
+        (int) Math.ceil(src.right),
+        (int) Math.ceil(src.bottom)
+    );
   }
 }
diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java
index 8c1f315..14b1c66 100644
--- a/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java
+++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java
@@ -77,12 +77,14 @@
 
   private final Path path = new Path();
   private final Matrix matrix = new Matrix();
+  private final Matrix canvasMatrix = new Matrix();
   private final Paint contentPaint = new LPaint(Paint.ANTI_ALIAS_FLAG);
   private final Paint dstInPaint = new LPaint(Paint.ANTI_ALIAS_FLAG, PorterDuff.Mode.DST_IN);
   private final Paint dstOutPaint = new LPaint(Paint.ANTI_ALIAS_FLAG, PorterDuff.Mode.DST_OUT);
   private final Paint mattePaint = new LPaint(Paint.ANTI_ALIAS_FLAG);
   private final Paint clearPaint = new LPaint(PorterDuff.Mode.CLEAR);
   private final RectF rect = new RectF();
+  private final RectF canvasBounds = new RectF();
   private final RectF maskBoundsRect = new RectF();
   private final RectF matteBoundsRect = new RectF();
   private final RectF tempMaskBoundsRect = new RectF();
@@ -259,7 +261,17 @@
     matrix.preConcat(transform.getMatrix());
     intersectBoundsWithMask(rect, matrix);
 
-    if (!rect.intersect(0, 0, canvas.getWidth(), canvas.getHeight())) {
+    // Intersect the mask and matte rect with the canvas bounds.
+    // If the canvas has a transform, then we need to transform its bounds by its matrix
+    // so that we know the coordinate space that the canvas is showing.
+    canvasBounds.set(0f, 0f, canvas.getWidth(), canvas.getHeight());
+    //noinspection deprecation
+    canvas.getMatrix(canvasMatrix);
+    if (!canvasMatrix.isIdentity()) {
+      canvasMatrix.invert(canvasMatrix);
+      canvasMatrix.mapRect(canvasBounds);
+    }
+    if (!rect.intersect(canvasBounds)) {
       rect.set(0, 0, 0, 0);
     }
 
diff --git a/snapshot-tests/build.gradle b/snapshot-tests/build.gradle
index 243105b..ee8ed56 100644
--- a/snapshot-tests/build.gradle
+++ b/snapshot-tests/build.gradle
@@ -65,6 +65,7 @@
   implementation 'androidx.appcompat:appcompat:1.4.0-beta01'
   implementation "androidx.compose.ui:ui:$composeVersion"
   implementation "androidx.compose.ui:ui-tooling:$composeVersion"
+  implementation "androidx.compose.material:material:$composeVersion"
 
   implementation 'com.squareup.okhttp3:okhttp:4.9.1'
 
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/LottieSnapshotTest.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/LottieSnapshotTest.kt
index 0acb9a0..6df7cd4 100644
--- a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/LottieSnapshotTest.kt
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/LottieSnapshotTest.kt
@@ -17,11 +17,11 @@
 import com.airbnb.lottie.snapshots.tests.ApplyOpacityToLayerTestCase
 import com.airbnb.lottie.snapshots.tests.AssetsTestCase
 import com.airbnb.lottie.snapshots.tests.ColorStateListColorFilterTestCase
-import com.airbnb.lottie.snapshots.tests.ComposeDynamicPropertiesTestCase
 import com.airbnb.lottie.snapshots.tests.CustomBoundsTestCase
 import com.airbnb.lottie.snapshots.tests.DynamicPropertiesTestCase
 import com.airbnb.lottie.snapshots.tests.FailureTestCase
 import com.airbnb.lottie.snapshots.tests.FrameBoundariesTestCase
+import com.airbnb.lottie.snapshots.tests.LargeCompositionSoftwareRendering
 import com.airbnb.lottie.snapshots.tests.MarkersTestCase
 import com.airbnb.lottie.snapshots.tests.NightModeTestCase
 import com.airbnb.lottie.snapshots.tests.OutlineMasksAndMattesTestCase
@@ -56,22 +56,21 @@
         Manifest.permission.READ_EXTERNAL_STORAGE
     )
 
+    lateinit var testCaseContext: SnapshotTestCaseContext
+    lateinit var snapshotter: HappoSnapshotter
+
     @Before
     fun setup() {
         LottieCompositionCache.getInstance().resize(1)
-    }
-
-    @Test
-    fun testAll() = runBlocking {
         val context = ApplicationProvider.getApplicationContext<Context>()
-        val snapshotter = HappoSnapshotter(context) { name, variant ->
+        snapshotter = HappoSnapshotter(context) { name, variant ->
             snapshotActivityRule.scenario.onActivity { activity ->
                 activity.updateUiForSnapshot(name, variant)
             }
         }
-        val testCaseContext: SnapshotTestCaseContext = object : SnapshotTestCaseContext {
+        testCaseContext = object : SnapshotTestCaseContext {
             override val context: Context = context
-            override val snapshotter: HappoSnapshotter = snapshotter
+            override val snapshotter: HappoSnapshotter = this@LottieSnapshotTest.snapshotter
             override val bitmapPool: BitmapPool = BitmapPool()
             override val animationViewPool: ObjectPool<LottieAnimationView> = ObjectPool {
                 val animationViewContainer = FrameLayout(context)
@@ -80,9 +79,7 @@
                 }
             }
             override val filmStripViewPool: ObjectPool<FilmStripView> = ObjectPool {
-                FilmStripView(context).apply {
-                    setLayerType(View.LAYER_TYPE_NONE, null)
-                }
+                FilmStripView(context)
             }
 
             override fun onActivity(callback: (SnapshotTestActivity) -> Unit) {
@@ -103,7 +100,10 @@
 
             })
         }
-        val prodAnimations = ProdAnimationsTestCase()
+    }
+
+    @Test
+    fun testAll() = runBlocking {
         val testCases = listOf(
             CustomBoundsTestCase(),
             ColorStateListColorFilterTestCase(),
@@ -118,13 +118,13 @@
             NightModeTestCase(),
             ApplyOpacityToLayerTestCase(),
             OutlineMasksAndMattesTestCase(),
-//            ComposeDynamicPropertiesTestCase(),
-            prodAnimations,
+            LargeCompositionSoftwareRendering(),
+            ProdAnimationsTestCase(),
         )
 
         withTimeout(TimeUnit.MINUTES.toMillis(45)) {
             launch {
-                with(prodAnimations) {
+                with(testCases.filterIsInstance<ProdAnimationsTestCase>().firstOrNull() ?: return@launch) {
                     // Kick off the downloads ahead of time so it can start while the other tests are snapshotting
                     testCaseContext.downloadAnimations()
                 }
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/SnapshotTestCaseContext.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/SnapshotTestCaseContext.kt
index 5e38ce0..f254de1 100644
--- a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/SnapshotTestCaseContext.kt
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/SnapshotTestCaseContext.kt
@@ -1,8 +1,11 @@
 package com.airbnb.lottie.snapshots
 
 import android.content.Context
+import android.graphics.Bitmap
 import android.graphics.BitmapFactory
 import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.PorterDuff
 import android.util.Log
 import android.view.View
 import android.view.ViewGroup
@@ -11,6 +14,9 @@
 import android.widget.LinearLayout
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.platform.ComposeView
+import androidx.core.view.doOnAttach
+import androidx.core.view.doOnLayout
+import androidx.core.view.doOnPreDraw
 import com.airbnb.lottie.FontAssetDelegate
 import com.airbnb.lottie.LottieAnimationView
 import com.airbnb.lottie.LottieComposition
@@ -22,8 +28,10 @@
 import com.airbnb.lottie.snapshots.utils.ObjectPool
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.android.awaitFrame
+import kotlinx.coroutines.suspendCancellableCoroutine
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.withContext
+import kotlin.coroutines.resume
 
 private val ActivityContentLock = Mutex()
 
@@ -68,6 +76,8 @@
     assetName: String,
     snapshotName: String = assetName,
     snapshotVariant: String = "default",
+    widthPx: Int = context.resources.displayMetrics.widthPixels,
+    heightPx: Int = context.resources.displayMetrics.heightPixels,
     callback: (LottieAnimationView) -> Unit,
 ) {
     val result = LottieCompositionFactory.fromAssetSync(context, assetName)
@@ -78,16 +88,15 @@
     animationView.scale = 1f
     animationView.scaleType = ImageView.ScaleType.FIT_CENTER
     callback(animationView)
-    val widthSpec = View.MeasureSpec.makeMeasureSpec(
-        context.resources.displayMetrics
-            .widthPixels,
-        View.MeasureSpec.EXACTLY
-    )
-    val heightSpec = View.MeasureSpec.makeMeasureSpec(
-        context.resources.displayMetrics
-            .heightPixels, View.MeasureSpec.EXACTLY
-    )
     val animationViewContainer = animationView.parent as ViewGroup
+    val widthSpec = View.MeasureSpec.makeMeasureSpec(
+        widthPx,
+        View.MeasureSpec.EXACTLY,
+    )
+    val heightSpec: Int = View.MeasureSpec.makeMeasureSpec(
+        heightPx,
+        View.MeasureSpec.EXACTLY,
+    )
     animationViewContainer.measure(widthSpec, heightSpec)
     animationViewContainer.layout(0, 0, animationViewContainer.measuredWidth, animationViewContainer.measuredHeight)
     val bitmap = bitmapPool.acquire(animationView.width, animationView.height)
@@ -133,6 +142,7 @@
     val bitmap = bitmapPool.acquire(filmStripView.width, filmStripView.height)
     val canvas = Canvas(bitmap)
     filmStripView.setComposition(composition, name)
+    canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR)
     withContext(Dispatchers.Main) {
         log("Drawing $name")
         filmStripView.draw(canvas)
@@ -150,17 +160,21 @@
 ) = withContext(Dispatchers.Default) {
     log("Snapshotting $name")
     val composeView = ComposeView(context)
-    composeView.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT)
-    onActivity { activity ->
-        activity.binding.content.addView(composeView)
-    }
-    awaitFrame()
-    composeView.setContent(content)
-    val bitmap = bitmapPool.acquire(composeView.width, composeView.height)
-    val canvas = Canvas(bitmap)
-    withContext(Dispatchers.Main) {
-        log("Drawing $name")
-        composeView.draw(canvas)
+    composeView.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)
+    val bitmap = withContext(Dispatchers.Main) {
+        composeView.setContent(content)
+        suspendCancellableCoroutine<Bitmap> { cont ->
+            composeView.doOnLayout {
+                log("Drawing $name")
+                val bitmap = bitmapPool.acquire(composeView.width, composeView.height)
+                val canvas = Canvas(bitmap)
+                composeView.draw(canvas)
+                cont.resume(bitmap)
+            }
+            onActivity { activity ->
+                activity.binding.content.addView(composeView)
+            }
+        }
     }
     onActivity { activity ->
         activity.binding.content.removeView(composeView)
@@ -168,4 +182,4 @@
     LottieCompositionCache.getInstance().clear()
     snapshotter.record(bitmap, name, variant)
     bitmapPool.release(bitmap)
-}
+}
\ No newline at end of file
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/LargeCompositionSoftwareRendering.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/LargeCompositionSoftwareRendering.kt
new file mode 100644
index 0000000..cdce72f
--- /dev/null
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/LargeCompositionSoftwareRendering.kt
@@ -0,0 +1,106 @@
+package com.airbnb.lottie.snapshots.tests
+
+import android.graphics.Matrix
+import android.widget.ImageView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import com.airbnb.lottie.LottieAnimationView
+import com.airbnb.lottie.LottieComposition
+import com.airbnb.lottie.LottieCompositionFactory
+import com.airbnb.lottie.compose.LottieAnimation
+import com.airbnb.lottie.compose.LottieCompositionSpec
+import com.airbnb.lottie.compose.rememberLottieComposition
+import com.airbnb.lottie.snapshots.SnapshotTestCase
+import com.airbnb.lottie.snapshots.SnapshotTestCaseContext
+import com.airbnb.lottie.snapshots.snapshotComposable
+import com.airbnb.lottie.snapshots.withAnimationView
+
+class LargeCompositionSoftwareRendering : SnapshotTestCase {
+    override suspend fun SnapshotTestCaseContext.run() {
+        snapshotWithImageView("Default") {}
+        snapshotWithImageView("CenterCrop") { av ->
+            av.scaleType = ImageView.ScaleType.CENTER_CROP
+        }
+        snapshotWithImageView("Center") { av ->
+            av.scaleType = ImageView.ScaleType.CENTER
+        }
+        snapshotWithImageView("Center With Scale") { av ->
+            av.scaleType = ImageView.ScaleType.CENTER
+            av.scale = 0.08f
+        }
+        snapshotWithImageView("CenterInside") { av ->
+            av.scaleType = ImageView.ScaleType.CENTER_INSIDE
+        }
+        snapshotWithImageView("FitCenter") { av ->
+            av.scaleType = ImageView.ScaleType.FIT_CENTER
+        }
+        snapshotWithImageView("FitStart") { av ->
+            av.scaleType = ImageView.ScaleType.FIT_START
+        }
+        snapshotWithImageView("FitEnd") { av ->
+            av.scaleType = ImageView.ScaleType.FIT_END
+        }
+        snapshotWithImageView("FitXY") { av ->
+            av.scaleType = ImageView.ScaleType.FIT_XY
+        }
+        snapshotWithImageView("Matrix With Sew") { av ->
+            av.scaleType = ImageView.ScaleType.MATRIX
+            av.imageMatrix = Matrix().apply {
+                preScale(0.025f, 0.025f)
+                preSkew(1f, 0f)
+            }
+        }
+
+        snapshotWithComposable("Fit") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.Fit)
+        }
+        snapshotWithComposable("Crop") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.Crop)
+        }
+        snapshotWithComposable("FillBounds") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.FillBounds)
+        }
+        snapshotWithComposable("FillWidth") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.FillWidth)
+        }
+        snapshotWithComposable("FillHeight") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.FillHeight)
+        }
+        snapshotWithComposable("Inside") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.Inside)
+        }
+        snapshotWithComposable("None") { comp ->
+            LottieAnimation(comp, progress = 0f, contentScale = ContentScale.None)
+        }
+    }
+
+    private suspend fun SnapshotTestCaseContext.snapshotWithImageView(snapshotVariant: String, callback: (LottieAnimationView) -> Unit) {
+        withAnimationView("Tests/LargeComposition.json", "Large Composition Tests", snapshotVariant, widthPx = 275, heightPx = 275) { av ->
+            av.setBackgroundColor(0x7f7f7f7f)
+            callback(av)
+        }
+    }
+
+    private suspend fun SnapshotTestCaseContext.snapshotWithComposable(
+        snapshotVariant: String,
+        callback: @Composable (composition: LottieComposition) -> Unit
+    ) {
+        val composition = LottieCompositionFactory.fromAssetSync(context, "Tests/LargeComposition.json").value!!
+        snapshotComposable("Large Composition Tests - Compose", snapshotVariant) {
+            Box(
+                modifier = Modifier
+                    .size(100.dp)
+                    .background(Color.Gray)
+            ) {
+                callback(composition)
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/BitmapPool.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/BitmapPool.kt
index 153687a..aae3c24 100644
--- a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/BitmapPool.kt
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/BitmapPool.kt
@@ -15,17 +15,13 @@
     private val semaphore = SuspendingSemaphore(MAX_RELEASED_BITMAPS)
     private val bitmaps = Collections.synchronizedList(ArrayList<Bitmap>())
     private val releasedBitmaps = ConcurrentHashMap<Bitmap, Bitmap>()
-    private val clearPaint by lazy {
-        Paint().apply {
-            xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
-        }
-    }
 
+    @Synchronized
     fun clear() {
         bitmaps.clear()
     }
 
-    @ExperimentalCoroutinesApi
+    @Synchronized
     fun acquire(width: Int, height: Int): Bitmap {
         if (width <= 0 || height <= 0) {
             return TRANSPARENT_1X1_BITMAP
@@ -45,25 +41,22 @@
             bitmaps
                 .firstOrNull { it.width >= width && it.height >= height }
                 ?.also { bitmaps.remove(it) }
-                ?.also { it.eraseColor(0) }
         } ?: createNewBitmap(width, height)
 
         val croppedBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height)
         releasedBitmaps[croppedBitmap] = bitmap
 
-        Canvas(croppedBitmap).apply {
-            drawRect(0f, 0f, croppedBitmap.width.toFloat(), croppedBitmap.height.toFloat(), clearPaint)
-        }
-
         return croppedBitmap
     }
 
+    @Synchronized
     fun release(bitmap: Bitmap) {
         if (bitmap == TRANSPARENT_1X1_BITMAP) {
             return
         }
 
         val originalBitmap = releasedBitmaps.remove(bitmap) ?: throw IllegalArgumentException("Unable to find original bitmap.")
+        originalBitmap.eraseColor(0)
 
         bitmaps += originalBitmap
         semaphore.release()
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/ObjectPool.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/ObjectPool.kt
index b4c430a..16f8f36 100644
--- a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/ObjectPool.kt
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/ObjectPool.kt
@@ -12,7 +12,6 @@
     private val objects = Collections.synchronizedList(ArrayList<T>())
     private val releasedObjects = HashSet<T>()
 
-    @ExperimentalCoroutinesApi
     @Synchronized
     fun acquire(): T {
         val blockedStartTime = System.currentTimeMillis()
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/Utils.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/Utils.kt
index d2e1baf..5501510 100644
--- a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/Utils.kt
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/utils/Utils.kt
@@ -7,27 +7,31 @@
 import com.amazonaws.mobileconnectors.s3.transferutility.TransferState
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.suspendCancellableCoroutine
 import kotlin.coroutines.resume
 import kotlin.coroutines.resumeWithException
 import kotlin.coroutines.suspendCoroutine
 
-suspend fun TransferObserver.await() = suspendCoroutine<TransferObserver> { continuation ->
+suspend fun TransferObserver.await() = suspendCancellableCoroutine<TransferObserver> { continuation ->
     val listener = object : TransferListener {
         override fun onProgressChanged(id: Int, bytesCurrent: Long, bytesTotal: Long) {}
 
         override fun onError(id: Int, ex: Exception) {
             Log.e(L.TAG, "$id failed uploading!", ex)
             continuation.resumeWithException(ex)
+            cleanTransferListener()
         }
 
         override fun onStateChanged(id: Int, state: TransferState) {
             when (state) {
                 TransferState.COMPLETED -> {
                     continuation.resume(this@await)
+                    cleanTransferListener()
                 }
                 TransferState.CANCELED, TransferState.FAILED -> {
                     Log.d(L.TAG, "$id failed uploading ($state).")
                     continuation.resume(this@await)
+                    cleanTransferListener()
                 }
                 else -> Unit
             }
diff --git a/snapshot-tests/src/main/assets/Tests/LargeComposition.json b/snapshot-tests/src/main/assets/Tests/LargeComposition.json
new file mode 100644
index 0000000..6c07daa
--- /dev/null
+++ b/snapshot-tests/src/main/assets/Tests/LargeComposition.json
@@ -0,0 +1 @@
+{"v":"5.8.1","fr":25,"ip":0,"op":2,"w":2000,"h":4000,"nm":"Comp 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1000,2000,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[1554.562,597.062],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.220496237278,1,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1.281,-21.469],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[1780.188,3801.75],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.116727701823,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[2.094,12.875],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":1,"nm":"Red Solid 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1000,2000,0],"ix":2,"l":2},"a":{"a":0,"k":[1000,2000,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"sw":2000,"sh":4000,"sc":"#ff0000","ip":0,"op":2,"st":0,"bm":0}],"markers":[]}
\ No newline at end of file
diff --git a/snapshot-tests/src/main/res/layout/film_strip_view.xml b/snapshot-tests/src/main/res/layout/film_strip_view.xml
index 1705fbf..26ebc8e 100644
--- a/snapshot-tests/src/main/res/layout/film_strip_view.xml
+++ b/snapshot-tests/src/main/res/layout/film_strip_view.xml
@@ -1,5 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <GridLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
     android:id="@+id/grid_layout"
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"
@@ -9,45 +10,54 @@
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_1"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="software" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_2"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="software" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_3"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="software" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_4"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="software" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_5"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="software" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_6"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="software" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_7"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="hardware" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_8"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="hardware" />
 
     <com.airbnb.lottie.snapshots.NoCacheLottieAnimationView
         android:id="@+id/animation_9"
         android:layout_width="@dimen/film_strip_size"
-        android:layout_height="@dimen/film_strip_size" />
+        android:layout_height="@dimen/film_strip_size"
+        app:lottie_renderMode="hardware" />
 </GridLayout>
diff --git a/snapshot-tests/src/main/res/layout/snapshot_tests_activity.xml b/snapshot-tests/src/main/res/layout/snapshot_tests_activity.xml
index 05bdd73..93421bf 100644
--- a/snapshot-tests/src/main/res/layout/snapshot_tests_activity.xml
+++ b/snapshot-tests/src/main/res/layout/snapshot_tests_activity.xml
@@ -3,17 +3,14 @@
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    android:orientation="vertical">
+    android:orientation="vertical"
+    android:padding="24dp">
 
-    <TextView
-        android:id="@+id/statusTextView"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:paddingEnd="24dp"
-        android:paddingStart="24dp"
-        android:paddingTop="24dp"
-        android:textSize="10sp"
-        tools:text="com.airbnb.android" />
+    <FrameLayout
+        android:id="@+id/content"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1" />
 
     <TextView
         android:id="@+id/counterTextView"
@@ -25,9 +22,13 @@
         android:text="0"
         android:textSize="10sp" />
 
-    <FrameLayout
-        android:id="@+id/content"
-        android:layout_width="match_parent"
-        android:layout_height="0dp"
-        android:layout_weight="1" />
+    <TextView
+        android:id="@+id/statusTextView"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:paddingEnd="24dp"
+        android:paddingStart="24dp"
+        android:paddingTop="24dp"
+        android:textSize="10sp"
+        tools:text="com.airbnb.android" />
 </LinearLayout>
\ No newline at end of file