Allow software rendering to be invalidated when dynamic properties change (#2034)

The test case failed to re-render before but works now.
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
index 37c83a5..53d05ae 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
@@ -236,6 +236,19 @@
     super.unscheduleDrawable(who);
   }
 
+  @Override public void invalidate() {
+    super.invalidate();
+    Drawable d = getDrawable();
+    if (d instanceof LottieDrawable && ((LottieDrawable) d).getRenderMode() == RenderMode.SOFTWARE) {
+      // This normally isn't needed. However, when using software rendering, Lottie caches rendered bitmaps
+      // and updates it when the animation changes internally.
+      // If you have dynamic properties with a value callback and want to update the value of the dynamic property, you need a way
+      // to tell Lottie that the bitmap is dirty and it needs to be re-rendered. Normal drawables always re-draw the actual shapes
+      // so this isn't an issue but for this path, we have to take the extra step of setting the dirty flag.
+      lottieDrawable.invalidateSelf();
+    }
+  }
+
   @Override public void invalidateDrawable(@NonNull Drawable dr) {
     if (getDrawable() == lottieDrawable) {
       // We always want to invalidate the root drawable so it redraws the whole drawable.
diff --git a/lottie/src/main/java/com/airbnb/lottie/value/LottieValueCallback.java b/lottie/src/main/java/com/airbnb/lottie/value/LottieValueCallback.java
index 201c3bc..1cf5d20 100644
--- a/lottie/src/main/java/com/airbnb/lottie/value/LottieValueCallback.java
+++ b/lottie/src/main/java/com/airbnb/lottie/value/LottieValueCallback.java
@@ -4,11 +4,23 @@
 import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 
+import com.airbnb.lottie.LottieAnimationView;
+import com.airbnb.lottie.LottieDrawable;
 import com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation;
 
 /**
  * Allows you to set a callback on a resolved {@link com.airbnb.lottie.model.KeyPath} to modify
  * its animation values at runtime.
+ *
+ * If your dynamic property does the following, you must call {@link LottieAnimationView#invalidate()} or
+ * {@link LottieDrawable#invalidateSelf()} each time you want to update this value.
+ * 1. Use {@link com.airbnb.lottie.RenderMode.SOFTWARE}
+ * 2. Rendering a static image (the animation is either paused or there are no values
+ *    changing within the animation itself)
+ * When using software rendering, Lottie caches the internal rendering bitmap. Whenever the animation changes
+ * internally, Lottie knows to invalidate the bitmap and re-render it on the next frame. If the animation
+ * never changes but your dynamic property does outside of Lottie, Lottie must be notified that it changed
+ * in order to set the bitmap as dirty and re-render it on the next frame.
  */
 public class LottieValueCallback<T> {
   private final LottieFrameInfo<T> frameInfo = new LottieFrameInfo<>();
@@ -31,6 +43,9 @@
    * Override this if you haven't set a static value in the constructor or with setValue.
    * <p>
    * Return null to resort to the default value.
+   *
+   * Refer to the javadoc for this class for a special case that requires manual invalidation
+   * each time you want to return something different from this method.
    */
   @Nullable
   public T getValue(LottieFrameInfo<T> frameInfo) {
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 0ef1f39..879b65a 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
@@ -30,6 +30,7 @@
 import com.airbnb.lottie.snapshots.tests.PartialFrameProgressTestCase
 import com.airbnb.lottie.snapshots.tests.ProdAnimationsTestCase
 import com.airbnb.lottie.snapshots.tests.ScaleTypesTestCase
+import com.airbnb.lottie.snapshots.tests.SoftwareRenderingDynamicPropertiesInvalidationTestCase
 import com.airbnb.lottie.snapshots.tests.TextTestCase
 import com.airbnb.lottie.snapshots.utils.BitmapPool
 import com.airbnb.lottie.snapshots.utils.HappoSnapshotter
@@ -125,6 +126,7 @@
             ComposeDynamicPropertiesTestCase(),
             ProdAnimationsTestCase(),
             ClipChildrenTestCase(),
+            SoftwareRenderingDynamicPropertiesInvalidationTestCase(),
         )
 
         withTimeout(TimeUnit.MINUTES.toMillis(45)) {
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/SoftwareRenderingDynamicPropertiesInvalidationTestCase.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/SoftwareRenderingDynamicPropertiesInvalidationTestCase.kt
new file mode 100644
index 0000000..a858bd6
--- /dev/null
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/SoftwareRenderingDynamicPropertiesInvalidationTestCase.kt
@@ -0,0 +1,68 @@
+package com.airbnb.lottie.snapshots.tests
+
+import android.graphics.Canvas
+import android.graphics.Color
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.ImageView
+import com.airbnb.lottie.LottieCompositionFactory
+import com.airbnb.lottie.LottieProperty
+import com.airbnb.lottie.RenderMode
+import com.airbnb.lottie.model.KeyPath
+import com.airbnb.lottie.snapshots.R
+import com.airbnb.lottie.snapshots.SnapshotTestCase
+import com.airbnb.lottie.snapshots.SnapshotTestCaseContext
+import com.airbnb.lottie.value.LottieFrameInfo
+import com.airbnb.lottie.value.LottieValueCallback
+
+/**
+ * When using software rendering, Lottie caches its internal render bitmap if the animation changes.
+ * However, if a dynamic property changes in a LottieValueCallback, the consumer must call LottieAnimationView.invalidate()
+ * or LottieDrawable.invalidateSelf() to invalidate the drawing cache.
+ */
+class SoftwareRenderingDynamicPropertiesInvalidationTestCase : SnapshotTestCase {
+    override suspend fun SnapshotTestCaseContext.run() {
+        val animationView = animationViewPool.acquire()
+        val composition = LottieCompositionFactory.fromRawResSync(context, R.raw.heart).value!!
+        animationView.setComposition(composition)
+        animationView.renderMode = RenderMode.SOFTWARE
+        animationView.layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+        animationView.scaleType = ImageView.ScaleType.FIT_CENTER
+        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
+        animationViewContainer.measure(widthSpec, heightSpec)
+        animationViewContainer.layout(0, 0, animationViewContainer.measuredWidth, animationViewContainer.measuredHeight)
+        val canvas = Canvas()
+
+        var color = Color.GREEN
+        animationView.addValueCallback(KeyPath("**", "Fill 1"), LottieProperty.COLOR, object : LottieValueCallback<Int>() {
+            override fun getValue(frameInfo: LottieFrameInfo<Int>?): Int {
+                return color
+            }
+        })
+
+        var bitmap = bitmapPool.acquire(animationView.width, animationView.height)
+        canvas.setBitmap(bitmap)
+        animationView.draw(canvas)
+        snapshotter.record(bitmap, "Heart Software Dynamic Property", "Green")
+        bitmapPool.release(bitmap)
+
+        bitmap = bitmapPool.acquire(animationView.width, animationView.height)
+        canvas.setBitmap(bitmap)
+        color = Color.BLUE
+        animationView.invalidate()
+        animationView.draw(canvas)
+        snapshotter.record(bitmap, "Heart Software Dynamic Property", "Blue")
+        bitmapPool.release(bitmap)
+
+        animationViewPool.release(animationView)
+    }
+}
\ No newline at end of file