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">