FontFamilyResolver.kt

/*
 * Copyright 2021 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.ui.text.font

import androidx.compose.runtime.State
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.caches.LruCache
import androidx.compose.ui.text.platform.createSynchronizedObject
import androidx.compose.ui.text.platform.synchronized
import androidx.compose.ui.util.fastMap

@ExperimentalTextApi
internal class FontFamilyResolverImpl(
    internal val platformFontLoader: PlatformFontLoader /* exposed for desktop ParagraphBuilder */,
    private val platformResolveInterceptor: PlatformResolveInterceptor =
        PlatformResolveInterceptor.Default,
    private val typefaceRequestCache: TypefaceRequestCache = GlobalTypefaceRequestCache,
    private val fontListFontFamilyTypefaceAdapter: FontListFontFamilyTypefaceAdapter =
        FontListFontFamilyTypefaceAdapter(GlobalAsyncTypefaceCache),
    private val platformFamilyTypefaceAdapter: PlatformFontFamilyTypefaceAdapter =
        PlatformFontFamilyTypefaceAdapter()
) : FontFamily.Resolver {
    private val createDefaultTypeface: (TypefaceRequest) -> Any = {
        resolve(it.copy(fontFamily = null)).value
    }

    override suspend fun preload(
        fontFamily: FontFamily
    ) {
        // all other types of FontFamily are already preloaded.
        if (fontFamily !is FontListFontFamily) return

        fontListFontFamilyTypefaceAdapter.preload(fontFamily, platformFontLoader)

        val typeRequests = fontFamily.fonts.fastMap {
            TypefaceRequest(
                platformResolveInterceptor.interceptFontFamily(fontFamily),
                platformResolveInterceptor.interceptFontWeight(it.weight),
                platformResolveInterceptor.interceptFontStyle(it.style),
                FontSynthesis.All,
                platformFontLoader.cacheKey
            )
        }

        typefaceRequestCache.preWarmCache(typeRequests) { typeRequest ->
            @Suppress("MoveLambdaOutsideParentheses")
            fontListFontFamilyTypefaceAdapter.resolve(
                typefaceRequest = typeRequest,
                platformFontLoader = platformFontLoader,
                onAsyncCompletion = { /* nothing */ },
                createDefaultTypeface = createDefaultTypeface
            ) ?: platformFamilyTypefaceAdapter.resolve(
                typefaceRequest = typeRequest,
                platformFontLoader = platformFontLoader,
                onAsyncCompletion = { /* nothing */ },
                createDefaultTypeface = createDefaultTypeface
            ) ?: throw IllegalStateException("Could not load font")
        }
    }

    override fun resolve(
        fontFamily: FontFamily?,
        fontWeight: FontWeight,
        fontStyle: FontStyle,
        fontSynthesis: FontSynthesis,
    ): State<Any> {
        return resolve(TypefaceRequest(
            platformResolveInterceptor.interceptFontFamily(fontFamily),
            platformResolveInterceptor.interceptFontWeight(fontWeight),
            platformResolveInterceptor.interceptFontStyle(fontStyle),
            platformResolveInterceptor.interceptFontSynthesis(fontSynthesis),
            platformFontLoader.cacheKey
        ))
    }

    /**
     * Resolves the final [typefaceRequest] without interceptors.
     */
    private fun resolve(typefaceRequest: TypefaceRequest): State<Any> {
        val result = typefaceRequestCache.runCached(typefaceRequest) { onAsyncCompletion ->
            fontListFontFamilyTypefaceAdapter.resolve(
                typefaceRequest,
                platformFontLoader,
                onAsyncCompletion,
                createDefaultTypeface
            ) ?: platformFamilyTypefaceAdapter.resolve(
                typefaceRequest,
                platformFontLoader,
                onAsyncCompletion,
                createDefaultTypeface
            ) ?: throw IllegalStateException("Could not load font")
        }
        return result
    }
}

/**
 * Platform level [FontFamily.Resolver] argument interceptor. This interface is
 * intended to bridge accessibility constraints on any platform with
 * Compose through the use of [FontFamilyResolverImpl.resolve].
 */
internal interface PlatformResolveInterceptor {

    fun interceptFontFamily(fontFamily: FontFamily?): FontFamily? = fontFamily

    fun interceptFontWeight(fontWeight: FontWeight): FontWeight = fontWeight

    fun interceptFontStyle(fontStyle: FontStyle): FontStyle = fontStyle

    fun interceptFontSynthesis(fontSynthesis: FontSynthesis): FontSynthesis = fontSynthesis

    companion object {
        // NO-OP default interceptor
        internal val Default: PlatformResolveInterceptor = object : PlatformResolveInterceptor {}
    }
}

internal val GlobalTypefaceRequestCache = TypefaceRequestCache()
@OptIn(ExperimentalTextApi::class)
internal val GlobalAsyncTypefaceCache = AsyncTypefaceCache()

@ExperimentalTextApi
internal expect class PlatformFontFamilyTypefaceAdapter() : FontFamilyTypefaceAdapter

internal data class TypefaceRequest(
    val fontFamily: FontFamily?,
    val fontWeight: FontWeight,
    val fontStyle: FontStyle,
    val fontSynthesis: FontSynthesis,
    val resourceLoaderCacheKey: Any?
)

internal sealed interface TypefaceResult : State<Any> {
    val cacheable: Boolean
    // Immutable results present as State, but don't trigger a read observer
    class Immutable(
        override val value: Any,
        override val cacheable: Boolean = true
    ) : TypefaceResult

    class Async(internal val current: AsyncFontListLoader) : TypefaceResult, State<Any> by current {
        override val cacheable: Boolean
            get() = current.cacheable
    }
}

internal class TypefaceRequestCache {
    internal val lock = createSynchronizedObject()
    // @GuardedBy("lock")
    private val resultCache = LruCache<TypefaceRequest, TypefaceResult>(16)

    fun runCached(
        typefaceRequest: TypefaceRequest,
        resolveTypeface: ((TypefaceResult) -> Unit) -> TypefaceResult
    ): State<Any> {
        synchronized(lock) {
            resultCache.get(typefaceRequest)?.let {
                if (it.cacheable) {
                    return it
                } else {
                    resultCache.remove(typefaceRequest)
                }
            }
        }
        // this is not run synchronized2 as it incurs expected file system reads.
        //
        // As a result, it is possible the same FontFamily resolution is started twice if this
        // function is entered concurrently. This is explicitly allowed, to avoid creating a global
        // lock here.
        //
        // This function must ensure that the final result is a valid cache in the presence of
        // multiple entries.
        //
        // Necessary font load de-duping is the responsibility of actual font resolution mechanisms.
        val currentTypefaceResult = try {
            resolveTypeface { finalResult ->
                // may this after runCached returns, or immediately if the typeface is immediately
                // available without dispatch

                // this converts an async (state) result to an immutable (val) result to optimize
                // future lookups
                synchronized(lock) {
                    if (finalResult.cacheable) {
                        resultCache.put(typefaceRequest, finalResult)
                    } else {
                        resultCache.remove(typefaceRequest)
                    }
                }
            }
        } catch (cause: Exception) {
            throw IllegalStateException("Could not load font", cause)
        }
        synchronized(lock) {
            // async result may have completed prior to this block entering, do not overwrite
            // final results
            if (resultCache.get(typefaceRequest) == null && currentTypefaceResult.cacheable) {
                resultCache.put(typefaceRequest, currentTypefaceResult)
            }
        }
        return currentTypefaceResult
    }

    fun preWarmCache(
        typefaceRequests: List<TypefaceRequest>,
        resolveTypeface: (TypefaceRequest) -> TypefaceResult
    ) {
        for (i in typefaceRequests.indices) {
            val typeRequest = typefaceRequests[i]

            val prior = synchronized(lock) { resultCache.get(typeRequest) }
            if (prior != null) continue

            val next = try {
                resolveTypeface(typeRequest)
            } catch (cause: Exception) {
                throw IllegalStateException("Could not load font", cause)
            }

            // only cache immutable, should not reach as FontListFontFamilyTypefaceAdapter already
            // has async fonts in permanent cache
            if (next is TypefaceResult.Async) continue

            synchronized(lock) {
                resultCache.put(typeRequest, next)
            }
        }
    }

    // @VisibleForTesting
    internal fun get(typefaceRequest: TypefaceRequest) = synchronized(lock) {
        resultCache.get(typefaceRequest)
    }

    // @VisibleForTesting
    internal val size: Int
        get() = synchronized(lock) {
            resultCache.size
        }
}