Add a fallback resource when compositions fail to load (#1350)

This may happen either with invalid inputs or when network loading fails.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 98b8c3a..8ff4688 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,19 @@
+# 3.1.0
+### Features and Improvements
+* **Breaking Change** Replace JsonReader parsing APIs with InputStream variants to prevent 
+exposing Lottie's copy of Moshi's json parser.
+* Add the ability to catch all Lottie composition errors with `setFailureListener` and 
+`resetFailureListener` (#1321).
+* Add the ability to set a fallback drawable res when Lottie fails to parse a composition or 
+load it from the internet. Use `setFallbackResource` from code or`lottie_fallbackRes` from xml.
+## Bugs Fixed
+* Prevent masks from either clipping edges or having thin borders pre-Pie.
+* Apply animation scale to dash pattern offsets.
+* Apply animation scale to gradient strokes.
+* Fuzzy match content types when downloading animations from the internet.
+* Prevent a StackOverflowException on KitKat.
+* Prevent resume from resuming when system animations are disabled.
+
 # 3.0.7
 * Fixed renderMode XML attr being ignored.
 * Allow progress to be set in between frames.
diff --git a/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/BitmapPool.kt b/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/BitmapPool.kt
index 44ae011..9cff898 100644
--- a/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/BitmapPool.kt
+++ b/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/BitmapPool.kt
@@ -7,6 +7,7 @@
 import android.graphics.PorterDuffXfermode
 import android.util.Log
 import com.airbnb.lottie.L
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import java.util.*
 import java.util.concurrent.ConcurrentHashMap
 
@@ -20,6 +21,7 @@
         }
     }
 
+    @ExperimentalCoroutinesApi
     fun acquire(width: Int, height: Int): Bitmap {
         if (width <= 0 || height <= 0) {
             return TRANSPARENT_1X1_BITMAP
diff --git a/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/LottieTest.kt b/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/LottieTest.kt
index 8fe74c8..92aacee 100644
--- a/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/LottieTest.kt
+++ b/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/LottieTest.kt
@@ -62,7 +62,7 @@
     private lateinit var snapshotter: HappoSnapshotter
 
     private val bitmapPool by lazy { BitmapPool() }
-    private val dummyBitmap by lazy { BitmapFactory.decodeResource(activity.resources, com.airbnb.lottie.samples.R.drawable.airbnb); }
+    private val dummyBitmap by lazy { BitmapFactory.decodeResource(activity.resources, R.drawable.airbnb); }
 
     private val filmStripViewPool = ObjectPool<FilmStripView> {
         FilmStripView(activity).apply {
@@ -99,6 +99,7 @@
     @ObsoleteCoroutinesApi
     fun testAll() = runBlocking {
         withTimeout(TimeUnit.MINUTES.toMillis(45)) {
+            snapshotFailure()
             snapshotFrameBoundaries()
             snapshotScaleTypes()
             testDynamicProperties()
@@ -214,6 +215,32 @@
         }
     }
 
+    private suspend fun snapshotFailure() {
+        val animationView = animationViewPool.acquire()
+        val semaphore = SuspendingSemaphore(0)
+        animationView.setFailureListener { semaphore.release() }
+        animationView.setFallbackResource(SampleAppR.drawable.ic_close)
+        animationView.setAnimationFromJson("Not Valid Json", null)
+        semaphore.acquire()
+        animationView.layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+        animationView.scale = 1f
+        animationView.scaleType = ImageView.ScaleType.FIT_CENTER
+        val widthSpec = View.MeasureSpec.makeMeasureSpec(activity.resources.displayMetrics.widthPixels, View.MeasureSpec.EXACTLY)
+        val heightSpec = View.MeasureSpec.makeMeasureSpec(activity.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 bitmap = bitmapPool.acquire(animationView.width, animationView.height)
+        val canvas = Canvas(bitmap)
+        animationView.draw(canvas)
+        animationViewPool.release(animationView)
+        val snapshotName = "Failure"
+        val snapshotVariant = "Default"
+        snapshotter.record(bitmap, snapshotName, snapshotVariant)
+        activity.recordSnapshot(snapshotName, snapshotVariant)
+        bitmapPool.release(bitmap)
+    }
+
     private suspend fun snapshotFrameBoundaries() {
         withDrawable("Tests/Frame.json", "Frame Boundary", "Frame 16 Red") { drawable ->
             drawable.frame = 16
diff --git a/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/ObjectPool.kt b/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/ObjectPool.kt
index efee920..bc61143 100644
--- a/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/ObjectPool.kt
+++ b/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/ObjectPool.kt
@@ -2,6 +2,7 @@
 
 import android.util.Log
 import com.airbnb.lottie.L
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import java.util.*
 import kotlin.collections.HashSet
 
@@ -11,6 +12,7 @@
     private val objects = Collections.synchronizedList(ArrayList<T>())
     private val releasedObjects = HashSet<T>()
 
+    @ExperimentalCoroutinesApi
     fun acquire(): T {
         var blockedStartTime = 0L
         if (semaphore.isFull()) {
diff --git a/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/SuspendingSemaphore.kt b/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/SuspendingSemaphore.kt
index 7fe571b..ed8b870 100644
--- a/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/SuspendingSemaphore.kt
+++ b/LottieSample/src/androidTest/java/com/airbnb/lottie/samples/SuspendingSemaphore.kt
@@ -1,5 +1,6 @@
 package com.airbnb.lottie.samples
 
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.runBlocking
 
@@ -29,6 +30,6 @@
         }
     }
 
-    @Suppress("EXPERIMENTAL_API_USAGE")
+    @ExperimentalCoroutinesApi
     fun isFull() = bufferedChannel.isFull
 }
\ No newline at end of file
diff --git a/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/DynamicActivity.kt b/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/DynamicActivity.kt
index 10966ac..d703f5b 100644
--- a/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/DynamicActivity.kt
+++ b/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/DynamicActivity.kt
@@ -49,6 +49,7 @@
                 Log.d(TAG, it.keysToString())
             }
         }
+        animationView.setFailureListener { /* do nothing */ }
 
         jumpHeight.postDelayed({ setupValueCallbacks() }, 1000)
         updateButtonText()
diff --git a/LottieSample/src/main/res/drawable/ic_error.xml b/LottieSample/src/main/res/drawable/ic_error.xml
new file mode 100644
index 0000000..8bd3566
--- /dev/null
+++ b/LottieSample/src/main/res/drawable/ic_error.xml
@@ -0,0 +1,5 @@
+<vector android:height="48dp" android:tint="#FF0000"
+    android:viewportHeight="24.0" android:viewportWidth="24.0"
+    android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="#FF000000" android:pathData="M11,15h2v2h-2zM11,7h2v6h-2zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
+</vector>
diff --git a/LottieSample/src/main/res/layout/activity_dynamic.xml b/LottieSample/src/main/res/layout/activity_dynamic.xml
index bc6456a..5bf5cc7 100644
--- a/LottieSample/src/main/res/layout/activity_dynamic.xml
+++ b/LottieSample/src/main/res/layout/activity_dynamic.xml
@@ -10,7 +10,8 @@
         android:layout_width="match_parent"
         android:layout_height="0dp"
         android:layout_weight="1"
-        airbnb:lottie_fileName="AndroidWave.json"
+        airbnb:lottie_fileName="AndroidWave2.json"
+        airbnb:lottie_fallbackRes="@drawable/ic_error"
         airbnb:lottie_autoPlay="true"
         airbnb:lottie_loop="true"/>
     <Button
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
index eb3b1d7..773a93e 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
@@ -16,6 +16,7 @@
 import android.util.Log;
 import android.view.View;
 
+import androidx.annotation.DrawableRes;
 import androidx.annotation.FloatRange;
 import androidx.annotation.MainThread;
 import androidx.annotation.NonNull;
@@ -79,7 +80,18 @@
     }
   };
 
-  private LottieListener<Throwable> failureListener = DEFAULT_FAILURE_LISTENER;
+  private final LottieListener<Throwable> wrappedFailureListener = new LottieListener<Throwable>() {
+    @Override
+    public void onResult(Throwable result) {
+      if (fallbackResource != 0) {
+        setImageResource(fallbackResource);
+      }
+      LottieListener<Throwable> l = failureListener == null ? DEFAULT_FAILURE_LISTENER : failureListener;
+      l.onResult(result);
+    }
+  };
+  @Nullable private LottieListener<Throwable> failureListener;
+  @DrawableRes private int fallbackResource = 0;
 
   private final LottieDrawable lottieDrawable = new LottieDrawable();
   private boolean isInitialized;
@@ -143,6 +155,8 @@
           setAnimationFromUrl(url);
         }
       }
+
+      setFallbackResource(ta.getResourceId(R.styleable.LottieAnimationView_lottie_fallbackRes, 0));
     }
     if (ta.getBoolean(R.styleable.LottieAnimationView_lottie_autoPlay, false)) {
       wasAnimatingWhenDetached = true;
@@ -388,30 +402,32 @@
 
   /**
    * Set a default failure listener that will be called if any of the setAnimation APIs fail for any reason.
-   * This can be used to replace the default behavior which is to crash.
+   * This can be used to replace the default behavior.
    *
-   * @see #resetFailureListener()
+   * The default behavior will log any network errors and rethrow all other exceptions.
+   *
+   * If you are loading an animation from the network, errors may occur if your user has no internet.
+   * You can use this listener to retry the download or you can have it default to an error drawable
+   * with {@link #setFallbackResource(int)}.
+   *
+   * Unless you are using {@link #setAnimationFromUrl(String)}, errors are unexpected.
+   *
+   * Set the listener to null to revert to the default behavior.
    */
-  public void setFailureListener(LottieListener<Throwable> failureListener) {
-    if (compositionTask != null) {
-      compositionTask.removeFailureListener(this.failureListener);
-      compositionTask.addFailureListener(failureListener);
-    }
+  public void setFailureListener(@Nullable LottieListener<Throwable> failureListener) {
     this.failureListener = failureListener;
   }
 
   /**
-   * Clears the failure listener set with {@link #setFailureListener(LottieListener)} and restores the default behavior
-   * which is to crash.
+   * Set a drawable that will be rendered if the LottieComposition fails to load for any reason.
+   * Unless you are using {@link #setAnimationFromUrl(String)}, this is an unexpected error and
+   * you should handle it with {@link #setFailureListener(LottieListener)}.
+   *
+   * If this is a network animation, you may use this to show an error to the user or
+   * you can use a failure listener to retry the download.
    */
-  public void resetFailureListener() {
-    if (failureListener == DEFAULT_FAILURE_LISTENER) {
-      return;
-    }
-    if (compositionTask != null) {
-      compositionTask.removeFailureListener(failureListener);
-    }
-    this.failureListener = DEFAULT_FAILURE_LISTENER;
+  public void setFallbackResource(@DrawableRes int fallbackResource) {
+    this.fallbackResource = fallbackResource;
   }
 
   private void setCompositionTask(LottieTask<LottieComposition> compositionTask) {
@@ -419,13 +435,13 @@
     cancelLoaderTask();
     this.compositionTask = compositionTask
             .addListener(loadedListener)
-            .addFailureListener(failureListener);
+            .addFailureListener(wrappedFailureListener);
   }
 
   private void cancelLoaderTask() {
     if (compositionTask != null) {
       compositionTask.removeListener(loadedListener);
-      compositionTask.removeFailureListener(failureListener);
+      compositionTask.removeFailureListener(wrappedFailureListener);
     }
   }
 
diff --git a/lottie/src/main/res/values/attrs.xml b/lottie/src/main/res/values/attrs.xml
index 3f54388..5d51c4a 100644
--- a/lottie/src/main/res/values/attrs.xml
+++ b/lottie/src/main/res/values/attrs.xml
@@ -4,6 +4,7 @@
         <attr name="lottie_fileName" format="string" />
         <attr name="lottie_rawRes" format="reference" />
         <attr name="lottie_url" format="string" />
+        <attr name="lottie_fallbackRes" format="reference" />
         <attr name="lottie_autoPlay" format="boolean" />
         <attr name="lottie_loop" format="boolean" />
         <attr name="lottie_repeatMode" format="enum">