[Compose] Parse LottieComposition synchronously instead of using LottieTask (#1888)

Using LottieTask under the hood incurred several extra thread hops including a main thread post. Switching it to a result like this is both faster and also enables custom factories to be used. From my initial tests, this cut the parse time for the heart animation in the repo roughly in half.

Unfortunately, LaunchedTask takes a few ms to start. I tried with rememberCoroutineScope().launch and the initial delay was the same so I'm not sure if there is anything else that can be done here.

Fixes #1880
diff --git a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieCompositionSpec.kt b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieCompositionSpec.kt
index 9444a5b..ba38a5b 100644
--- a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieCompositionSpec.kt
+++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieCompositionSpec.kt
@@ -1,5 +1,7 @@
 package com.airbnb.lottie.compose
 
+import com.airbnb.lottie.LottieComposition
+
 /**
  * Specification for a [com.airbnb.lottie.LottieComposition]. Each subclass represents a different source.
  * A [com.airbnb.lottie.LottieComposition] is the stateless parsed version of a Lottie json file and is
@@ -41,4 +43,9 @@
      * Load an animation from its json string.
      */
     inline class JsonString(val jsonString: String) : LottieCompositionSpec
+
+    /**
+     * Load an animation from a custom factory. This will be called on an IO thread pool.
+     */
+    inline class Custom(val factory: () -> LottieComposition) : LottieCompositionSpec
 }
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 42d3e3c..f4f4187 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
@@ -13,18 +13,15 @@
 import com.airbnb.lottie.LottieComposition
 import com.airbnb.lottie.LottieCompositionFactory
 import com.airbnb.lottie.LottieImageAsset
-import com.airbnb.lottie.LottieTask
+import com.airbnb.lottie.LottieResult
 import com.airbnb.lottie.model.Font
 import com.airbnb.lottie.utils.Logger
 import com.airbnb.lottie.utils.Utils
 import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.suspendCancellableCoroutine
 import kotlinx.coroutines.withContext
 import java.io.FileInputStream
 import java.io.IOException
 import java.util.zip.ZipInputStream
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
 
 /**
  * Use this with [rememberLottieComposition#cacheKey]'s cacheKey parameter to generate a default
@@ -81,6 +78,7 @@
 ): LottieCompositionResult {
     val context = LocalContext.current
     val result by remember(spec) { mutableStateOf(LottieCompositionResultImpl()) }
+
     LaunchedEffect(spec) {
         var exception: Throwable? = null
         var failedCount = 0
@@ -115,58 +113,63 @@
     fontFileExtension: String,
     cacheKey: String?,
 ): LottieComposition {
-    val task = when (spec) {
-        is LottieCompositionSpec.RawRes -> {
-            if (cacheKey == DefaultCacheKey) {
-                LottieCompositionFactory.fromRawRes(context, spec.resId)
-            } else {
-                LottieCompositionFactory.fromRawRes(context, spec.resId, cacheKey)
-            }
-        }
-        is LottieCompositionSpec.Url -> {
-            if (cacheKey == DefaultCacheKey) {
-                LottieCompositionFactory.fromUrl(context, spec.url)
-            } else {
-                LottieCompositionFactory.fromUrl(context, spec.url, cacheKey)
-            }
-        }
-        is LottieCompositionSpec.File -> {
-            val fis = withContext(Dispatchers.IO) {
-                @Suppress("BlockingMethodInNonBlockingContext")
-                FileInputStream(spec.fileName)
-            }
-            when {
-                spec.fileName.endsWith("zip") -> LottieCompositionFactory.fromZipStream(
-                    ZipInputStream(fis),
-                    spec.fileName.takeIf { cacheKey != null },
-                )
-                else -> LottieCompositionFactory.fromJsonInputStream(fis, spec.fileName.takeIf { cacheKey != null })
-            }
-        }
-        is LottieCompositionSpec.Asset -> {
-            if (cacheKey == DefaultCacheKey) {
-                LottieCompositionFactory.fromAsset(context, spec.assetName)
-            } else {
-                LottieCompositionFactory.fromAsset(context, spec.assetName, null)
-            }
-        }
-        is LottieCompositionSpec.JsonString -> {
-            val jsonStringCacheKey = if (cacheKey == DefaultCacheKey) spec.jsonString.hashCode().toString() else cacheKey
-            LottieCompositionFactory.fromJsonString(spec.jsonString, jsonStringCacheKey)
-        }
-    }
+    val result = parseCompositionSync(context, spec, cacheKey)
+    result.exception?.let { throw it }
 
-    val composition = task.await()
+    val composition = result.value!!
     loadImagesFromAssets(context, composition, imageAssetsFolder)
     loadFontsFromAssets(context, composition, fontAssetsFolder, fontFileExtension)
     return composition
 }
 
-private suspend fun <T> LottieTask<T>.await(): T = suspendCancellableCoroutine { cont ->
-    addListener { c ->
-        if (!cont.isCompleted) cont.resume(c)
-    }.addFailureListener { e ->
-        if (!cont.isCompleted) cont.resumeWithException(e)
+private fun parseCompositionSync(
+    context: Context,
+    spec: LottieCompositionSpec,
+    cacheKey: String?,
+): LottieResult<LottieComposition> {
+    return when (spec) {
+        is LottieCompositionSpec.RawRes -> {
+            if (cacheKey == DefaultCacheKey) {
+                LottieCompositionFactory.fromRawResSync(context, spec.resId)
+            } else {
+                LottieCompositionFactory.fromRawResSync(context, spec.resId, cacheKey)
+            }
+        }
+        is LottieCompositionSpec.Url -> {
+            if (cacheKey == DefaultCacheKey) {
+                LottieCompositionFactory.fromUrlSync(context, spec.url)
+            } else {
+                LottieCompositionFactory.fromUrlSync(context, spec.url, cacheKey)
+            }
+        }
+        is LottieCompositionSpec.File -> {
+            val fis = FileInputStream(spec.fileName)
+            when {
+                spec.fileName.endsWith("zip") -> LottieCompositionFactory.fromZipStreamSync(
+                    ZipInputStream(fis),
+                    spec.fileName.takeIf { cacheKey != null },
+                )
+                else -> LottieCompositionFactory.fromJsonInputStreamSync(fis, spec.fileName.takeIf { cacheKey != null })
+            }
+        }
+        is LottieCompositionSpec.Asset -> {
+            if (cacheKey == DefaultCacheKey) {
+                LottieCompositionFactory.fromAssetSync(context, spec.assetName)
+            } else {
+                LottieCompositionFactory.fromAssetSync(context, spec.assetName, null)
+            }
+        }
+        is LottieCompositionSpec.JsonString -> {
+            val jsonStringCacheKey = if (cacheKey == DefaultCacheKey) spec.jsonString.hashCode().toString() else cacheKey
+            LottieCompositionFactory.fromJsonStringSync(spec.jsonString, jsonStringCacheKey)
+        }
+        is LottieCompositionSpec.Custom -> {
+            try {
+                LottieResult(spec.factory())
+            } catch (e: Throwable) {
+                LottieResult<LottieComposition>(e)
+            }
+        }
     }
 }