Support Lottie to be rendered on per-window UI thread (#2685)

Issue: #2684
diff --git a/lottie-compose/src/main/java/com/airbnb/lottie/compose/rememberLottieComposition.kt b/lottie-compose/src/main/java/com/airbnb/lottie/compose/rememberLottieComposition.kt
index d9cd9ff..a4dfe40 100644
--- a/lottie-compose/src/main/java/com/airbnb/lottie/compose/rememberLottieComposition.kt
+++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/rememberLottieComposition.kt
@@ -186,16 +186,19 @@
                 val actualCacheKey = if (cacheKey == DefaultCacheKey) spec.fileName else cacheKey
                 when {
                     spec.fileName.endsWith("zip") -> LottieCompositionFactory.fromZipStream(
+                        context,
                         ZipInputStream(fis),
                         actualCacheKey,
                     )
 
                     spec.fileName.endsWith("tgs") -> LottieCompositionFactory.fromJsonInputStream(
+                        context,
                         GZIPInputStream(fis),
                         actualCacheKey,
                     )
 
                     else -> LottieCompositionFactory.fromJsonInputStream(
+                        context,
                         fis,
                         actualCacheKey,
                     )
@@ -213,7 +216,7 @@
 
         is LottieCompositionSpec.JsonString -> {
             val jsonStringCacheKey = if (cacheKey == DefaultCacheKey) spec.jsonString.hashCode().toString() else cacheKey
-            LottieCompositionFactory.fromJsonString(spec.jsonString, jsonStringCacheKey)
+            LottieCompositionFactory.fromJsonString(context, spec.jsonString, jsonStringCacheKey)
         }
 
         is LottieCompositionSpec.ContentProvider -> {
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
index 8972e08..5e20771 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
@@ -492,7 +492,7 @@
 
   private LottieTask<LottieComposition> fromRawRes(@RawRes final int rawRes) {
     if (isInEditMode()) {
-      return new LottieTask<>(() -> cacheComposition
+      return new LottieTask<>(getContext().getMainLooper(), () -> cacheComposition
           ? LottieCompositionFactory.fromRawResSync(getContext(), rawRes) : LottieCompositionFactory.fromRawResSync(getContext(), rawRes, null), true);
     } else {
       return cacheComposition ?
@@ -508,7 +508,7 @@
 
   private LottieTask<LottieComposition> fromAssets(final String assetName) {
     if (isInEditMode()) {
-      return new LottieTask<>(() -> cacheComposition ?
+      return new LottieTask<>(getContext().getMainLooper(), () -> cacheComposition ?
           LottieCompositionFactory.fromAssetSync(getContext(), assetName) : LottieCompositionFactory.fromAssetSync(getContext(), assetName, null), true);
     } else {
       return cacheComposition ?
@@ -546,7 +546,7 @@
    * Auto-closes the stream.
    */
   public void setAnimation(InputStream stream, @Nullable String cacheKey) {
-    setCompositionTask(LottieCompositionFactory.fromJsonInputStream(stream, cacheKey));
+    setCompositionTask(LottieCompositionFactory.fromJsonInputStream(getContext(), stream, cacheKey));
   }
 
   /**
@@ -559,7 +559,7 @@
    * Auto-closes the stream.
    */
   public void setAnimation(ZipInputStream stream, @Nullable String cacheKey) {
-    setCompositionTask(LottieCompositionFactory.fromZipStream(stream, cacheKey));
+    setCompositionTask(LottieCompositionFactory.fromZipStream(getContext(), stream, cacheKey));
   }
 
   /**
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieCompositionFactory.java b/lottie/src/main/java/com/airbnb/lottie/LottieCompositionFactory.java
index 57b723c..4f1b353 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieCompositionFactory.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieCompositionFactory.java
@@ -10,6 +10,7 @@
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
 import android.graphics.Typeface;
+import android.os.Looper;
 import android.util.Base64;
 
 import androidx.annotation.Nullable;
@@ -136,13 +137,19 @@
     return fromUrl(context, url, "url_" + url);
   }
 
+  public static LottieTask<LottieComposition> fromUrl(final Context context, final String url, @Nullable final String cacheKey) {
+    // App can override {@link Context#getMainLooper()} to support lottie view on per-window ui thread.
+    return fromUrl(context, context.getMainLooper(), url, cacheKey);
+  }
+
   /**
    * Fetch an animation from an http url. Once it is downloaded once, Lottie will cache the file to disk for
    * future use. Because of this, you may call `fromUrl` ahead of time to warm the cache if you think you
    * might need an animation in the future.
    */
-  public static LottieTask<LottieComposition> fromUrl(final Context context, final String url, @Nullable final String cacheKey) {
-    return cache(cacheKey, () -> {
+  public static LottieTask<LottieComposition> fromUrl(
+      final Context context, final Looper uiLooper, final String url, @Nullable final String cacheKey) {
+    return cache(uiLooper, cacheKey, () -> {
       LottieResult<LottieComposition> result = L.networkFetcher(context).fetchSync(context, url, cacheKey);
       if (cacheKey != null && result.getValue() != null) {
         LottieCompositionCache.getInstance().put(cacheKey, result.getValue());
@@ -187,13 +194,18 @@
    * <p>
    * To skip the cache, add null as a third parameter.
    *
-   * @see #fromZipStream(ZipInputStream, String)
+   * @see #fromZipStream(Context, ZipInputStream, String)
    */
   public static LottieTask<LottieComposition> fromAsset(Context context, final String fileName) {
     String cacheKey = "asset_" + fileName;
     return fromAsset(context, fileName, cacheKey);
   }
 
+  public static LottieTask<LottieComposition> fromAsset(Context context, final String fileName, final String cacheKey) {
+    // App can override {@link Context#getMainLooper()} to support lottie view on per-window ui thread.
+    return fromAsset(context, context.getMainLooper(), fileName, cacheKey);
+  }
+
   /**
    * Parse an animation from src/main/assets. It is recommended to use {@link #fromRawRes(Context, int)} instead.
    * The asset file name will be used as a cache key so future usages won't have to parse the json again.
@@ -201,12 +213,12 @@
    * <p>
    * Pass null as the cache key to skip the cache.
    *
-   * @see #fromZipStream(ZipInputStream, String)
+   * @see #fromZipStream(Context, ZipInputStream, String)
    */
-  public static LottieTask<LottieComposition> fromAsset(Context context, final String fileName, @Nullable final String cacheKey) {
+  public static LottieTask<LottieComposition> fromAsset(Context context, Looper uiLooper, final String fileName, @Nullable final String cacheKey) {
     // Prevent accidentally leaking an Activity.
     final Context appContext = context.getApplicationContext();
-    return cache(cacheKey, () -> fromAssetSync(appContext, fileName, cacheKey), null);
+    return cache(uiLooper, cacheKey, () -> fromAssetSync(appContext, fileName, cacheKey), null);
   }
 
   /**
@@ -254,9 +266,11 @@
    *                font family (fFamily) in your animation file.
    */
   public static LottieTask<LottieComposition> fromInputStream(@Nullable Context context, InputStream inputStream, @Nullable String cacheKey) {
+    // App can override {@link Context#getMainLooper()} to support lottie view on per-window ui thread.
+    final Looper uiLooper = context != null ? context.getMainLooper() : Looper.getMainLooper();
     // Prevent accidentally leaking an Activity.
     final Context appContext = context == null ? null : context.getApplicationContext();
-    return cache(cacheKey, () -> fromInputStreamSync(appContext, inputStream, cacheKey), null);
+    return cache(uiLooper, cacheKey, () -> fromInputStreamSync(appContext, inputStream, cacheKey), null);
   }
 
   /**
@@ -309,10 +323,12 @@
    * Pass null as the cache key to skip caching.
    */
   public static LottieTask<LottieComposition> fromRawRes(Context context, @RawRes final int rawRes, @Nullable final String cacheKey) {
+    // App can override {@link Context#getMainLooper()} to support lottie view on per-window ui thread.
+    final Looper uiLooper = context.getMainLooper();
     // Prevent accidentally leaking an Activity.
     final WeakReference<Context> contextRef = new WeakReference<>(context);
     final Context appContext = context.getApplicationContext();
-    return cache(cacheKey, () -> {
+    return cache(uiLooper, cacheKey, () -> {
       @Nullable Context originalContext = contextRef.get();
       Context context1 = originalContext != null ? originalContext : appContext;
       return fromRawResSync(context1, rawRes, cacheKey);
@@ -383,6 +399,11 @@
    *
    * @see #fromJsonInputStreamSync(InputStream, String, boolean)
    */
+  public static LottieTask<LottieComposition> fromJsonInputStream(final Context context, final InputStream stream, @Nullable final String cacheKey) {
+    // App can override {@link Context#getMainLooper()} to support lottie view on per-window ui thread.
+    return cache(context.getMainLooper(), cacheKey, () -> fromJsonInputStreamSync(stream, cacheKey), () -> closeQuietly(stream));
+  }
+
   public static LottieTask<LottieComposition> fromJsonInputStream(final InputStream stream, @Nullable final String cacheKey) {
     return cache(cacheKey, () -> fromJsonInputStreamSync(stream, cacheKey), () -> closeQuietly(stream));
   }
@@ -443,6 +464,11 @@
     return cache(cacheKey, () -> fromJsonStringSync(json, cacheKey), null);
   }
 
+  public static LottieTask<LottieComposition> fromJsonString(final Context context, final String json, @Nullable final String cacheKey) {
+    // App can override {@link Context#getMainLooper()} to support lottie view on per-window ui thread.
+    return cache(context.getMainLooper(), cacheKey, () -> fromJsonStringSync(json, cacheKey), null);
+  }
+
   /**
    * Return a LottieComposition for the specified raw json string.
    * If loading from a file, it is preferable to use the InputStream or rawRes version.
@@ -523,16 +549,21 @@
   /**
    * @see #fromZipStreamSync(Context, ZipInputStream, String)
    */
-  public static LottieTask<LottieComposition> fromZipStream(Context context, final ZipInputStream inputStream, @Nullable final String cacheKey) {
-    return cache(cacheKey, () -> fromZipStreamSync(context, inputStream, cacheKey), () -> closeQuietly(inputStream));
+  public static LottieTask<LottieComposition> fromZipStream(@Nullable Context context, final ZipInputStream inputStream,
+      @Nullable final String cacheKey) {
+    // App can override {@link Context#getMainLooper()} to support lottie view on per-window ui thread.
+    Looper uiLooper = context != null ? context.getMainLooper() : Looper.getMainLooper();
+    return cache(uiLooper, cacheKey, () -> fromZipStreamSync(context, inputStream, cacheKey), () -> closeQuietly(inputStream));
   }
 
   /**
    * @see #fromZipStreamSync(Context, ZipInputStream, String)
    */
-  public static LottieTask<LottieComposition> fromZipStream(Context context, final ZipInputStream inputStream,
+  public static LottieTask<LottieComposition> fromZipStream(@Nullable Context context, final ZipInputStream inputStream,
       @Nullable final String cacheKey, boolean close) {
-    return cache(cacheKey, () -> fromZipStreamSync(context, inputStream, cacheKey), close ? () -> closeQuietly(inputStream) : null);
+    // App can override {@link Context#getMainLooper()} to support lottie view on per-window ui thread.
+    Looper uiLooper = context != null ? context.getMainLooper() : Looper.getMainLooper();
+    return cache(uiLooper, cacheKey, () -> fromZipStreamSync(context, inputStream, cacheKey), close ? () -> closeQuietly(inputStream) : null);
   }
 
   /**
@@ -767,17 +798,22 @@
     return null;
   }
 
+  private static LottieTask<LottieComposition> cache(
+      @Nullable final String cacheKey, Callable<LottieResult<LottieComposition>> callable, @Nullable Runnable onCached) {
+    return cache(Looper.getMainLooper(), cacheKey, callable, onCached);
+  }
+
   /**
    * First, check to see if there are any in-progress tasks associated with the cache key and return it if there is.
    * If not, create a new task for the callable.
    * Then, add the new task to the task cache and set up listeners so it gets cleared when done.
    */
-  private static LottieTask<LottieComposition> cache(@Nullable final String cacheKey, Callable<LottieResult<LottieComposition>> callable,
-      @Nullable Runnable onCached) {
+  private static LottieTask<LottieComposition> cache(
+      final Looper uiLooper, @Nullable final String cacheKey, Callable<LottieResult<LottieComposition>> callable, @Nullable Runnable onCached) {
     LottieTask<LottieComposition> task = null;
     final LottieComposition cachedComposition = cacheKey == null ? null : LottieCompositionCache.getInstance().get(cacheKey);
     if (cachedComposition != null) {
-      task = new LottieTask<>(cachedComposition);
+      task = new LottieTask<>(uiLooper, cachedComposition);
     }
     if (cacheKey != null && taskCache.containsKey(cacheKey)) {
       task = taskCache.get(cacheKey);
@@ -789,7 +825,7 @@
       return task;
     }
 
-    task = new LottieTask<>(callable);
+    task = new LottieTask<>(uiLooper, callable);
     if (cacheKey != null) {
       AtomicBoolean resultAlreadyCalled = new AtomicBoolean(false);
       task.addListener(result -> {
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieTask.java b/lottie/src/main/java/com/airbnb/lottie/LottieTask.java
index 30f9595..07b6d5a 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieTask.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieTask.java
@@ -50,23 +50,35 @@
   /* Preserve add order. */
   private final Set<LottieListener<T>> successListeners = new LinkedHashSet<>(1);
   private final Set<LottieListener<Throwable>> failureListeners = new LinkedHashSet<>(1);
-  private final Handler handler = new Handler(Looper.getMainLooper());
+
+  /**
+   * Handler to notify listener on UI thread. If view is rendered on per-window UI thread, app should override
+   * {@link Context#getMainLooper()} for the {@link WindowContext} that hosts the view tree so that uiHandler
+   * will point to the per-window ui thread.
+   */
+  private final Handler uiHandler;
 
   @Nullable private volatile LottieResult<T> result = null;
 
   @RestrictTo(RestrictTo.Scope.LIBRARY)
-  public LottieTask(Callable<LottieResult<T>> runnable) {
-    this(runnable, false);
+  public LottieTask(Looper uiLooper, Callable<LottieResult<T>> runnable) {
+    this(uiLooper, runnable, false);
   }
 
   public LottieTask(T result) {
+    this(Looper.getMainLooper(), result);
+  }
+
+  public LottieTask(Looper uiLooper, T result) {
+    uiHandler = new Handler(uiLooper);
     setResult(new LottieResult<>(result));
   }
 
   /**
    * runNow is only used for testing.
    */
-  @RestrictTo(RestrictTo.Scope.LIBRARY) LottieTask(Callable<LottieResult<T>> runnable, boolean runNow) {
+  @RestrictTo(RestrictTo.Scope.LIBRARY) LottieTask(Looper uiLooper, Callable<LottieResult<T>> runnable, boolean runNow) {
+    uiHandler = new Handler(uiLooper);
     if (runNow) {
       try {
         setResult(runnable.call());
@@ -145,11 +157,11 @@
   }
 
   private void notifyListeners() {
-    // Listeners should be called on the main thread.
-    if (Looper.myLooper() == Looper.getMainLooper()) {
+    // Listeners should be called on the ui thread.
+    if (Looper.myLooper() == uiHandler.getLooper()) {
       notifyListenersInternal();
     } else {
-      handler.post(this::notifyListenersInternal);
+      uiHandler.post(this::notifyListenersInternal);
     }
   }
 
diff --git a/lottie/src/test/java/com/airbnb/lottie/LottieTaskTest.java b/lottie/src/test/java/com/airbnb/lottie/LottieTaskTest.java
index 0d1fda6..733a394 100644
--- a/lottie/src/test/java/com/airbnb/lottie/LottieTaskTest.java
+++ b/lottie/src/test/java/com/airbnb/lottie/LottieTaskTest.java
@@ -20,6 +20,8 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 
+import android.os.Looper;
+
 public class LottieTaskTest extends BaseTest {
 
   @Mock
@@ -30,9 +32,11 @@
   @Rule
   public MockitoRule rule = MockitoJUnit.rule();
 
+  private final Looper uiLooper = Looper.getMainLooper();
+
   @Test
   public void testListener() {
-    new LottieTask<>(() -> new LottieResult<>(5), true)
+    new LottieTask<>(uiLooper, () -> new LottieResult<>(5), true)
         .addListener(successListener)
         .addFailureListener(failureListener);
     verify(successListener, times(1)).onResult(5);
@@ -42,7 +46,7 @@
   @Test
   public void testException() {
     final IllegalStateException exception = new IllegalStateException("foo");
-    new LottieTask<>((Callable<LottieResult<Integer>>) () -> {
+    new LottieTask<>(uiLooper, (Callable<LottieResult<Integer>>) () -> {
       throw exception;
     }, true)
         .addListener(successListener)
@@ -58,18 +62,10 @@
   @Test
   public void testRemoveListener() {
     final Semaphore lock = new Semaphore(0);
-    LottieTask<Integer> task = new LottieTask<>(new Callable<LottieResult<Integer>>() {
-      @Override public LottieResult<Integer> call() {
-        return new LottieResult<>(5);
-      }
-    })
+    LottieTask<Integer> task = new LottieTask<>(uiLooper, () -> new LottieResult<>(5))
         .addListener(successListener)
         .addFailureListener(failureListener)
-        .addListener(new LottieListener<Integer>() {
-          @Override public void onResult(Integer result) {
-            lock.release();
-          }
-        });
+        .addListener(result -> lock.release());
     task.removeListener(successListener);
     try {
       lock.acquire();
@@ -82,11 +78,7 @@
 
   @Test
   public void testAddListenerAfter() {
-    LottieTask<Integer> task = new LottieTask<>(new Callable<LottieResult<Integer>>() {
-      @Override public LottieResult<Integer> call() {
-        return new LottieResult<>(5);
-      }
-    }, true);
+    LottieTask<Integer> task = new LottieTask<>(uiLooper, () -> new LottieResult<>(5), true);
 
     task.addListener(successListener);
     task.addFailureListener(failureListener);
diff --git a/sample/src/main/kotlin/com/airbnb/lottie/samples/PlayerViewModel.kt b/sample/src/main/kotlin/com/airbnb/lottie/samples/PlayerViewModel.kt
index d68bb36..a30f99a 100644
--- a/sample/src/main/kotlin/com/airbnb/lottie/samples/PlayerViewModel.kt
+++ b/sample/src/main/kotlin/com/airbnb/lottie/samples/PlayerViewModel.kt
@@ -2,7 +2,9 @@
 
 import android.animation.ValueAnimator
 import android.app.Application
+import android.content.Context
 import android.net.Uri
+import android.os.Looper
 import com.airbnb.lottie.LottieComposition
 import com.airbnb.lottie.LottieCompositionFactory
 import com.airbnb.lottie.LottieTask
@@ -38,16 +40,17 @@
 
 class PlayerViewModel(
     initialState: PlayerState,
-    private val application: Application
+    private val application: Application,
+    private val uiLooper: Looper,
 ) : MavericksViewModel<PlayerState>(initialState) {
 
     fun fetchAnimation(args: CompositionArgs) {
         val url = args.url
 
         when {
-            url != null -> LottieCompositionFactory.fromUrl(application, url, null)
+            url != null -> LottieCompositionFactory.fromUrl(application, uiLooper, url, null)
             args.fileUri != null -> taskForUri(args.fileUri)
-            args.asset != null -> LottieCompositionFactory.fromAsset(application, args.asset, null)
+            args.asset != null -> LottieCompositionFactory.fromAsset(application, uiLooper, args.asset, null)
             else -> error("Don't know how to fetch animation for $args")
         }
             .addListener {
@@ -65,7 +68,7 @@
             else -> error("Unknown scheme ${uri.scheme}")
         }
 
-        return LottieCompositionFactory.fromJsonInputStream(fis, null)
+        return LottieCompositionFactory.fromJsonInputStream(application, fis, null)
     }
 
     fun toggleRenderGraphVisible() = setState { copy(renderGraphVisible = !renderGraphVisible) }
@@ -114,7 +117,8 @@
 
     companion object : MavericksViewModelFactory<PlayerViewModel, PlayerState> {
         override fun create(viewModelContext: ViewModelContext, state: PlayerState): PlayerViewModel {
-            return PlayerViewModel(state, viewModelContext.app())
+            // App can override {@link Context#getMainLooper()} to support lottie view on per-window ui thread.
+            return PlayerViewModel(state, viewModelContext.app(), viewModelContext.activity.mainLooper)
         }
     }
 }