1 /*
<lambda>null2  * Copyright 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.ui.text.font
18 
19 import androidx.collection.LruCache
20 import androidx.collection.mutableScatterMapOf
21 import androidx.compose.runtime.State
22 import androidx.compose.runtime.getValue
23 import androidx.compose.runtime.mutableStateOf
24 import androidx.compose.runtime.setValue
25 import androidx.compose.ui.text.platform.FontCacheManagementDispatcher
26 import androidx.compose.ui.text.platform.makeSynchronizedObject
27 import androidx.compose.ui.text.platform.synchronized
28 import androidx.compose.ui.util.fastDistinctBy
29 import androidx.compose.ui.util.fastFilteredMap
30 import androidx.compose.ui.util.fastForEach
31 import androidx.compose.ui.util.fastMap
32 import kotlin.coroutines.CoroutineContext
33 import kotlin.coroutines.EmptyCoroutineContext
34 import kotlin.coroutines.coroutineContext
35 import kotlinx.coroutines.CancellationException
36 import kotlinx.coroutines.CoroutineExceptionHandler
37 import kotlinx.coroutines.CoroutineScope
38 import kotlinx.coroutines.CoroutineStart
39 import kotlinx.coroutines.Job
40 import kotlinx.coroutines.SupervisorJob
41 import kotlinx.coroutines.async
42 import kotlinx.coroutines.coroutineScope
43 import kotlinx.coroutines.isActive
44 import kotlinx.coroutines.joinAll
45 import kotlinx.coroutines.launch
46 import kotlinx.coroutines.withTimeout
47 import kotlinx.coroutines.withTimeoutOrNull
48 import kotlinx.coroutines.yield
49 
50 internal class FontListFontFamilyTypefaceAdapter(
51     private val asyncTypefaceCache: AsyncTypefaceCache = AsyncTypefaceCache(),
52     injectedContext: CoroutineContext = EmptyCoroutineContext
53 ) : FontFamilyTypefaceAdapter {
54 
55     private var asyncLoadScope: CoroutineScope =
56         CoroutineScope(
57             // order is important, we prefer our handler but allow injected to overwrite
58             DropExceptionHandler /* default */ +
59                 FontCacheManagementDispatcher /* default */ +
60                 injectedContext /* from caller */ +
61                 SupervisorJob(injectedContext[Job]) /* forced */
62         )
63 
64     suspend fun preload(family: FontFamily, resourceLoader: PlatformFontLoader) {
65         if (family !is FontListFontFamily) return
66 
67         val allFonts = family.fonts
68         // only preload styles that can be satisfied by async fonts
69         val asyncStyles =
70             family.fonts
71                 .fastFilteredMap({ it.loadingStrategy == FontLoadingStrategy.Async }) {
72                     it.weight to it.style
73                 }
74                 .fastDistinctBy { it }
75 
76         val asyncLoads: MutableList<Font> = mutableListOf()
77 
78         asyncStyles.fastForEach { (fontWeight, fontStyle) ->
79             val matched = fontMatcher.matchFont(allFonts, fontWeight, fontStyle)
80             val typeRequest =
81                 TypefaceRequest(
82                     family,
83                     fontWeight,
84                     fontStyle,
85                     FontSynthesis.All,
86                     resourceLoader.cacheKey
87                 )
88             // this may be satisfied by non-async font still, which is OK as they'll be cached for
89             // immediate lookup by caller
90             //
91             // only do the permanent cache for results provided via async fonts
92             val (asyncFontsToLoad, _) =
93                 matched.firstImmediatelyAvailable(
94                     typeRequest,
95                     asyncTypefaceCache,
96                     resourceLoader,
97                     createDefaultTypeface = {} // unused, no fallback necessary
98                 )
99             if (asyncFontsToLoad != null) {
100                 asyncLoads.add(asyncFontsToLoad.first())
101             }
102         }
103 
104         return coroutineScope {
105             asyncLoads
106                 .fastDistinctBy { it }
107                 .fastMap { font ->
108                     async {
109                         asyncTypefaceCache.runCached(font, resourceLoader, true) {
110                             try {
111                                 withTimeout(Font.MaximumAsyncTimeoutMillis) {
112                                     resourceLoader.awaitLoad(font)
113                                 }
114                             } catch (cause: Exception) {
115                                 throw IllegalStateException("Unable to load font $font", cause)
116                             } ?: throw IllegalStateException("Unable to load font $font")
117                         }
118                     }
119                 }
120                 .joinAll()
121         }
122     }
123 
124     override fun resolve(
125         typefaceRequest: TypefaceRequest,
126         platformFontLoader: PlatformFontLoader,
127         onAsyncCompletion: ((TypefaceResult.Immutable) -> Unit),
128         createDefaultTypeface: (TypefaceRequest) -> Any
129     ): TypefaceResult? {
130         if (typefaceRequest.fontFamily !is FontListFontFamily) return null
131         val matched =
132             fontMatcher.matchFont(
133                 typefaceRequest.fontFamily.fonts,
134                 typefaceRequest.fontWeight,
135                 typefaceRequest.fontStyle
136             )
137         val (asyncFontsToLoad, synthesizedTypeface) =
138             matched.firstImmediatelyAvailable(
139                 typefaceRequest,
140                 asyncTypefaceCache,
141                 platformFontLoader,
142                 createDefaultTypeface
143             )
144         if (asyncFontsToLoad == null) return TypefaceResult.Immutable(synthesizedTypeface)
145         val asyncLoader =
146             AsyncFontListLoader(
147                 fontList = asyncFontsToLoad,
148                 initialType = synthesizedTypeface,
149                 typefaceRequest = typefaceRequest,
150                 asyncTypefaceCache = asyncTypefaceCache,
151                 onCompletion = onAsyncCompletion,
152                 platformFontLoader = platformFontLoader
153             )
154 
155         // Always launch on whatever scope was set prior to this call, and continue until the load
156         // completes.
157         // Launch is undispatched, allowing immediate results to complete this frame if they're
158         // already loaded or can be loaded in a blocking manner (e.g. from disk).
159         asyncLoadScope.launch(start = CoroutineStart.UNDISPATCHED) { asyncLoader.load() }
160         return TypefaceResult.Async(asyncLoader)
161     }
162 
163     companion object {
164         val fontMatcher = FontMatcher()
165         val DropExceptionHandler = CoroutineExceptionHandler { _, _ ->
166             // expected to happen when font load fails during async fallback
167             // safe to ignore (or log)
168         }
169     }
170 }
171 
172 /**
173  * Find the first typeface that is immediately available, as well as any async fonts that are higher
174  * priority in the fallback chain.
175  *
176  * If the List<Font> returned is non-null, it should be used for async fallback resolution with the
177  * current typeface loaded used as the initial typeface.
178  *
179  * @param typefaceRequest type to load
180  * @param asyncTypefaceCache cache for finding pre-loaded async fonts
181  * @param platformFontLoader loader for resolving types from fonts
182  * @return (async fonts to resolve for fallback) to (a typeface that can display this frame)
183  */
firstImmediatelyAvailablenull184 private fun List<Font>.firstImmediatelyAvailable(
185     typefaceRequest: TypefaceRequest,
186     asyncTypefaceCache: AsyncTypefaceCache,
187     platformFontLoader: PlatformFontLoader,
188     createDefaultTypeface: (TypefaceRequest) -> Any
189 ): Pair<List<Font>?, Any> {
190     var asyncFontsToLoad: MutableList<Font>? = null
191     for (idx in indices) {
192         val font = get(idx)
193         when (font.loadingStrategy) {
194             FontLoadingStrategy.Blocking -> {
195                 val result: Any =
196                     asyncTypefaceCache.runCachedBlocking(font, platformFontLoader) {
197                         try {
198                             platformFontLoader.loadBlocking(font)
199                         } catch (cause: Exception) {
200                             createDefaultTypeface(typefaceRequest)
201                         }
202                     } ?: createDefaultTypeface(typefaceRequest)
203                 return asyncFontsToLoad to
204                     typefaceRequest.fontSynthesis.synthesizeTypeface(
205                         result,
206                         font,
207                         typefaceRequest.fontWeight,
208                         typefaceRequest.fontStyle,
209                     )
210             }
211             FontLoadingStrategy.OptionalLocal -> {
212                 val result =
213                     asyncTypefaceCache.runCachedBlocking(font, platformFontLoader) {
214                         // optional fonts should not throw, but consider it a failed load if they do
215                         kotlin.runCatching { platformFontLoader.loadBlocking(font) }.getOrNull()
216                     }
217                 if (result != null) {
218                     return asyncFontsToLoad to
219                         typefaceRequest.fontSynthesis.synthesizeTypeface(
220                             result,
221                             font,
222                             typefaceRequest.fontWeight,
223                             typefaceRequest.fontStyle,
224                         )
225                 }
226             }
227             FontLoadingStrategy.Async -> {
228                 val cacheResult = asyncTypefaceCache.get(font, platformFontLoader)
229                 if (cacheResult == null) {
230                     if (asyncFontsToLoad == null) {
231                         asyncFontsToLoad = mutableListOf(font)
232                     } else {
233                         asyncFontsToLoad.add(font)
234                     }
235                 } else if (cacheResult.isPermanentFailure) {
236                     continue // ignore permanent failure; this font will never load
237                 } else if (cacheResult.result != null) {
238                     // it's not a permanent failure, use it
239                     return asyncFontsToLoad to
240                         typefaceRequest.fontSynthesis.synthesizeTypeface(
241                             cacheResult.result,
242                             font,
243                             typefaceRequest.fontWeight,
244                             typefaceRequest.fontStyle
245                         )
246                 }
247             }
248             else -> throw IllegalStateException("Unknown font type $font")
249         }
250     }
251     // none of the passed fonts match, fall back to platform font
252     val fallbackTypeface = createDefaultTypeface(typefaceRequest)
253     return asyncFontsToLoad to fallbackTypeface
254 }
255 
256 internal class AsyncFontListLoader(
257     private val fontList: List<Font>,
258     initialType: Any,
259     private val typefaceRequest: TypefaceRequest,
260     private val asyncTypefaceCache: AsyncTypefaceCache,
261     private val onCompletion: (TypefaceResult.Immutable) -> Unit,
262     private val platformFontLoader: PlatformFontLoader
263 ) : State<Any> {
264     override var value by mutableStateOf(initialType)
265         private set
266 
267     internal var cacheable = true
268 
loadnull269     suspend fun load() {
270         try {
271             fontList.fastForEach { font ->
272                 // we never have to resolve Blocking or OptionalLocal to complete async resolution.
273                 // if the fonts before async are OptionalLocal, they must all be null
274                 // if the fonts before async are Blocking, this request never happens
275                 // if the fonts after async are Blocking or OptionalLocal, they are already the
276                 //     fallback value and do not need resolved again
277                 // therefore, it is not possible for an async load failure early in the chain to
278                 //     require a new blocking or optional load to resolve
279                 if (font.loadingStrategy == FontLoadingStrategy.Async) {
280                     val typeface =
281                         asyncTypefaceCache.runCached(font, platformFontLoader, false) {
282                             font.loadWithTimeoutOrNull()
283                         }
284                     if (typeface != null) {
285                         value =
286                             typefaceRequest.fontSynthesis.synthesizeTypeface(
287                                 typeface,
288                                 font,
289                                 typefaceRequest.fontWeight,
290                                 typefaceRequest.fontStyle
291                             )
292                         return /* done loading on first successful typeface */
293                     } else {
294                         // check cancellation and yield the thread before trying the next font
295                         yield()
296                     }
297                 }
298             }
299         } finally {
300             // if we walked off the end, then the current value is the final result
301             val shouldCache = coroutineContext.isActive
302             cacheable = false
303             onCompletion.invoke(TypefaceResult.Immutable(value, shouldCache))
304         }
305     }
306 
307     /**
308      * Load a font in a timeout context and ensure that no exception is thrown to caller coroutine.
309      */
loadWithTimeoutOrNullnull310     internal suspend fun Font.loadWithTimeoutOrNull(): Any? {
311         return try {
312             // case 0: load completes - success (non-null)
313             // case 1: we timeout - permanent failure (null)
314             withTimeoutOrNull(Font.MaximumAsyncTimeoutMillis) {
315                 platformFontLoader.awaitLoad(this@loadWithTimeoutOrNull)
316             }
317         } catch (cancel: CancellationException) {
318             // case 2: callee cancels - permanent failure (null)
319             if (coroutineContext.isActive) null else throw cancel
320         } catch (uncaughtFontLoadException: Exception) {
321             // case 3: callee throws another exception - permanent failure (null)
322 
323             // since we're basically acting as a global event loop here, an exception that makes
324             // it to us is "uncaught" and should be loggable
325 
326             // inform uncaught exception handler of the font load failure, so apps may log if
327             // desired
328 
329             // note: this error is not fatal, and we will continue
330             coroutineContext[CoroutineExceptionHandler]?.handleException(
331                 coroutineContext,
332                 IllegalStateException(
333                     "Unable to load font ${this@loadWithTimeoutOrNull}",
334                     uncaughtFontLoadException
335                 )
336             )
337             null
338         }
339     }
340 }
341 
342 /**
343  * A cache for saving async typefaces that have been loaded.
344  *
345  * This stores the non-synthesized type, as returned by the async loader directly.
346  *
347  * All async failures are cached permanently, while successful typefaces may be evicted from the
348  * cache at a fixed size.
349  */
350 internal class AsyncTypefaceCache {
351     @kotlin.jvm.JvmInline
352     internal value class AsyncTypefaceResult(val result: Any?) {
353         val isPermanentFailure: Boolean
354             get() = result == null
355     }
356 
357     private val PermanentFailure = AsyncTypefaceResult(null)
358 
359     internal data class Key(val font: Font, val loaderKey: Any?)
360 
361     // 16 is based on the cache in TypefaceCompat Android, but no firm logic for this size.
362     // After loading, fonts are put into the resultCache to allow reading from a kotlin function
363     // context, reducing async fonts overhead cache lookup overhead only while cached
364     // @GuardedBy("cacheLock")
365     private val resultCache = LruCache<Key, AsyncTypefaceResult>(16)
366     // failures and preloads are permanent, so they are stored separately
367     // @GuardedBy("cacheLock")
368     private val permanentCache = mutableScatterMapOf<Key, AsyncTypefaceResult>()
369 
370     private val cacheLock = makeSynchronizedObject()
371 
putnull372     fun put(
373         font: Font,
374         platformFontLoader: PlatformFontLoader,
375         result: Any?,
376         forever: Boolean = false
377     ) {
378         val key = Key(font, platformFontLoader.cacheKey)
379         synchronized(cacheLock) {
380             when {
381                 result == null -> {
382                     permanentCache[key] = PermanentFailure
383                 }
384                 forever -> {
385                     permanentCache[key] = AsyncTypefaceResult(result)
386                 }
387                 else -> {
388                     resultCache.put(key, AsyncTypefaceResult(result))
389                 }
390             }
391         }
392     }
393 
getnull394     fun get(font: Font, platformFontLoader: PlatformFontLoader): AsyncTypefaceResult? {
395         val key = Key(font, platformFontLoader.cacheKey)
396         return synchronized(cacheLock) { resultCache[key] ?: permanentCache[key] }
397     }
398 
runCachednull399     suspend fun runCached(
400         font: Font,
401         platformFontLoader: PlatformFontLoader,
402         forever: Boolean,
403         block: suspend () -> Any?
404     ): Any? {
405         val key = Key(font, platformFontLoader.cacheKey)
406         synchronized(cacheLock) {
407             val priorResult = resultCache[key] ?: permanentCache[key]
408             if (priorResult != null) {
409                 return priorResult.result
410             }
411         }
412         return block().also {
413             synchronized(cacheLock) {
414                 when {
415                     it == null -> {
416                         permanentCache[key] = PermanentFailure
417                     }
418                     forever -> {
419                         permanentCache[key] = AsyncTypefaceResult(it)
420                     }
421                     else -> {
422                         resultCache.put(key, AsyncTypefaceResult(it))
423                     }
424                 }
425             }
426         }
427     }
428 
runCachedBlockingnull429     inline fun runCachedBlocking(
430         font: Font,
431         platformFontLoader: PlatformFontLoader,
432         block: () -> Any?
433     ): Any? {
434         synchronized(cacheLock) {
435             val key = Key(font, platformFontLoader.cacheKey)
436             val priorResult = resultCache[key] ?: permanentCache[key]
437             if (priorResult != null) {
438                 return priorResult.result
439             }
440         }
441         return block().also { put(font, platformFontLoader, it) }
442     }
443 }
444