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