GoogleFont.kt

/*
 * Copyright 2022 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.
 */

// this file provides integration with fonts.google.com, which is called Google Fonts
@file:Suppress("MentionsGoogle")

package androidx.compose.ui.text.googlefonts

import android.content.Context
import android.graphics.Typeface
import android.os.Handler
import android.os.Looper
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.AndroidFont
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontLoadingStrategy
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.core.provider.FontRequest
import androidx.core.provider.FontsContractCompat
import java.net.URLEncoder
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine

/**
 * Load a font from Google Fonts via Downloadable Fonts.
 *
 * To learn more about the features supported by Google Fonts, see
 * [Get Started with the Google Fonts for Android](https://developers.google.com/fonts/docs/android)
 *
 * @param name of font such as "Roboto" or "Open Sans"
 * @param fontProvider configuration for downloadable font provider
 * @param weight font weight to load, or weight to closest match if [bestEffort] is true
 * @param style italic or normal font
 * @param bestEffort If besteffort is true and your query specifies a valid family name but the
 * requested width/weight/italic value is not supported Google Fonts will return the best match it
 * can find within the family. If false, exact matches will be returned only.
 */
// contains Google in name because this function provides integration with fonts.google.com
@Suppress("MentionsGoogle")
@ExperimentalTextApi
fun GoogleFont(
    name: String,
    fontProvider: GoogleFontProvider,
    weight: FontWeight = FontWeight.W400,
    style: FontStyle = FontStyle.Normal,
    bestEffort: Boolean = true
): Font {
    require(name.isNotEmpty()) { "name cannot be empty" }
    return GoogleFontImpl(
        name = name,
        fontProvider = fontProvider,
        weight = weight,
        style = style,
        bestEffort = bestEffort
    )
}

/**
 * Attributes used to create a [FontRequest] for a [GoogleFont].
 *
 * @see FontRequest
 */
@ExperimentalTextApi
// contains Google in name because this function provides integration with fonts.google.com
@Suppress("MentionsGoogle")
class GoogleFontProvider(
    internal val providerAuthority: String,
    internal val providerPackage: String,
    internal val certificates: List<List<ByteArray>>
) {

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as GoogleFontProvider

        if (providerAuthority != other.providerAuthority) return false
        if (providerPackage != other.providerPackage) return false
        if (certificates != other.certificates) return false

        return true
    }

    override fun hashCode(): Int {
        var result = providerAuthority.hashCode()
        result = 31 * result + providerPackage.hashCode()
        result = 31 * result + certificates.hashCode()
        return result
    }
}

@ExperimentalTextApi
internal data class GoogleFontImpl constructor(
    val name: String,
    private val fontProvider: GoogleFontProvider,
    override val weight: FontWeight,
    override val style: FontStyle,
    val bestEffort: Boolean
) : AndroidFont(FontLoadingStrategy.Async) {
    override val typefaceLoader: TypefaceLoader
        get() = GoogleFontTypefaceLoader

    fun toFontRequest(): FontRequest {
        val query = "name=${name.encode()}&weight=${weight.weight}" +
            "&italic=${style.toQueryParam()}&besteffort=${bestEffortQueryParam()}"
        return FontRequest(
            fontProvider.providerAuthority,
            fontProvider.providerPackage,
            query,
            fontProvider.certificates
        )
    }

    private fun bestEffortQueryParam() = if (bestEffort) "true" else "false"
    private fun FontStyle.toQueryParam(): Int = if (this == FontStyle.Italic) 1 else 0
    private fun String.encode() = URLEncoder.encode(this, "UTF-8")

    fun toTypefaceStyle(): Int {
        val isItalic = style == FontStyle.Italic
        val isBold = weight >= FontWeight.Bold
        return when {
            isItalic && isBold -> Typeface.BOLD_ITALIC
            isItalic -> Typeface.ITALIC
            isBold -> Typeface.BOLD
            else -> Typeface.NORMAL
        }
    }

    override fun toString(): String {
        return "GoogleFont(name=\"$name\", weight=$weight, style=$style, bestEffort=$bestEffort)"
    }
}

@ExperimentalTextApi
internal object GoogleFontTypefaceLoader : AndroidFont.TypefaceLoader {
    override fun loadBlocking(context: Context, font: AndroidFont): Typeface? {
        error("GoogleFont only support async loading: $font")
    }

    override suspend fun awaitLoad(context: Context, font: AndroidFont): Typeface? {
        return awaitLoad(context, font, DefaultFontsContractCompatLoader)
    }

    internal suspend fun awaitLoad(
        context: Context,
        font: AndroidFont,
        loader: FontsContractCompatLoader
    ): Typeface? {
        require(font is GoogleFontImpl) { "Only GoogleFontImpl supported (actual $font)" }
        val fontRequest = font.toFontRequest()
        val typefaceStyle = font.toTypefaceStyle()

        return suspendCancellableCoroutine { continuation ->
            val callback = object : FontsContractCompat.FontRequestCallback() {
                override fun onTypefaceRetrieved(typeface: Typeface?) {
                    // this is entered from any thread
                    continuation.resume(typeface)
                }

                override fun onTypefaceRequestFailed(reason: Int) {
                    // this is entered from any thread
                    continuation.cancel(
                        IllegalStateException("Failed to load $font (reason=$reason)")
                    )
                }
            }

            loader.requestFont(
                context = context,
                fontRequest = fontRequest,
                typefaceStyle = typefaceStyle,
                handler = asyncHandlerForCurrentThreadOrMainIfNoLooper(),
                callback = callback
            )
        }
    }

    private fun asyncHandlerForCurrentThreadOrMainIfNoLooper(): Handler {
        val looper = Looper.myLooper() ?: Looper.getMainLooper()
        return HandlerHelper.createAsync(looper)
    }
}

/**
 * To allow mocking for tests
 */
internal interface FontsContractCompatLoader {
    fun requestFont(
        context: Context,
        fontRequest: FontRequest,
        typefaceStyle: Int,
        handler: Handler,
        callback: FontsContractCompat.FontRequestCallback
    )
}

/**
 * Actual implementation of requestFont using androidx.core
 */
private object DefaultFontsContractCompatLoader : FontsContractCompatLoader {
    override fun requestFont(
        context: Context,
        fontRequest: FontRequest,
        typefaceStyle: Int,
        handler: Handler,
        callback: FontsContractCompat.FontRequestCallback
    ) {
        FontsContractCompat.requestFont(
            context,
            fontRequest,
            typefaceStyle,
            false, /* isBlockingFetch*/
            0, /* timeout - not used when isBlockingFetch=false */
            handler,
            callback
        )
    }
}