<lambda>null1 package com.airbnb.lottie.compose
2 
3 import android.content.Context
4 import android.graphics.BitmapFactory
5 import android.graphics.Typeface
6 import android.util.Base64
7 import androidx.compose.runtime.Composable
8 import androidx.compose.runtime.LaunchedEffect
9 import androidx.compose.runtime.getValue
10 import androidx.compose.runtime.mutableStateOf
11 import androidx.compose.runtime.remember
12 import androidx.compose.ui.platform.LocalContext
13 import com.airbnb.lottie.LottieComposition
14 import com.airbnb.lottie.LottieCompositionFactory
15 import com.airbnb.lottie.LottieImageAsset
16 import com.airbnb.lottie.LottieTask
17 import com.airbnb.lottie.model.Font
18 import com.airbnb.lottie.utils.Logger
19 import com.airbnb.lottie.utils.Utils
20 import kotlinx.coroutines.Dispatchers
21 import kotlinx.coroutines.suspendCancellableCoroutine
22 import kotlinx.coroutines.withContext
23 import java.io.FileInputStream
24 import java.io.IOException
25 import java.util.zip.ZipInputStream
26 import kotlin.coroutines.resume
27 import kotlin.coroutines.resumeWithException
28 
29 /**
30  * Use this with [rememberLottieComposition#cacheKey]'s cacheKey parameter to generate a default
31  * cache key for the composition.
32  */
33 private const val DefaultCacheKey = "__LottieInternalDefaultCacheKey__"
34 
35 /**
36  * Takes a [LottieCompositionSpec], attempts to load and parse the animation, and returns a [LottieCompositionResult].
37  *
38  * [LottieCompositionResult] allows you to explicitly check for loading, failures, call
39  * [LottieCompositionResult.await], or invoke it like a function to get the nullable composition.
40  *
41  * [LottieCompositionResult] implements State<LottieComposition?> so if you don't need the full result class,
42  * you can use this function like:
43  * ```
44  * val compositionResult: LottieCompositionResult = lottieComposition(spec)
45  * // or...
46  * val composition: State<LottieComposition?> by lottieComposition(spec)
47  * ```
48  *
49  * The loaded composition will automatically load and set images that are embedded in the json as a base64 string
50  * or will load them from assets if an imageAssetsFolder is supplied.
51  *
52  * @param spec The [LottieCompositionSpec] that defines which LottieComposition should be loaded.
53  * @param imageAssetsFolder A subfolder in `src/main/assets` that contains the exported images
54  *                          that this composition uses. DO NOT rename any images from your design tool. The
55  *                          filenames must match the values that are in your json file.
56  * @param fontAssetsFolder The default folder Lottie will look in to find font files. Fonts will be matched
57  *                         based on the family name specified in the Lottie json file.
58  *                         Defaults to "fonts/" so if "Helvetica" was in the Json file, Lottie will auto-match
59  *                         fonts located at "src/main/assets/fonts/Helvetica.ttf". Missing fonts will be skipped
60  *                         and should be set via fontRemapping or via dynamic properties.
61  * @param fontFileExtension The default file extension for font files specified in the fontAssetsFolder or fontRemapping.
62  *                          Defaults to ttf.
63  * @param cacheKey Set a cache key for this composition. When set, subsequent calls to fetch this composition will
64  *                 return directly from the cache instead of having to reload and parse the animation. Set this to
65  *                 null to skip the cache. By default, this will automatically generate a cache key derived
66  *                 from your [LottieCompositionSpec].
67  * @param onRetry An optional callback that will be called if loading the animation fails.
68  *                It is passed the failed count (the number of times it has failed) and the exception
69  *                from the previous attempt to load the composition. [onRetry] is a suspending function
70  *                so you can do things like add a backoff delay or await an internet connection before
71  *                retrying again. [rememberLottieRetrySignal] can be used to handle explicit retires.
72  */
73 @Composable
74 @JvmOverloads
75 fun rememberLottieComposition(
76     spec: LottieCompositionSpec,
77     imageAssetsFolder: String? = null,
78     fontAssetsFolder: String = "fonts/",
79     fontFileExtension: String = ".ttf",
80     cacheKey: String? = DefaultCacheKey,
81     onRetry: suspend (failCount: Int, previousException: Throwable) -> Boolean = { _, _ -> false },
82 ): LottieCompositionResult {
83     val context = LocalContext.current
<lambda>null84     val result by remember(spec) { mutableStateOf(LottieCompositionResultImpl()) }
85     // Warm the task cache. We can start the parsing task before the LaunchedEffect gets dispatched and run.
86     // The LaunchedEffect task will join the task created inline here via LottieCompositionFactory's task cache.
<lambda>null87     remember(spec, cacheKey) { lottieTask(context, spec, cacheKey, isWarmingCache = true) }
<lambda>null88     LaunchedEffect(spec, cacheKey) {
89         var exception: Throwable? = null
90         var failedCount = 0
91         while (!result.isSuccess && (failedCount == 0 || onRetry(failedCount, exception!!))) {
92             try {
93                 val composition = lottieComposition(
94                     context,
95                     spec,
96                     imageAssetsFolder.ensureTrailingSlash(),
97                     fontAssetsFolder.ensureTrailingSlash(),
98                     fontFileExtension.ensureLeadingPeriod(),
99                     cacheKey,
100                 )
101                 result.complete(composition)
102             } catch (e: Throwable) {
103                 exception = e
104                 failedCount++
105             }
106         }
107         if (!result.isComplete && exception != null) {
108             result.completeExceptionally(exception)
109         }
110     }
111     return result
112 }
113 
lottieCompositionnull114 private suspend fun lottieComposition(
115     context: Context,
116     spec: LottieCompositionSpec,
117     imageAssetsFolder: String?,
118     fontAssetsFolder: String?,
119     fontFileExtension: String,
120     cacheKey: String?,
121 ): LottieComposition {
122     val task = requireNotNull(lottieTask(context, spec, cacheKey, isWarmingCache = false)) {
123         "Unable to create parsing task for $spec."
124     }
125 
126     val composition = task.await()
127     loadImagesFromAssets(context, composition, imageAssetsFolder)
128     loadFontsFromAssets(context, composition, fontAssetsFolder, fontFileExtension)
129     return composition
130 }
131 
lottieTasknull132 private fun lottieTask(
133     context: Context,
134     spec: LottieCompositionSpec,
135     cacheKey: String?,
136     isWarmingCache: Boolean,
137 ): LottieTask<LottieComposition>? {
138     return when (spec) {
139         is LottieCompositionSpec.RawRes -> {
140             if (cacheKey == DefaultCacheKey) {
141                 LottieCompositionFactory.fromRawRes(context, spec.resId)
142             } else {
143                 LottieCompositionFactory.fromRawRes(context, spec.resId, cacheKey)
144             }
145         }
146         is LottieCompositionSpec.Url -> {
147             if (cacheKey == DefaultCacheKey) {
148                 LottieCompositionFactory.fromUrl(context, spec.url)
149             } else {
150                 LottieCompositionFactory.fromUrl(context, spec.url, cacheKey)
151             }
152         }
153         is LottieCompositionSpec.File -> {
154             if (isWarmingCache) {
155                 // Warming the cache is done from the main thread so we can't
156                 // create the FileInputStream needed in this path.
157                 null
158             } else {
159                 val fis = FileInputStream(spec.fileName)
160                 when {
161                     spec.fileName.endsWith("zip") -> LottieCompositionFactory.fromZipStream(
162                         ZipInputStream(fis),
163                         if (cacheKey == DefaultCacheKey) spec.fileName else cacheKey,
164                     )
165                     else -> LottieCompositionFactory.fromJsonInputStream(
166                         fis,
167                         if (cacheKey == DefaultCacheKey) spec.fileName else cacheKey,
168                     )
169                 }
170             }
171         }
172         is LottieCompositionSpec.Asset -> {
173             if (cacheKey == DefaultCacheKey) {
174                 LottieCompositionFactory.fromAsset(context, spec.assetName)
175             } else {
176                 LottieCompositionFactory.fromAsset(context, spec.assetName, cacheKey)
177             }
178         }
179         is LottieCompositionSpec.JsonString -> {
180             val jsonStringCacheKey = if (cacheKey == DefaultCacheKey) spec.jsonString.hashCode().toString() else cacheKey
181             LottieCompositionFactory.fromJsonString(spec.jsonString, jsonStringCacheKey)
182         }
183         is LottieCompositionSpec.ContentProvider -> {
184             val inputStream = context.contentResolver.openInputStream(spec.uri)
185             LottieCompositionFactory.fromJsonInputStream(inputStream, if (cacheKey == DefaultCacheKey) spec.uri.toString() else cacheKey)
186         }
187     }
188 }
189 
awaitnull190 private suspend fun <T> LottieTask<T>.await(): T = suspendCancellableCoroutine { cont ->
191     addListener { c ->
192         if (!cont.isCompleted) cont.resume(c)
193     }.addFailureListener { e ->
194         if (!cont.isCompleted) cont.resumeWithException(e)
195     }
196 }
197 
loadImagesFromAssetsnull198 private suspend fun loadImagesFromAssets(
199     context: Context,
200     composition: LottieComposition,
201     imageAssetsFolder: String?,
202 ) {
203     if (!composition.hasImages()) {
204         return
205     }
206     withContext(Dispatchers.IO) {
207         for (asset in composition.images.values) {
208             maybeDecodeBase64Image(asset)
209             maybeLoadImageFromAsset(context, asset, imageAssetsFolder)
210         }
211     }
212 }
213 
maybeLoadImageFromAssetnull214 private fun maybeLoadImageFromAsset(
215     context: Context,
216     asset: LottieImageAsset,
217     imageAssetsFolder: String?,
218 ) {
219     if (asset.bitmap != null || imageAssetsFolder == null) return
220     val filename = asset.fileName
221     val inputStream = try {
222         context.assets.open(imageAssetsFolder + filename)
223     } catch (e: IOException) {
224         Logger.warning("Unable to open asset.", e)
225         return
226     }
227     try {
228         val opts = BitmapFactory.Options()
229         opts.inScaled = true
230         opts.inDensity = 160
231         var bitmap = BitmapFactory.decodeStream(inputStream, null, opts)
232         bitmap = Utils.resizeBitmapIfNeeded(bitmap, asset.width, asset.height)
233         asset.bitmap = bitmap
234     } catch (e: IllegalArgumentException) {
235         Logger.warning("Unable to decode image.", e)
236     }
237 }
238 
maybeDecodeBase64Imagenull239 private fun maybeDecodeBase64Image(asset: LottieImageAsset) {
240     if (asset.bitmap != null) return
241     val filename = asset.fileName
242     if (filename.startsWith("data:") && filename.indexOf("base64,") > 0) {
243         // Contents look like a base64 data URI, with the format data:image/png;base64,<data>.
244         try {
245             val data = Base64.decode(filename.substring(filename.indexOf(',') + 1), Base64.DEFAULT)
246             val opts = BitmapFactory.Options()
247             opts.inScaled = true
248             opts.inDensity = 160
249             asset.bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opts)
250         } catch (e: IllegalArgumentException) {
251             Logger.warning("data URL did not have correct base64 format.", e)
252         }
253     }
254 }
255 
loadFontsFromAssetsnull256 private suspend fun loadFontsFromAssets(
257     context: Context,
258     composition: LottieComposition,
259     fontAssetsFolder: String?,
260     fontFileExtension: String,
261 ) {
262     if (composition.fonts.isEmpty()) return
263     withContext(Dispatchers.IO) {
264         for (font in composition.fonts.values) {
265             maybeLoadTypefaceFromAssets(context, font, fontAssetsFolder, fontFileExtension)
266         }
267     }
268 }
269 
maybeLoadTypefaceFromAssetsnull270 private fun maybeLoadTypefaceFromAssets(
271     context: Context,
272     font: Font,
273     fontAssetsFolder: String?,
274     fontFileExtension: String,
275 ) {
276     val path = "$fontAssetsFolder${font.family}${fontFileExtension}"
277     val typefaceWithDefaultStyle = try {
278         Typeface.createFromAsset(context.assets, path)
279     } catch (e: Exception) {
280         Logger.error("Failed to find typeface in assets with path $path.", e)
281         return
282     }
283     try {
284         val typefaceWithStyle = typefaceForStyle(typefaceWithDefaultStyle, font.style)
285         font.typeface = typefaceWithStyle
286     } catch (e: Exception) {
287         Logger.error("Failed to create ${font.family} typeface with style=${font.style}!", e)
288     }
289 }
290 
typefaceForStylenull291 private fun typefaceForStyle(typeface: Typeface, style: String): Typeface? {
292     val containsItalic = style.contains("Italic")
293     val containsBold = style.contains("Bold")
294     val styleInt = when {
295         containsItalic && containsBold -> Typeface.BOLD_ITALIC
296         containsItalic -> Typeface.ITALIC
297         containsBold -> Typeface.BOLD
298         else -> Typeface.NORMAL
299     }
300     return if (typeface.style == styleInt) typeface else Typeface.create(typeface, styleInt)
301 }
302 
ensureTrailingSlashnull303 private fun String?.ensureTrailingSlash(): String? = when {
304     isNullOrBlank() -> null
305     endsWith('/') -> this
306     else -> "$this/"
307 }
308 
Stringnull309 private fun String.ensureLeadingPeriod(): String = when {
310     isBlank() -> this
311     startsWith(".") -> this
312     else -> ".$this"
313 }