From c32f18ede7873f4c5e16493456340ef9878830d9 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Sun, 15 Jul 2018 22:08:03 -0700 Subject: [PATCH] Refactored LottieCompositionFactory APIs (#830) Replaced existing `LottieComposition.Factory` methods with a new `LottieCompositionFactory` The main purpose of this is to provide a clean way to handle exceptions. Today, parsing exceptions are thrown on a background thread and impossible to handle. This replaces that with a new `LottieResult` which mimics a typical `Result` class in functional programming. It either has a result or an exception. The composition methods now return a `LottieTask>>`. You can add/remove listeners on the LottieTask. The listener will be called with the result that contains either the exception or error. This also deprecates all `LottieComposition.Factory` methods because they all have an analogous `LottieCompositionFactory` API. This PR also extracts LottieAnimationView's cache into a separate class and added tests which found an off by one error. #710 --- CHANGELOG.md | 5 +- .../lottie/samples/LottieFontViewGroup.kt | 39 ++-- .../lottie/samples/LottiefilesViewModel.kt | 3 +- .../airbnb/lottie/samples/PlayerFragment.kt | 4 +- .../airbnb/lottie/samples/PlayerViewModel.kt | 48 ++-- .../airbnb/lottie/LottieAnimationView.java | 110 ++++----- .../com/airbnb/lottie/LottieComposition.java | 163 +++++++------- .../lottie/LottieCompositionFactory.java | 150 ++++++++++++ .../com/airbnb/lottie/LottieDrawable.java | 8 + .../com/airbnb/lottie/LottieListener.java | 8 + .../java/com/airbnb/lottie/LottieResult.java | 56 +++++ .../java/com/airbnb/lottie/LottieTask.java | 213 ++++++++++++++++++ .../lottie/OnCompositionLoadedListener.java | 5 + .../lottie/model/LottieCompositionCache.java | 59 +++++ .../lottie/parser/AsyncCompositionLoader.java | 35 --- .../lottie/utils/LottieValueAnimator.java | 8 + .../java/com/airbnb/lottie/KeyPathTest.java | 9 +- .../lottie/LottieCompositionFactoryTest.java | 85 +++++++ .../com/airbnb/lottie/LottieTaskTest.java | 105 +++++++++ .../model/LottieCompositionCacheTest.java | 65 ++++++ 20 files changed, 934 insertions(+), 244 deletions(-) create mode 100644 lottie/src/main/java/com/airbnb/lottie/LottieCompositionFactory.java create mode 100644 lottie/src/main/java/com/airbnb/lottie/LottieListener.java create mode 100644 lottie/src/main/java/com/airbnb/lottie/LottieResult.java create mode 100644 lottie/src/main/java/com/airbnb/lottie/LottieTask.java create mode 100644 lottie/src/main/java/com/airbnb/lottie/model/LottieCompositionCache.java delete mode 100644 lottie/src/main/java/com/airbnb/lottie/parser/AsyncCompositionLoader.java create mode 100644 lottie/src/test/java/com/airbnb/lottie/LottieCompositionFactoryTest.java create mode 100644 lottie/src/test/java/com/airbnb/lottie/LottieTaskTest.java create mode 100644 lottie/src/test/java/com/airbnb/lottie/model/LottieCompositionCacheTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a4d860bd..d11f666dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # 2.6.0 ### Features and Improvements +* Deprecated `LottieComposition.Factory` in favor of LottieCompositionFactory. + * The new factory methods make it easier to catch exceptions by separating out success and + failure handlers. Previously, catching exceptions was impossible and would crash your app. * [Sample App] Added the ability to load a file from assets. # 2.5.7 @@ -12,7 +15,6 @@ * Fixed a potential dangling Choreographer callback ([#775](https://githubcom/airbnb/lottie-android/pull/775)) # 2.5.5 -# Bugs Fixed * Fixed end times for layers/animations. Before, if the layer/animation out frame was 20, it would fully render frame 20. This is incorrect. The last rendered frame should be 19.999... in this case. This should make Lottie reflect After Effects more accurately. However, if you are getting the frame in onAnimationEnd or onAnimationRepeat, it will be one less than it used to be. * Added support for base64 encoded images directly in the json instead of the filename. They are 33% larger than their equivalent image file but enables you to have images with a single file. * Fixed a lint error about KeyPath visibility. @@ -20,7 +22,6 @@ * Prevent autoPlay from starting before the animation was attached to the window. This caused animations in a RecyclerView to start playing before they were on screen. # 2.5.4 -# Bugs Fixed * You can now call playAnimation() from onAnimationEnd * Min/Max frames are clipped to the composition start/end * setProgress takes into account start and end frame diff --git a/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/LottieFontViewGroup.kt b/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/LottieFontViewGroup.kt index b3e071ffad..1c22eb49c6 100644 --- a/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/LottieFontViewGroup.kt +++ b/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/LottieFontViewGroup.kt @@ -12,6 +12,7 @@ import android.view.inputmethod.InputConnection import android.widget.FrameLayout import com.airbnb.lottie.LottieAnimationView import com.airbnb.lottie.LottieComposition +import com.airbnb.lottie.LottieCompositionFactory import com.airbnb.lottie.LottieDrawable import java.util.* @@ -25,20 +26,17 @@ class LottieFontViewGroup @JvmOverloads constructor( init { isFocusableInTouchMode = true - LottieComposition.Factory.fromAssetFileName(context, "Mobilo/BlinkingCursor.json" - ) { composition -> - if (composition == null) { - return@fromAssetFileName - } - cursorView.layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - cursorView.setComposition(composition) - cursorView.repeatCount = LottieDrawable.INFINITE - cursorView.playAnimation() - addView(cursorView) - } + LottieCompositionFactory.fromAsset(context, "Mobilo/BlinkingCursor.json") + .addListener { + cursorView.layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + cursorView.setComposition(it) + cursorView.repeatCount = LottieDrawable.INFINITE + cursorView.playAnimation() + addView(cursorView) + } } private fun addSpace() { @@ -154,14 +152,11 @@ class LottieFontViewGroup @JvmOverloads constructor( if (compositionMap.containsKey(fileName)) { addComposition(compositionMap[fileName]!!) } else { - LottieComposition.Factory.fromAssetFileName(context, fileName - ) { composition -> - if (composition == null) { - return@fromAssetFileName - } - compositionMap.put(fileName, composition) - addComposition(composition) - } + LottieCompositionFactory.fromAsset(context, fileName) + .addListener { + compositionMap.put(fileName, it) + addComposition(it) + } } return true diff --git a/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/LottiefilesViewModel.kt b/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/LottiefilesViewModel.kt index 53607c5b58..4244a03229 100644 --- a/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/LottiefilesViewModel.kt +++ b/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/LottiefilesViewModel.kt @@ -6,6 +6,7 @@ import android.arch.lifecycle.Lifecycle import android.arch.lifecycle.MutableLiveData import android.arch.lifecycle.OnLifecycleEvent import android.util.Log +import com.airbnb.lottie.L import com.airbnb.lottie.samples.model.AnimationData import com.airbnb.lottie.samples.model.AnimationResponse import io.reactivex.Observable @@ -59,7 +60,7 @@ class LottiefilesViewModel(application: Application) : AndroidViewModel(applicat responses.add(it) animationDataList.value = flatten(animationDataList.value, it.data) }, { - Log.d("Gabe", "e#\t", it); + Log.d(L.TAG, "e#\t", it); }, { loading.value = false })) diff --git a/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/PlayerFragment.kt b/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/PlayerFragment.kt index 98e1e333b1..4980985652 100644 --- a/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/PlayerFragment.kt +++ b/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/PlayerFragment.kt @@ -15,6 +15,7 @@ import android.support.transition.TransitionManager import android.support.v4.app.Fragment import android.support.v4.content.ContextCompat import android.support.v7.app.AppCompatActivity +import android.util.Log import android.view.* import android.widget.EditText import androidx.view.children @@ -146,7 +147,8 @@ class PlayerFragment : Fragment() { onCompositionLoaded(it) }) viewModel.error.observe(this, Observer { - Snackbar.make(coordinatorLayout, R.string.composition_load_error, Snackbar.LENGTH_LONG) + Snackbar.make(coordinatorLayout, R.string.composition_load_error, Snackbar.LENGTH_LONG).show() + Log.w(L.TAG, "Error loading composition.", viewModel.error.value); }) viewModel.fetchAnimation(args) diff --git a/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/PlayerViewModel.kt b/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/PlayerViewModel.kt index d91cd0d01c..a7716b50bb 100644 --- a/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/PlayerViewModel.kt +++ b/LottieSample/src/main/kotlin/com/airbnb/lottie/samples/PlayerViewModel.kt @@ -10,6 +10,7 @@ import android.net.Uri import android.os.Handler import android.os.Looper import com.airbnb.lottie.LottieComposition +import com.airbnb.lottie.LottieCompositionFactory import com.airbnb.lottie.samples.model.CompositionArgs import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers @@ -87,17 +88,13 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) } private fun handleJsonResponse(jsonString: String) { - try { - LottieComposition.Factory.fromJsonString(jsonString, { - if (it == null) { - error.value = IllegalArgumentException("Unable to parse composition") - } else { - composition.value = CompositionData(it) + LottieCompositionFactory.fromJsonString(jsonString) + .addListener { + this.composition.value = CompositionData(it) + } + .addFailureListener { + this.error.value = it } - }) - } catch (e: RuntimeException) { - error.value = e - } } @SuppressLint("CheckResult") @@ -114,7 +111,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) if (zipEntry.name.contains("__MACOSX")) { zis.closeEntry() } else if (zipEntry.name.contains(".json")) { - val composition = LottieComposition.Factory.fromInputStreamSync(zis, false) + val result = LottieCompositionFactory.fromJsonInputStreamSync(zis, false) + val composition = result.value if (composition == null) { throw IllegalArgumentException("Unable to parse composition") } else { @@ -160,22 +158,22 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) return } - LottieComposition.Factory.fromInputStream(fis) { - if (it == null) { - error.value = IllegalArgumentException("Er") - } else { - composition.value = CompositionData(it) - } - } + LottieCompositionFactory.fromJsonInputStream(fis) + .addListener { + this.composition.value = CompositionData(it) + } + .addFailureListener { + this.error.value = it + } } private fun fetchAnimationByAsset(asset: String) { - LottieComposition.Factory.fromAssetFileName(getApplication(), asset) { - if (it == null) { - error.value = IllegalArgumentException("Error loading asset " + asset) - } else { - composition.value = CompositionData(it) - } - } + LottieCompositionFactory.fromAsset(getApplication(), asset) + .addListener { + composition.value = CompositionData(it) + } + .addFailureListener { + error.value = it + } } } \ No newline at end of file diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java index 70818b8890..409ab9fcf2 100644 --- a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java +++ b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java @@ -11,6 +11,7 @@ import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.FloatRange; +import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.RawRes; @@ -20,9 +21,9 @@ import android.util.AttributeSet; import android.util.JsonReader; import android.util.Log; -import android.util.SparseArray; import com.airbnb.lottie.model.KeyPath; +import com.airbnb.lottie.model.LottieCompositionCache; import com.airbnb.lottie.value.LottieFrameInfo; import com.airbnb.lottie.value.LottieValueCallback; import com.airbnb.lottie.value.SimpleLottieValueCallback; @@ -30,10 +31,7 @@ import org.json.JSONObject; import java.io.StringReader; -import java.lang.ref.WeakReference; -import java.util.HashMap; import java.util.List; -import java.util.Map; /** * This view will load, deserialize, and display an After Effects animation exported with @@ -66,23 +64,11 @@ public enum CacheStrategy { Strong } - private static final SparseArray RAW_RES_STRONG_REF_CACHE = new SparseArray<>(); - private static final SparseArray> RAW_RES_WEAK_REF_CACHE = - new SparseArray<>(); - - private static final Map ASSET_STRONG_REF_CACHE = new HashMap<>(); - private static final Map> ASSET_WEAK_REF_CACHE = - new HashMap<>(); - - private final OnCompositionLoadedListener loadedListener = - new OnCompositionLoadedListener() { - @Override public void onCompositionLoaded(@Nullable LottieComposition composition) { - if (composition != null) { - setComposition(composition); - } - compositionLoader = null; - } - }; + private final LottieListener loadedListener = new LottieListener() { + @Override public void onResult(LottieComposition composition) { + setComposition(composition); + } + }; private final LottieDrawable lottieDrawable = new LottieDrawable(); private CacheStrategy defaultCacheStrategy; @@ -92,7 +78,7 @@ public enum CacheStrategy { private boolean autoPlay = false; private boolean useHardwareLayer = false; - @Nullable private Cancellable compositionLoader; + @Nullable private LottieTask compositionTask; /** Can be null because it is created async */ @Nullable private LottieComposition composition; @@ -352,35 +338,24 @@ public void setAnimation(@RawRes int animationResId) { * strong reference to the composition once it is loaded * and deserialized. {@link CacheStrategy#Weak} will hold a weak reference to said composition. */ - public void setAnimation(@RawRes final int animationResId, final CacheStrategy cacheStrategy) { - this.animationResId = animationResId; + public void setAnimation(@RawRes final int rawRes, final CacheStrategy cacheStrategy) { + this.animationResId = rawRes; animationName = null; - if (RAW_RES_WEAK_REF_CACHE.indexOfKey(animationResId) > 0) { - WeakReference compRef = RAW_RES_WEAK_REF_CACHE.get(animationResId); - LottieComposition ref = compRef.get(); - if (ref != null) { - setComposition(ref); - return; - } - } else if (RAW_RES_STRONG_REF_CACHE.indexOfKey(animationResId) > 0) { - setComposition(RAW_RES_STRONG_REF_CACHE.get(animationResId)); + LottieComposition cachedComposition = LottieCompositionCache.getInstance().getRawRes(rawRes); + if (cachedComposition != null) { + setComposition(cachedComposition); return; } clearComposition(); cancelLoaderTask(); - compositionLoader = LottieComposition.Factory.fromRawFile(getContext(), animationResId, - new OnCompositionLoadedListener() { - @Override public void onCompositionLoaded(LottieComposition composition) { - if (cacheStrategy == CacheStrategy.Strong) { - RAW_RES_STRONG_REF_CACHE.put(animationResId, composition); - } else if (cacheStrategy == CacheStrategy.Weak) { - RAW_RES_WEAK_REF_CACHE.put(animationResId, new WeakReference<>(composition)); - } - - setComposition(composition); + compositionTask = LottieCompositionFactory.fromRawRes(getContext(), rawRes) + .addListener(new LottieListener() { + @Override public void onResult(LottieComposition composition) { + LottieCompositionCache.getInstance().put(rawRes, composition, cacheStrategy); } - }); + }) + .addListener(loadedListener); } /** @@ -395,41 +370,30 @@ public void setAnimation(String animationName) { /** * Sets the animation from a file in the assets directory. - * This will load and deserialize the file asynchronously. + * This will load and deserialize the file asynchronously if it is not already in the cache. *

* You may also specify a cache strategy. Specifying {@link CacheStrategy#Strong} will hold a * strong reference to the composition once it is loaded * and deserialized. {@link CacheStrategy#Weak} will hold a weak reference to said composition. */ - public void setAnimation(final String animationName, final CacheStrategy cacheStrategy) { - this.animationName = animationName; + public void setAnimation(final String assetName, final CacheStrategy cacheStrategy) { + this.animationName = assetName; animationResId = 0; - if (ASSET_WEAK_REF_CACHE.containsKey(animationName)) { - WeakReference compRef = ASSET_WEAK_REF_CACHE.get(animationName); - LottieComposition ref = compRef.get(); - if (ref != null) { - setComposition(ref); - return; - } - } else if (ASSET_STRONG_REF_CACHE.containsKey(animationName)) { - setComposition(ASSET_STRONG_REF_CACHE.get(animationName)); + LottieComposition cachedComposition = LottieCompositionCache.getInstance().get(assetName); + if (cachedComposition != null) { + setComposition(cachedComposition); return; } clearComposition(); cancelLoaderTask(); - compositionLoader = LottieComposition.Factory.fromAssetFileName(getContext(), animationName, - new OnCompositionLoadedListener() { - @Override public void onCompositionLoaded(LottieComposition composition) { - if (cacheStrategy == CacheStrategy.Strong) { - ASSET_STRONG_REF_CACHE.put(animationName, composition); - } else if (cacheStrategy == CacheStrategy.Weak) { - ASSET_WEAK_REF_CACHE.put(animationName, new WeakReference<>(composition)); - } - - setComposition(composition); + compositionTask = LottieCompositionFactory.fromAsset(getContext(), assetName) + .addListener(new LottieListener() { + @Override public void onResult(LottieComposition composition) { + LottieCompositionCache.getInstance().put(assetName, composition, cacheStrategy); } - }); + }) + .addListener(loadedListener); } /** @@ -463,13 +427,13 @@ public void setAnimationFromJson(String jsonString) { public void setAnimation(JsonReader reader) { clearComposition(); cancelLoaderTask(); - compositionLoader = LottieComposition.Factory.fromJsonReader(reader, loadedListener); + compositionTask = LottieCompositionFactory.fromJsonReader(reader) + .addListener(loadedListener); } private void cancelLoaderTask() { - if (compositionLoader != null) { - compositionLoader.cancel(); - compositionLoader = null; + if (compositionTask != null) { + compositionTask.removeListener(loadedListener); } } @@ -523,6 +487,7 @@ public boolean hasMatte() { * Plays the animation from the beginning. If speed is < 0, it will start at the end * and play towards the beginning */ + @MainThread public void playAnimation() { lottieDrawable.playAnimation(); enableOrDisableHardwareLayer(); @@ -532,6 +497,7 @@ public void playAnimation() { * Continues playing the animation from its current position. If speed < 0, it will play backwards * from the current position. */ + @MainThread public void resumeAnimation() { lottieDrawable.resumeAnimation(); enableOrDisableHardwareLayer(); @@ -825,11 +791,13 @@ public float getScale() { return lottieDrawable.getScale(); } + @MainThread public void cancelAnimation() { lottieDrawable.cancelAnimation(); enableOrDisableHardwareLayer(); } + @MainThread public void pauseAnimation() { lottieDrawable.pauseAnimation(); enableOrDisableHardwareLayer(); diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieComposition.java b/lottie/src/main/java/com/airbnb/lottie/LottieComposition.java index 0e1c1a0687..abd8401bdf 100644 --- a/lottie/src/main/java/com/airbnb/lottie/LottieComposition.java +++ b/lottie/src/main/java/com/airbnb/lottie/LottieComposition.java @@ -3,10 +3,10 @@ import android.content.Context; import android.content.res.Resources; import android.graphics.Rect; -import android.os.AsyncTask; import android.support.annotation.Nullable; import android.support.annotation.RawRes; import android.support.annotation.RestrictTo; +import android.support.annotation.WorkerThread; import android.support.v4.util.LongSparseArray; import android.support.v4.util.SparseArrayCompat; import android.util.JsonReader; @@ -15,26 +15,23 @@ import com.airbnb.lottie.model.Font; import com.airbnb.lottie.model.FontCharacter; import com.airbnb.lottie.model.layer.Layer; -import com.airbnb.lottie.parser.AsyncCompositionLoader; -import com.airbnb.lottie.parser.LottieCompositionParser; import org.json.JSONObject; import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.StringReader; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Map; -import static com.airbnb.lottie.utils.Utils.closeQuietly; - /** * After Effects/Bodymovin composition model. This is the serialized model from which the * animation will be created. + * + * To create one, use {@link LottieCompositionFactory}. + * * It can be used with a {@link com.airbnb.lottie.LottieAnimationView} or * {@link com.airbnb.lottie.LottieDrawable}. */ @@ -155,125 +152,131 @@ public float getDurationFrames() { return sb.toString(); } - @SuppressWarnings({"WeakerAccess"}) + /** + * @see LottieCompositionFactory + */ + @Deprecated public static class Factory { private Factory() { } /** - * Loads a composition from a file stored in /assets. + * @see LottieCompositionFactory#fromAsset(Context, String) */ - public static Cancellable fromAssetFileName( - Context context, String fileName, OnCompositionLoadedListener listener) { - InputStream stream; - try { - stream = context.getAssets().open(fileName); - } catch (IOException e) { - throw new IllegalArgumentException("Unable to find file " + fileName, e); - } - return fromInputStream(stream, listener); + public static Cancellable fromAssetFileName(Context context, String fileName, OnCompositionLoadedListener l) { + ListenerAdapter listener = new ListenerAdapter(l); + LottieCompositionFactory.fromAsset(context, fileName).addListener(listener); + return listener; } /** - * Loads a composition from a file stored in res/raw. + * @see LottieCompositionFactory#fromRawRes(Context, int) */ - public static Cancellable fromRawFile( - Context context, @RawRes int resId, OnCompositionLoadedListener listener) { - return fromInputStream(context.getResources().openRawResource(resId), listener); + public static Cancellable fromRawFile(Context context, @RawRes int resId, OnCompositionLoadedListener l) { + ListenerAdapter listener = new ListenerAdapter(l); + LottieCompositionFactory.fromRawRes(context, resId).addListener(listener); + return listener; } /** - * Loads a composition from an arbitrary input stream. - *

- * ex: fromInputStream(context, new FileInputStream(filePath), (composition) -> {}); + * @see LottieCompositionFactory#fromJsonInputStream(InputStream) */ - public static Cancellable fromInputStream( - InputStream stream, OnCompositionLoadedListener listener) { - return fromJsonReader(new JsonReader(new InputStreamReader(stream)), listener); + public static Cancellable fromInputStream(InputStream stream, OnCompositionLoadedListener l) { + ListenerAdapter listener = new ListenerAdapter(l); + LottieCompositionFactory.fromJsonInputStream(stream).addListener(listener); + return listener; } /** - * Loads a composition from a json string. This is preferable to loading a JSONObject because - * internally, Lottie uses {@link JsonReader} so any original overhead to create the JSONObject - * is wasted. - * - * This is the preferred method to use when loading an animation from the network because you - * have the response body as a raw string already. No need to convert it to a JSONObject. - * - * If you do have a JSONObject, you can call: - * `new JsonReader(new StringReader(jsonObject));` - * However, this is not recommended. + * @see LottieCompositionFactory#fromJsonString(String) */ - public static Cancellable fromJsonString( - String jsonString, OnCompositionLoadedListener listener) { - return fromJsonReader(new JsonReader(new StringReader(jsonString)), listener); + public static Cancellable fromJsonString(String jsonString, OnCompositionLoadedListener l) { + ListenerAdapter listener = new ListenerAdapter(l); + LottieCompositionFactory.fromJsonString(jsonString).addListener(listener); + return listener; } /** - * Loads a composition from a json reader. - *

- * ex: fromInputStream(context, new FileInputStream(filePath), (composition) -> {}); + * @see LottieCompositionFactory#fromJsonReader(JsonReader) */ - public static Cancellable fromJsonReader( - JsonReader reader, OnCompositionLoadedListener listener) { - AsyncCompositionLoader loader = new AsyncCompositionLoader(listener); - loader.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, reader); - return loader; + public static Cancellable fromJsonReader(JsonReader reader, OnCompositionLoadedListener l) { + ListenerAdapter listener = new ListenerAdapter(l); + LottieCompositionFactory.fromJsonReader(reader).addListener(listener); + return listener; } + /** + * @see LottieCompositionFactory#fromAssetSync(Context, String) + */ @Nullable + @WorkerThread public static LottieComposition fromFileSync(Context context, String fileName) { - try { - return fromInputStreamSync(context.getAssets().open(fileName)); - } catch (IOException e) { - throw new IllegalArgumentException("Unable to open asset " + fileName, e); - } + return LottieCompositionFactory.fromAssetSync(context, fileName).getValue(); } + /** + * @see LottieCompositionFactory#fromJsonInputStreamSync(InputStream) + */ @Nullable + @WorkerThread public static LottieComposition fromInputStreamSync(InputStream stream) { - return fromInputStreamSync(stream, true); + return LottieCompositionFactory.fromJsonInputStreamSync(stream).getValue(); } + /** + * @see LottieCompositionFactory#fromJsonInputStreamSync(InputStream, boolean) + */ @Nullable + @WorkerThread public static LottieComposition fromInputStreamSync(InputStream stream, boolean close) { - LottieComposition composition; - try { - composition = fromJsonSync(new JsonReader(new InputStreamReader(stream))); - } catch (IOException e) { - throw new IllegalArgumentException("Unable to parse composition.", e); - } finally { - if (close) { - closeQuietly(stream); - } - } - return composition; + return LottieCompositionFactory.fromJsonInputStreamSync(stream, close).getValue(); } /** - * Lottie now uses a streaming json parser. Prefer {@link #fromJsonSync(JsonReader)} if possible. - *

- * This will call toString() on your entire JSONObject. + * @see LottieCompositionFactory#fromJsonSync(JSONObject) */ - @Deprecated + @Nullable + @WorkerThread public static LottieComposition fromJsonSync(@SuppressWarnings("unused") Resources res, JSONObject json) { - return fromJsonSync(json.toString()); + return LottieCompositionFactory.fromJsonSync(json).getValue(); } /** - * Prefer using a JsonReader directly when possible. Reference the source for the async - * factory methods above. + * @see LottieCompositionFactory#fromJsonStringSync(String) */ - public static LottieComposition fromJsonSync(String string) { - try { - return fromJsonSync(new JsonReader(new StringReader(string))); - } catch (IOException e) { - throw new IllegalArgumentException(e); - } + @Nullable + @WorkerThread + public static LottieComposition fromJsonSync(String json) { + return LottieCompositionFactory.fromJsonStringSync(json).getValue(); } + /** + * @see LottieCompositionFactory#fromJsonReaderSync(JsonReader) + */ + @Nullable + @WorkerThread public static LottieComposition fromJsonSync(JsonReader reader) throws IOException { - return LottieCompositionParser.parse(reader); + return LottieCompositionFactory.fromJsonReaderSync(reader).getValue(); + } + + private static final class ListenerAdapter implements LottieListener, Cancellable { + private final OnCompositionLoadedListener listener; + private boolean cancelled = false; + + private ListenerAdapter(OnCompositionLoadedListener listener) { + this.listener = listener; + } + + @Override public void onResult(LottieComposition composition) { + if (cancelled) { + return; + } + listener.onCompositionLoaded(composition); + } + + @Override public void cancel() { + cancelled = true; + } } } } diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieCompositionFactory.java b/lottie/src/main/java/com/airbnb/lottie/LottieCompositionFactory.java new file mode 100644 index 0000000000..d71ca0ca08 --- /dev/null +++ b/lottie/src/main/java/com/airbnb/lottie/LottieCompositionFactory.java @@ -0,0 +1,150 @@ +package com.airbnb.lottie; + +import android.content.Context; +import android.content.res.Resources; +import android.support.annotation.RawRes; +import android.support.annotation.WorkerThread; +import android.util.JsonReader; + +import com.airbnb.lottie.parser.LottieCompositionParser; + +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringReader; +import java.util.concurrent.Callable; + +import static com.airbnb.lottie.utils.Utils.closeQuietly; + +/** + * Helpers to create a LottieComposition. + */ +public class LottieCompositionFactory { + + private LottieCompositionFactory() { + } + + public static LottieTask fromAsset(Context context, final String fileName) { + // Prevent accidentally leaking an Activity. + final Context appContext = context.getApplicationContext(); + return new LottieTask<>(new Callable>() { + @Override public LottieResult call() { + return fromAssetSync(appContext, fileName); + } + }); + } + + @WorkerThread + public static LottieResult fromAssetSync(Context context, String fileName) { + try { + return fromJsonInputStreamSync(context.getAssets().open(fileName)); + } catch (IOException e) { + return new LottieResult<>(e); + } + } + + public static LottieTask fromRawRes(Context context, @RawRes final int rawRes) { + // Prevent accidentally leaking an Activity. + final Context appContext = context.getApplicationContext(); + return new LottieTask<>(new Callable>() { + @Override public LottieResult call() throws Exception { + return fromRawResSync(appContext, rawRes); + } + }); + } + + @WorkerThread + public static LottieResult fromRawResSync(Context context, @RawRes int resId) { + try { + return fromJsonInputStreamSync(context.getResources().openRawResource(resId)); + } catch (Resources.NotFoundException e) { + return new LottieResult<>(e); + } + } + + public static LottieTask fromJsonInputStream(final InputStream stream) { + return new LottieTask<>(new Callable>() { + @Override public LottieResult call() throws Exception { + return fromJsonInputStreamSync(stream); + } + }); + } + + @WorkerThread + public static LottieResult fromJsonInputStreamSync(InputStream stream) { + return fromJsonInputStreamSync(stream, true); + } + + /** + * Return a LottieComposition for the given InputStream to json. + */ + @WorkerThread + public static LottieResult fromJsonInputStreamSync(InputStream stream, boolean close) { + try { + return fromJsonReaderSync(new JsonReader(new InputStreamReader(stream))); + } finally { + if (close) { + closeQuietly(stream); + } + } + } + + @Deprecated + public static LottieTask fromJson(final JSONObject json) { + return new LottieTask<>(new Callable>() { + @Override public LottieResult call() throws Exception { + return fromJsonSync(json); + } + }); + } + + /** + * Prefer passing in the json string directly. This method just calls `toString()` on your JSONObject. + * If you are loading this animation from the network, just use the response body string instead of + * parsing it first for improved performance. + */ + @Deprecated + @WorkerThread + public static LottieResult fromJsonSync(JSONObject json) { + return fromJsonStringSync(json.toString()); + } + + public static LottieTask fromJsonString(final String json) { + return new LottieTask<>(new Callable>() { + @Override public LottieResult call() throws Exception { + return fromJsonStringSync(json); + } + }); + } + + /** + * Return a LottieComposition for the specified raw json string. + * If loading from a file, it is preferable to use the InputStream or rawRes version. + */ + @WorkerThread + public static LottieResult fromJsonStringSync(String json) { + return fromJsonReaderSync(new JsonReader(new StringReader(json))); + } + + public static LottieTask fromJsonReader(final JsonReader reader) { + return new LottieTask<>(new Callable>() { + @Override public LottieResult call() throws Exception { + return fromJsonReaderSync(reader); + } + }); + } + + /** + * Return a LottieComposition for the specified json. + */ + @WorkerThread + public static LottieResult fromJsonReaderSync(JsonReader reader) { + try { + return new LottieResult<>(LottieCompositionParser.parse(reader)); + } catch (Exception e) { + return new LottieResult<>(e); + } + } +} diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java index eae6e7fee6..c97b94f67c 100644 --- a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java +++ b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java @@ -15,6 +15,7 @@ import android.support.annotation.FloatRange; import android.support.annotation.IntDef; import android.support.annotation.IntRange; +import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Log; @@ -186,6 +187,8 @@ public void recycleBitmaps() { } /** + * Create a composition with {@link LottieCompositionFactory} + * * @return True if the composition is different from the previously set composition, false otherwise. */ public boolean setComposition(LottieComposition composition) { @@ -319,10 +322,12 @@ public void clearComposition() { // + @MainThread @Override public void start() { playAnimation(); } + @MainThread @Override public void stop() { endAnimation(); } @@ -335,6 +340,7 @@ public void clearComposition() { * Plays the animation from the beginning. If speed is < 0, it will start at the end * and play towards the beginning */ + @MainThread public void playAnimation() { if (compositionLayer == null) { lazyCompositionTasks.add(new LazyCompositionTask() { @@ -347,6 +353,7 @@ public void playAnimation() { animator.playAnimation(); } + @MainThread public void endAnimation() { lazyCompositionTasks.clear(); animator.endAnimation(); @@ -356,6 +363,7 @@ public void endAnimation() { * Continues playing the animation from its current position. If speed < 0, it will play backwards * from the current position. */ + @MainThread public void resumeAnimation() { if (compositionLayer == null) { lazyCompositionTasks.add(new LazyCompositionTask() { diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieListener.java b/lottie/src/main/java/com/airbnb/lottie/LottieListener.java new file mode 100644 index 0000000000..b43c35a67a --- /dev/null +++ b/lottie/src/main/java/com/airbnb/lottie/LottieListener.java @@ -0,0 +1,8 @@ +package com.airbnb.lottie; + +/** + * Receive a result with either the value or exception for a {@link LottieTask} + */ +public interface LottieListener { + void onResult(T result); +} diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieResult.java b/lottie/src/main/java/com/airbnb/lottie/LottieResult.java new file mode 100644 index 0000000000..1f290d4285 --- /dev/null +++ b/lottie/src/main/java/com/airbnb/lottie/LottieResult.java @@ -0,0 +1,56 @@ +package com.airbnb.lottie; + +import android.support.annotation.Nullable; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Contains class to hold the resulting value of an async task or an exception if it failed. + * + * Either value or exception will be non-null. + */ +public final class LottieResult { + + @Nullable private final V value; + @Nullable private final Throwable exception; + + public LottieResult(V value) { + this.value = value; + exception = null; + } + + public LottieResult(Throwable exception) { + this.exception = exception; + value = null; + } + + @Nullable public V getValue() { + return value; + } + + @Nullable public Throwable getException() { + return exception; + } + + @Override public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof LottieResult)) { + return false; + } + LottieResult that = (LottieResult) o; + if (getValue() != null && getValue().equals(that.getValue())) { + return true; + } + if (getException() != null && that.getException() != null) { + return getException().toString().equals(getException().toString()); + } + return false; + } + + @Override public int hashCode() { + return Arrays.hashCode(new Object[]{getValue(), getException()}); + } +} diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieTask.java b/lottie/src/main/java/com/airbnb/lottie/LottieTask.java new file mode 100644 index 0000000000..2e154489db --- /dev/null +++ b/lottie/src/main/java/com/airbnb/lottie/LottieTask.java @@ -0,0 +1,213 @@ +package com.airbnb.lottie; + +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.util.Log; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; + +/** + * Helper to run asynchronous tasks with a result. + * Results can be obtained with {@link #addListener(LottieListener)}. + * Failures can be obtained with {@link #addFailureListener(LottieListener)}. + * + * A task will produce a single result or a single failure. + */ +public class LottieTask { + + @Nullable private Thread taskObserver; + + /* Preserve add order. */ + private final Set> successListeners = new LinkedHashSet<>(1); + private final Set> failureListeners = new LinkedHashSet<>(1); + private final Handler handler = new Handler(Looper.getMainLooper()); + private final FutureTask> task; + + @Nullable private LottieResult result = null; + + public LottieTask(Callable> runnable) { + this(runnable, false); + } + + /** + * runNow is only used for testing. + */ + LottieTask(Callable> runnable, boolean runNow) { + task = new FutureTask<>(runnable); + + if (runNow) { + try { + setResult(runnable.call()); + } catch (Throwable e) { + setResult(new LottieResult(e)); + } + } else { + task.run(); + startTaskObserverIfNeeded(); + } + } + + private void setResult(@Nullable LottieResult result) { + if (this.result != null) { + throw new IllegalStateException("A task may only be set once."); + } + this.result = result; + notifyListeners(); + } + + /** + * Add a task listener. If the task has completed, the listener will be called synchronously. + * @return the task for call chaining. + */ + public LottieTask addListener(LottieListener listener) { + if (result != null && result.getValue() != null) { + listener.onResult(result.getValue()); + } + + synchronized (successListeners) { + successListeners.add(listener); + } + startTaskObserverIfNeeded(); + return this; + } + + /** + * Remove a given task listener. The task will continue to execute so you can re-add + * a listener if neccesary. + * @return the task for call chaining. + */ + public LottieTask removeListener(LottieListener listener) { + synchronized (successListeners) { + successListeners.remove(listener); + } + stopTaskObserverIfNeeded(); + return this; + } + + /** + * Add a task failure listener. This will only be called in the even that an exception + * occurs. If an exception has already occurred, the listener will be called immediately. + * @return the task for call chaining. + */ + public LottieTask addFailureListener(LottieListener listener) { + if (result != null && result.getException() != null) { + listener.onResult(result.getException()); + } + + synchronized (failureListeners) { + failureListeners.add(listener); + } + startTaskObserverIfNeeded(); + return this; + } + + /** + * Remove a given task failure listener. The task will continue to execute so you can re-add + * a listener if neccesary. + * @return the task for call chaining. + */ + public LottieTask removeFailureListener(LottieListener listener) { + synchronized (failureListeners) { + failureListeners.remove(listener); + } + stopTaskObserverIfNeeded(); + return this; + } + + private void notifyListeners() { + // Listeners should be called on the main thread. + handler.post(new Runnable() { + @Override public void run() { + if (result == null || task.isCancelled()) { + return; + } + // Local reference in case it gets set on a background thread. + LottieResult result = LottieTask.this.result; + if (result.getValue() != null) { + notifySuccessListeners(result.getValue()); + } else { + notifyFailureListeners(result.getException()); + } + } + }); + } + + private void notifySuccessListeners(T value) { + // Allow listeners to remove themself in onResult. + // Otherwise we risk ConcurrentModificationException. + List> listenersCopy = new ArrayList<>(successListeners); + for (LottieListener l : listenersCopy) { + l.onResult(value); + } + } + + private void notifyFailureListeners(Throwable e) { + // Allow listeners to remove themself in onResult. + // Otherwise we risk ConcurrentModificationException. + List> listenersCopy = new ArrayList<>(failureListeners); + if (listenersCopy.isEmpty()) { + Log.w(L.TAG, "Lottie encountered an error but no failure listener was added.", e); + return; + } + + for (LottieListener l : listenersCopy) { + l.onResult(e); + } + } + + /** + * We monitor the task with an observer thread to determine when it is done and should notify + * the appropriate listeners. + */ + private void startTaskObserverIfNeeded() { + if (taskObserverAlive() || result != null) { + return; + } + taskObserver = new Thread("LottieTaskObserver") { + @Override public void run() { + if (isInterrupted()) { + return; + } + if (task.isDone()) { + try { + setResult(task.get()); + } catch (InterruptedException | ExecutionException e) { + setResult(new LottieResult(e)); + } + stopTaskObserverIfNeeded(); + } + } + }; + taskObserver.start(); + if (L.DBG) { + Log.d(L.TAG, "Starting TaskObserver thread"); + } + } + + /** + * We can stop observing the task if there are no more listeners or if the task is complete. + */ + private void stopTaskObserverIfNeeded() { + if (!taskObserverAlive()) { + return; + } + if (successListeners.isEmpty() || result != null) { + taskObserver.interrupt(); + if (L.DBG) { + Log.d(L.TAG, "Stopping TaskObserver thread"); + } + } + } + + private boolean taskObserverAlive() { + return taskObserver != null && taskObserver.isAlive(); + } +} diff --git a/lottie/src/main/java/com/airbnb/lottie/OnCompositionLoadedListener.java b/lottie/src/main/java/com/airbnb/lottie/OnCompositionLoadedListener.java index a8fd9b8c47..71fc3417e5 100644 --- a/lottie/src/main/java/com/airbnb/lottie/OnCompositionLoadedListener.java +++ b/lottie/src/main/java/com/airbnb/lottie/OnCompositionLoadedListener.java @@ -2,6 +2,11 @@ import android.support.annotation.Nullable; +/** + * @see LottieCompositionFactory + * @see LottieResult + */ +@Deprecated public interface OnCompositionLoadedListener { /** * Composition will be null if there was an error loading it. Check logcat for more details. diff --git a/lottie/src/main/java/com/airbnb/lottie/model/LottieCompositionCache.java b/lottie/src/main/java/com/airbnb/lottie/model/LottieCompositionCache.java new file mode 100644 index 0000000000..91f960a2df --- /dev/null +++ b/lottie/src/main/java/com/airbnb/lottie/model/LottieCompositionCache.java @@ -0,0 +1,59 @@ +package com.airbnb.lottie.model; + +import android.content.res.Resources; +import android.support.annotation.Nullable; +import android.support.annotation.RawRes; +import android.support.annotation.RestrictTo; +import android.support.annotation.VisibleForTesting; + +import com.airbnb.lottie.LottieAnimationView.CacheStrategy; +import com.airbnb.lottie.LottieComposition; + +import java.lang.ref.WeakReference; +import java.util.HashMap; +import java.util.Map; + +@RestrictTo(RestrictTo.Scope.LIBRARY) +public class LottieCompositionCache { + + private static final LottieCompositionCache INSTANCE = new LottieCompositionCache(); + + public static LottieCompositionCache getInstance() { + return INSTANCE; + } + + private final Map strongRefCache = new HashMap<>(); + private final Map> weakRefCache = new HashMap<>(); + + @VisibleForTesting + LottieCompositionCache() { + } + + @Nullable + public LottieComposition getRawRes(@RawRes int rawRes) { + return get(Integer.toString(rawRes)); + } + + @Nullable + public LottieComposition get(String assetName) { + if (strongRefCache.containsKey(assetName)) { + return strongRefCache.get(assetName); + } else if (weakRefCache.containsKey(assetName)) { + WeakReference compRef = weakRefCache.get(assetName); + return compRef.get(); + } + return null; + } + + public void put(@RawRes int rawRes, @Nullable LottieComposition composition, CacheStrategy cacheStrategy) { + put(Integer.toString(rawRes), composition, cacheStrategy); + } + + public void put(String cacheKey, @Nullable LottieComposition composition, CacheStrategy cacheStrategy) { + if (cacheStrategy == CacheStrategy.Strong) { + strongRefCache.put(cacheKey, composition); + } else if (cacheStrategy == CacheStrategy.Weak) { + weakRefCache.put(cacheKey, new WeakReference(composition)); + } + } +} diff --git a/lottie/src/main/java/com/airbnb/lottie/parser/AsyncCompositionLoader.java b/lottie/src/main/java/com/airbnb/lottie/parser/AsyncCompositionLoader.java deleted file mode 100644 index e5405d5138..0000000000 --- a/lottie/src/main/java/com/airbnb/lottie/parser/AsyncCompositionLoader.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.airbnb.lottie.parser; - -import android.os.AsyncTask; -import android.util.JsonReader; - -import com.airbnb.lottie.Cancellable; -import com.airbnb.lottie.LottieComposition; -import com.airbnb.lottie.OnCompositionLoadedListener; - -import java.io.IOException; - -public final class AsyncCompositionLoader - extends AsyncTask implements Cancellable { - private final OnCompositionLoadedListener loadedListener; - - @SuppressWarnings("WeakerAccess") public AsyncCompositionLoader(OnCompositionLoadedListener loadedListener) { - this.loadedListener = loadedListener; - } - - @Override protected LottieComposition doInBackground(JsonReader... params) { - try { - return LottieComposition.Factory.fromJsonSync(params[0]); - } catch (IOException e) { - throw new IllegalStateException(e); - } - } - - @Override protected void onPostExecute(LottieComposition composition) { - loadedListener.onCompositionLoaded(composition); - } - - @Override public void cancel() { - cancel(true); - } -} diff --git a/lottie/src/main/java/com/airbnb/lottie/utils/LottieValueAnimator.java b/lottie/src/main/java/com/airbnb/lottie/utils/LottieValueAnimator.java index 29b2be9888..fbeb0a253e 100644 --- a/lottie/src/main/java/com/airbnb/lottie/utils/LottieValueAnimator.java +++ b/lottie/src/main/java/com/airbnb/lottie/utils/LottieValueAnimator.java @@ -2,6 +2,7 @@ import android.animation.ValueAnimator; import android.support.annotation.FloatRange; +import android.support.annotation.MainThread; import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import android.view.Choreographer; @@ -192,6 +193,7 @@ public float getSpeed() { } } + @MainThread public void playAnimation() { running = true; notifyStart(isReversed()); @@ -201,15 +203,18 @@ public void playAnimation() { postFrameCallback(); } + @MainThread public void endAnimation() { removeFrameCallback(); notifyEnd(isReversed()); } + @MainThread public void pauseAnimation() { removeFrameCallback(); } + @MainThread public void resumeAnimation() { running = true; postFrameCallback(); @@ -221,6 +226,7 @@ public void resumeAnimation() { } } + @MainThread @Override public void cancel() { notifyCancel(); removeFrameCallback(); @@ -251,10 +257,12 @@ protected void postFrameCallback() { } } + @MainThread protected void removeFrameCallback() { this.removeFrameCallback(true); } + @MainThread protected void removeFrameCallback(boolean stopRunning) { Choreographer.getInstance().removeFrameCallback(this); if (stopRunning) { diff --git a/lottie/src/test/java/com/airbnb/lottie/KeyPathTest.java b/lottie/src/test/java/com/airbnb/lottie/KeyPathTest.java index d474fdca20..dfffc2ebfb 100644 --- a/lottie/src/test/java/com/airbnb/lottie/KeyPathTest.java +++ b/lottie/src/test/java/com/airbnb/lottie/KeyPathTest.java @@ -32,13 +32,8 @@ public class KeyPathTest { @Before public void setupDrawable() { lottieDrawable = new LottieDrawable(); - try { - LottieComposition composition = LottieComposition.Factory - .fromJsonSync(new JsonReader(new StringReader(Fixtures.SQUARES))); - lottieDrawable.setComposition(composition); - } catch (IOException e) { - throw new IllegalStateException(e); - } + LottieComposition composition = LottieCompositionFactory.fromJsonStringSync(Fixtures.SQUARES).getValue(); + lottieDrawable.setComposition(composition); } // diff --git a/lottie/src/test/java/com/airbnb/lottie/LottieCompositionFactoryTest.java b/lottie/src/test/java/com/airbnb/lottie/LottieCompositionFactoryTest.java new file mode 100644 index 0000000000..b00b4ca45a --- /dev/null +++ b/lottie/src/test/java/com/airbnb/lottie/LottieCompositionFactoryTest.java @@ -0,0 +1,85 @@ +package com.airbnb.lottie; + +import android.util.JsonReader; + +import junit.framework.Assert; + +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import java.io.FileNotFoundException; +import java.io.StringReader; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class) +public class LottieCompositionFactoryTest { + private static final String JSON = "{\"v\":\"4.11.1\",\"fr\":60,\"ip\":0,\"op\":180,\"w\":300,\"h\":300,\"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\":[150,150,0],\"ix\":2},\"a\":{\"a\":0,\"k\":[0,0,0],\"ix\":1},\"s\":{\"a\":0,\"k\":[100,100,100]," + + "\"ix\":6}},\"ao\":0,\"shapes\":[{\"ty\":\"rc\",\"d\":1,\"s\":{\"a\":0,\"k\":[100,100],\"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.928262987324,0,0,1],\"ix\":4},\"o\":{\"a\":0,\"k\":100,\"ix\":5},\"r\":1,\"nm\":\"Fill 1\",\"mn\":\"ADBE Vector " + + "Graphic - Fill\",\"hd\":false}],\"ip\":0,\"op\":180,\"st\":0,\"bm\":0}]}"; + + private static final String NOT_JSON = "not json"; + + @Test + public void testLoadJsonString() { + LottieResult result = LottieCompositionFactory.fromJsonStringSync(JSON); + assertNull(result.getException()); + assertNotNull(result.getValue()); + } + + @Test + public void testLoadInvalidJsonString() { + LottieResult result = LottieCompositionFactory.fromJsonStringSync(NOT_JSON); + assertNotNull(result.getException()); + assertNull(result.getValue()); + } + + @Test + public void testLoadJsonReader() { + JsonReader reader = new JsonReader(new StringReader(JSON)); + LottieResult result = LottieCompositionFactory.fromJsonReaderSync(reader); + assertNull(result.getException()); + assertNotNull(result.getValue()); + } + + @Test + public void testLoadInvalidJsonReader() { + JsonReader reader = new JsonReader(new StringReader(NOT_JSON)); + LottieResult result = LottieCompositionFactory.fromJsonReaderSync(reader); + assertNotNull(result.getException()); + assertNull(result.getValue()); + } + + @Test + public void testLoadInvalidAssetName() { + LottieResult result = LottieCompositionFactory.fromAssetSync(RuntimeEnvironment.application, "square2.json"); + assertEquals(FileNotFoundException.class, result.getException().getClass()); + assertNull(result.getValue()); + } + + @Test + public void testNonJsonAssetFile() { + LottieResult result = LottieCompositionFactory.fromAssetSync(RuntimeEnvironment.application, "not_json.txt"); + assertNotNull(result.getException()); + assertNull(result.getValue()); + } + + @Test + public void testLoadInvalidRawResName() { + LottieResult result = LottieCompositionFactory.fromRawResSync(RuntimeEnvironment.application, 0); + assertNotNull(result.getException()); + assertNull(result.getValue()); + } +} diff --git a/lottie/src/test/java/com/airbnb/lottie/LottieTaskTest.java b/lottie/src/test/java/com/airbnb/lottie/LottieTaskTest.java new file mode 100644 index 0000000000..0b28cb2e9a --- /dev/null +++ b/lottie/src/test/java/com/airbnb/lottie/LottieTaskTest.java @@ -0,0 +1,105 @@ +package com.airbnb.lottie; + +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.concurrent.Callable; +import java.util.concurrent.Semaphore; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class) +public class LottieTaskTest { + + @Mock + public LottieListener successListener; + @Mock + public LottieListener failureListener; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void testListener() { + LottieTask task = new LottieTask<>(new Callable>() { + @Override public LottieResult call() { + return new LottieResult<>(5); + } + }, true) + .addListener(successListener) + .addFailureListener(failureListener); + verify(successListener, times(1)).onResult(5); + verifyZeroInteractions(failureListener); + } + + @Test + public void testException() { + final IllegalStateException exception = new IllegalStateException("foo"); + LottieTask task = new LottieTask<>(new Callable>() { + @Override public LottieResult call() { + throw exception; + } + }, true) + .addListener(successListener) + .addFailureListener(failureListener); + verifyZeroInteractions(successListener); + verify(failureListener, times(1)).onResult(exception); + } + + /** + * This hangs on CI but not locally. + */ + @Ignore + @Test + public void testRemoveListener() { + final Semaphore lock = new Semaphore(0); + LottieTask task = new LottieTask<>(new Callable>() { + @Override public LottieResult call() { + return new LottieResult<>(5); + } + }) + .addListener(successListener) + .addFailureListener(failureListener) + .addListener(new LottieListener() { + @Override public void onResult(Integer result) { + lock.release(); + } + }); + task.removeListener(successListener); + try { + lock.acquire(); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + verifyZeroInteractions(successListener); + verifyZeroInteractions(failureListener); + } + + @Test + public void testAddListenerAfter() { + LottieTask task = new LottieTask<>(new Callable>() { + @Override public LottieResult call() { + return new LottieResult<>(5); + } + }, true); + + task.addListener(successListener); + task.addFailureListener(failureListener); + verify(successListener, times(1)).onResult(5); + verifyZeroInteractions(failureListener); + } +} diff --git a/lottie/src/test/java/com/airbnb/lottie/model/LottieCompositionCacheTest.java b/lottie/src/test/java/com/airbnb/lottie/model/LottieCompositionCacheTest.java new file mode 100644 index 0000000000..a86ef0f5a0 --- /dev/null +++ b/lottie/src/test/java/com/airbnb/lottie/model/LottieCompositionCacheTest.java @@ -0,0 +1,65 @@ +package com.airbnb.lottie.model; + +import com.airbnb.lottie.BuildConfig; +import com.airbnb.lottie.LottieAnimationView; +import com.airbnb.lottie.LottieComposition; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class) +public class LottieCompositionCacheTest { + + private LottieComposition composition; + private LottieCompositionCache cache; + + @Before + public void setup() { + composition = Mockito.mock(LottieComposition.class); + cache = new LottieCompositionCache(); + } + + @Test + public void testEmpty() { + assertNull(cache.get("foo")); + assertNull(cache.getRawRes(123)); + } + + @Test + public void testStrongAsset() { + cache.put("foo", composition, LottieAnimationView.CacheStrategy.Strong); + assertEquals(composition, cache.get("foo")); + } + + @Test + public void testWeakAsset() { + cache.put("foo", composition, LottieAnimationView.CacheStrategy.Weak); + assertEquals(composition, cache.get("foo")); + } + + @Test + public void testStrongRawRes() { + cache.put(123, composition, LottieAnimationView.CacheStrategy.Strong); + assertEquals(composition, cache.getRawRes(123)); + } + + @Test + public void testWeakRawRes() { + cache.put(123, composition, LottieAnimationView.CacheStrategy.Weak); + assertEquals(composition, cache.getRawRes(123)); + } + + @Test + public void testStringAndWeakRawRes() { + cache.put(123, composition, LottieAnimationView.CacheStrategy.Weak); + assertEquals(composition, cache.getRawRes(123)); + } +}