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