1 /*
<lambda>null2  * Copyright 2022 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 // this file provides integration with fonts.google.com, which is called Google Fonts
18 @file:Suppress("MentionsGoogle")
19 
20 package androidx.compose.ui.text.googlefonts
21 
22 import android.content.Context
23 import android.graphics.Typeface
24 import android.os.Handler
25 import android.os.Looper
26 import androidx.annotation.ArrayRes
27 import androidx.annotation.WorkerThread
28 import androidx.compose.ui.text.font.AndroidFont
29 import androidx.compose.ui.text.font.Font
30 import androidx.compose.ui.text.font.FontLoadingStrategy
31 import androidx.compose.ui.text.font.FontStyle
32 import androidx.compose.ui.text.font.FontVariation
33 import androidx.compose.ui.text.font.FontWeight
34 import androidx.core.provider.FontRequest
35 import androidx.core.provider.FontsContractCompat
36 import androidx.core.provider.FontsContractCompat.FontRequestCallback
37 import androidx.core.provider.FontsContractCompat.FontRequestCallback.FAIL_REASON_FONT_LOAD_ERROR
38 import androidx.core.provider.FontsContractCompat.FontRequestCallback.FAIL_REASON_FONT_NOT_FOUND
39 import androidx.core.provider.FontsContractCompat.FontRequestCallback.FAIL_REASON_FONT_UNAVAILABLE
40 import androidx.core.provider.FontsContractCompat.FontRequestCallback.FAIL_REASON_MALFORMED_QUERY
41 import androidx.core.provider.FontsContractCompat.FontRequestCallback.FAIL_REASON_PROVIDER_NOT_FOUND
42 import androidx.core.provider.FontsContractCompat.FontRequestCallback.FAIL_REASON_SECURITY_VIOLATION
43 import androidx.core.provider.FontsContractCompat.FontRequestCallback.FAIL_REASON_WRONG_CERTIFICATES
44 import androidx.core.provider.FontsContractCompat.FontRequestCallback.FontRequestFailReason
45 import java.net.URLEncoder
46 import kotlin.coroutines.resume
47 import kotlinx.coroutines.suspendCancellableCoroutine
48 
49 /**
50  * Load a font from Google Fonts via Downloadable Fonts.
51  *
52  * To learn more about the features supported by Google Fonts, see
53  * [Get Started with the Google Fonts for Android](https://developers.google.com/fonts/docs/android)
54  *
55  * @param googleFont A font to load from fonts.google.com
56  * @param fontProvider configuration for downloadable font provider
57  * @param weight font weight to load
58  * @param style italic or normal font
59  */
60 // contains Google in name because this function provides integration with fonts.google.com
61 @Suppress("MentionsGoogle")
62 fun Font(
63     googleFont: GoogleFont,
64     fontProvider: GoogleFont.Provider,
65     weight: FontWeight = FontWeight.W400,
66     style: FontStyle = FontStyle.Normal
67 ): Font {
68     return GoogleFontImpl(
69         name = googleFont.name,
70         fontProvider = fontProvider,
71         weight = weight,
72         style = style,
73         bestEffort = googleFont.bestEffort
74     )
75 }
76 
77 /**
78  * A downloadable font from fonts.google.com
79  *
80  * To learn more about the features supported by Google Fonts, see
81  * [Get Started with the Google Fonts for Android](https://developers.google.com/fonts/docs/android)
82  *
83  * For a full list of fonts available on Android, see the
84  * [Google Fonts Directory For Android XML](https://fonts.gstatic.com/s/a/directory.xml).
85  *
86  * @param name Name of a font on Google fonts, such as "Roboto" or "Open Sans"
87  * @param bestEffort If besteffort is true and your query specifies a valid family name but the
88  *   requested width/weight/italic value is not supported Google Fonts will return the best match it
89  *   can find within the family. If false, exact matches will be returned only.
90  * @throws IllegalArgumentException if name is empty
91  */
92 // contains Google in name because this function provides integration with fonts.google.com
93 @Suppress("MentionsGoogle")
94 class GoogleFont(val name: String, val bestEffort: Boolean = true) {
95     init {
<lambda>null96         require(name.isNotEmpty()) { "name cannot be empty" }
97     }
98 
99     /**
100      * Attributes used to create a [FontRequest] for a [GoogleFont] based [Font].
101      *
102      * @see FontRequest
103      */
104     // contains Google in name because this function provides integration with fonts.google.com
105     @Suppress("MentionsGoogle")
106     class Provider
107     private constructor(
108         internal val providerAuthority: String,
109         internal val providerPackage: String,
110         internal val certificates: List<List<ByteArray>>?,
111         @ArrayRes internal val certificatesRes: Int
112     ) {
113 
114         /**
115          * Describe a downloadable fonts provider using a list of certificates.
116          *
117          * The font provider is matched by `providerAuthority` and `packageName`, then the resulting
118          * provider has it's certificates validated against `certificates`.
119          *
120          * If the certificates check success, the provider is used for downloadable fonts.
121          *
122          * If the certificates check fails, the provider will not be used and any downloadable fonts
123          * requests configured with it will fail.
124          *
125          * @param providerAuthority The authority of the Font Provider to be used for the request.
126          * @param providerPackage The package for the Font Provider to be used for the request. This
127          *   is used to verify the identity of the provider.
128          * @param certificates The list of sets of hashes for the certificates the provider should
129          *   be signed with. This is used to verify the identity of the provider. Each set in the
130          *   list represents one collection of signature hashes. Refer to your font provider's
131          *   documentation for these values.
132          */
133         constructor(
134             providerAuthority: String,
135             providerPackage: String,
136             certificates: List<List<ByteArray>>
137         ) : this(providerAuthority, providerPackage, certificates, 0)
138 
139         /**
140          * Describe a downloadable fonts provider using a resource array for certificates.
141          *
142          * The font provider is matched by `providerAuthority` and `packageName`, then the resulting
143          * provider has it's certificates validated against `certificates`.
144          *
145          * If the certificates check success, the provider is used for downloadable fonts.
146          *
147          * If the certificates check fails, the provider will not be used and any downloadable fonts
148          * requests configured with it will fail.
149          *
150          * @param providerAuthority The authority of the Font Provider to be used for the request.
151          * @param providerPackage The package for the Font Provider to be used for the request. This
152          *   is used to verify the identity of the provider.
153          * @param certificates A resource array with the list of sets of hashes for the certificates
154          *   the provider should be signed with. This is used to verify the identity of the
155          *   provider. Each set in the list represents one collection of signature hashes. Refer to
156          *   your font provider's documentation for these values.
157          */
158         constructor(
159             providerAuthority: String,
160             providerPackage: String,
161             @ArrayRes certificates: Int
162         ) : this(providerAuthority, providerPackage, null, certificates)
163 
equalsnull164         override fun equals(other: Any?): Boolean {
165             if (this === other) return true
166             if (other !is Provider) return false
167 
168             if (providerAuthority != other.providerAuthority) return false
169             if (providerPackage != other.providerPackage) return false
170             if (certificates != other.certificates) return false
171             if (certificatesRes != other.certificatesRes) return false
172 
173             return true
174         }
175 
hashCodenull176         override fun hashCode(): Int {
177             var result = providerAuthority.hashCode()
178             result = 31 * result + providerPackage.hashCode()
179             result = 31 * result + (certificates?.hashCode() ?: 0)
180             result = 31 * result + certificatesRes
181             return result
182         }
183     }
184 }
185 
186 /**
187  * Check if the downloadable fonts provider is available on device.
188  *
189  * This is not necessary for normal usage, but may be useful in debugging downloadable fonts
190  * behavior.
191  *
192  * @param context for looking up font provider in
193  * @return true if the provider is usable for downloadable fonts, false if it's not found
194  * @throws IllegalStateException if the provider is on device, but certificates don't match
195  */
196 @WorkerThread
GoogleFontnull197 fun GoogleFont.Provider.isAvailableOnDevice(
198     @Suppress("ContextFirst") context: Context, // extension function
199 ): Boolean = checkAvailable(context.packageManager, context.resources)
200 
201 internal data class GoogleFontImpl
202 constructor(
203     val name: String,
204     private val fontProvider: GoogleFont.Provider,
205     override val weight: FontWeight,
206     override val style: FontStyle,
207     val bestEffort: Boolean
208 ) : AndroidFont(FontLoadingStrategy.Async, GoogleFontTypefaceLoader, FontVariation.Settings()) {
209     fun toFontRequest(): FontRequest {
210         // note: name is not encoded or quoted per spec
211         val query =
212             "name=$name&weight=${weight.weight}" +
213                 "&italic=${style.toQueryParam()}&besteffort=${bestEffortQueryParam()}"
214 
215         val certs = fontProvider.certificates
216         return if (certs != null) {
217             FontRequest(fontProvider.providerAuthority, fontProvider.providerPackage, query, certs)
218         } else {
219             FontRequest(
220                 fontProvider.providerAuthority,
221                 fontProvider.providerPackage,
222                 query,
223                 fontProvider.certificatesRes
224             )
225         }
226     }
227 
228     private fun bestEffortQueryParam() = if (bestEffort) "true" else "false"
229 
230     private fun FontStyle.toQueryParam(): Int = if (this == FontStyle.Italic) 1 else 0
231 
232     private fun String.encode() = URLEncoder.encode(this, "UTF-8")
233 
234     fun toTypefaceStyle(): Int {
235         val isItalic = style == FontStyle.Italic
236         val isBold = weight >= FontWeight.Bold
237         return when {
238             isItalic && isBold -> Typeface.BOLD_ITALIC
239             isItalic -> Typeface.ITALIC
240             isBold -> Typeface.BOLD
241             else -> Typeface.NORMAL
242         }
243     }
244 
245     override fun toString(): String {
246         return "Font(GoogleFont(\"$name\", bestEffort=$bestEffort), weight=$weight, " +
247             "style=$style)"
248     }
249 
250     override fun equals(other: Any?): Boolean {
251         if (this === other) return true
252         if (other !is GoogleFontImpl) return false
253 
254         if (name != other.name) return false
255         if (fontProvider != other.fontProvider) return false
256         if (weight != other.weight) return false
257         if (style != other.style) return false
258         if (bestEffort != other.bestEffort) return false
259 
260         return true
261     }
262 
263     override fun hashCode(): Int {
264         var result = name.hashCode()
265         result = 31 * result + fontProvider.hashCode()
266         result = 31 * result + weight.hashCode()
267         result = 31 * result + style.hashCode()
268         result = 31 * result + bestEffort.hashCode()
269         return result
270     }
271 }
272 
273 internal object GoogleFontTypefaceLoader : AndroidFont.TypefaceLoader {
loadBlockingnull274     override fun loadBlocking(context: Context, font: AndroidFont): Typeface? {
275         error("GoogleFont only support async loading: $font")
276     }
277 
awaitLoadnull278     override suspend fun awaitLoad(context: Context, font: AndroidFont): Typeface? {
279         return awaitLoad(context, font, DefaultFontsContractCompatLoader)
280     }
281 
awaitLoadnull282     internal suspend fun awaitLoad(
283         context: Context,
284         font: AndroidFont,
285         loader: FontsContractCompatLoader
286     ): Typeface? {
287         require(font is GoogleFontImpl) { "Only GoogleFontImpl supported (actual $font)" }
288         val fontRequest = font.toFontRequest()
289         val typefaceStyle = font.toTypefaceStyle()
290 
291         return suspendCancellableCoroutine { continuation ->
292             val callback =
293                 object : FontRequestCallback() {
294                     override fun onTypefaceRetrieved(typeface: Typeface?) {
295                         // this is entered from any thread
296                         continuation.resume(typeface)
297                     }
298 
299                     override fun onTypefaceRequestFailed(reason: Int) {
300                         // this is entered from any thread
301                         continuation.cancel(
302                             IllegalStateException(
303                                 "Failed to load $font (reason=$reason, " +
304                                     "${reasonToString(reason)})"
305                             )
306                         )
307                     }
308                 }
309 
310             loader.requestFont(
311                 context = context,
312                 fontRequest = fontRequest,
313                 typefaceStyle = typefaceStyle,
314                 handler = asyncHandlerForCurrentThreadOrMainIfNoLooper(),
315                 callback = callback
316             )
317         }
318     }
319 
asyncHandlerForCurrentThreadOrMainIfNoLoopernull320     private fun asyncHandlerForCurrentThreadOrMainIfNoLooper(): Handler {
321         val looper = Looper.myLooper() ?: Looper.getMainLooper()
322         return HandlerHelper.createAsync(looper)
323     }
324 }
325 
326 /** To allow mocking for tests */
327 internal interface FontsContractCompatLoader {
requestFontnull328     fun requestFont(
329         context: Context,
330         fontRequest: FontRequest,
331         typefaceStyle: Int,
332         handler: Handler,
333         callback: FontRequestCallback
334     )
335 }
336 
337 /** Actual implementation of requestFont using androidx.core */
338 private object DefaultFontsContractCompatLoader : FontsContractCompatLoader {
339     override fun requestFont(
340         context: Context,
341         fontRequest: FontRequest,
342         typefaceStyle: Int,
343         handler: Handler,
344         callback: FontRequestCallback
345     ) {
346         FontsContractCompat.requestFont(
347             context,
348             fontRequest,
349             typefaceStyle,
350             false, /* isBlockingFetch*/
351             0, /* timeout - not used when isBlockingFetch=false */
352             handler,
353             callback
354         )
355     }
356 }
357 
reasonToStringnull358 private fun reasonToString(@FontRequestFailReason reasonCode: Int): String {
359     return when (reasonCode) {
360         FAIL_REASON_PROVIDER_NOT_FOUND -> "The requested provider was not found on this device."
361         FAIL_REASON_WRONG_CERTIFICATES ->
362             "The given provider cannot be authenticated with the " + "certificates given."
363         FAIL_REASON_FONT_LOAD_ERROR ->
364             "Generic error loading font, for example variation " + "settings were not parsable"
365         FAIL_REASON_FONT_NOT_FOUND ->
366             "Font not found, please check availability on " +
367                 "GoogleFont.Provider.AllFontsList: https://fonts.gstatic.com/s/a/directory.xml"
368         FAIL_REASON_FONT_UNAVAILABLE ->
369             "The provider found the queried font, but it is " + "currently unavailable."
370         FAIL_REASON_MALFORMED_QUERY -> "The given query was not supported by this provider."
371         FAIL_REASON_SECURITY_VIOLATION ->
372             "Font was not loaded due to security issues. This " +
373                 "usually means the font was attempted to load in a restricted context"
374         else -> "Unknown error code"
375     }
376 }
377