1/* 2 * Copyright 2018 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 17package androidx.emoji.text; 18 19import android.content.Context; 20import android.content.pm.PackageManager.NameNotFoundException; 21import android.database.ContentObserver; 22import android.graphics.Typeface; 23import android.net.Uri; 24import android.os.Handler; 25import android.os.HandlerThread; 26import android.os.Process; 27import android.os.SystemClock; 28 29import androidx.annotation.GuardedBy; 30import androidx.annotation.NonNull; 31import androidx.annotation.Nullable; 32import androidx.annotation.RequiresApi; 33import androidx.annotation.RestrictTo; 34import androidx.core.graphics.TypefaceCompatUtil; 35import androidx.core.provider.FontRequest; 36import androidx.core.provider.FontsContractCompat; 37import androidx.core.provider.FontsContractCompat.FontFamilyResult; 38import androidx.core.util.Preconditions; 39 40import java.nio.ByteBuffer; 41 42/** 43 * {@link EmojiCompat.Config} implementation that asynchronously fetches the required font and the 44 * metadata using a {@link FontRequest}. FontRequest should be constructed to fetch an EmojiCompat 45 * compatible emoji font. 46 * <p/> 47 */ 48public class FontRequestEmojiCompatConfig extends EmojiCompat.Config { 49 50 /** 51 * Retry policy used when the font provider is not ready to give the font file. 52 * 53 * To control the thread the retries are handled on, see 54 * {@link FontRequestEmojiCompatConfig#setHandler}. 55 */ 56 public abstract static class RetryPolicy { 57 /** 58 * Called each time the metadata loading fails. 59 * 60 * This is primarily due to a pending download of the font. 61 * If a value larger than zero is returned, metadata loader will retry after the given 62 * milliseconds. 63 * <br /> 64 * If {@code zero} is returned, metadata loader will retry immediately. 65 * <br/> 66 * If a value less than 0 is returned, the metadata loader will stop retrying and 67 * EmojiCompat will get into {@link EmojiCompat#LOAD_STATE_FAILED} state. 68 * <p/> 69 * Note that the retry may happen earlier than you specified if the font provider notifies 70 * that the download is completed. 71 * 72 * @return long milliseconds to wait until next retry 73 */ 74 public abstract long getRetryDelay(); 75 } 76 77 /** 78 * A retry policy implementation that doubles the amount of time in between retries. 79 * 80 * If downloading hasn't finish within given amount of time, this policy give up and the 81 * EmojiCompat will get into {@link EmojiCompat#LOAD_STATE_FAILED} state. 82 */ 83 public static class ExponentialBackoffRetryPolicy extends RetryPolicy { 84 private final long mTotalMs; 85 private long mRetryOrigin; 86 87 /** 88 * @param totalMs A total amount of time to wait in milliseconds. 89 */ 90 public ExponentialBackoffRetryPolicy(long totalMs) { 91 mTotalMs = totalMs; 92 } 93 94 @Override 95 public long getRetryDelay() { 96 if (mRetryOrigin == 0) { 97 mRetryOrigin = SystemClock.uptimeMillis(); 98 // Since download may be completed after getting query result and before registering 99 // observer, requesting later at the same time. 100 return 0; 101 } else { 102 // Retry periodically since we can't trust notify change event. Some font provider 103 // may not notify us. 104 final long elapsedMillis = SystemClock.uptimeMillis() - mRetryOrigin; 105 if (elapsedMillis > mTotalMs) { 106 return -1; // Give up since download hasn't finished in 10 min. 107 } 108 // Wait until the same amount of the time from the first scheduled time, but adjust 109 // the minimum request interval is 1 sec and never exceeds 10 min in total. 110 return Math.min(Math.max(elapsedMillis, 1000), mTotalMs - elapsedMillis); 111 } 112 } 113 }; 114 115 /** 116 * @param context Context instance, cannot be {@code null} 117 * @param request {@link FontRequest} to fetch the font asynchronously, cannot be {@code null} 118 */ 119 public FontRequestEmojiCompatConfig(@NonNull Context context, @NonNull FontRequest request) { 120 super(new FontRequestMetadataLoader(context, request, DEFAULT_FONTS_CONTRACT)); 121 } 122 123 /** 124 * @hide 125 */ 126 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) 127 public FontRequestEmojiCompatConfig(@NonNull Context context, @NonNull FontRequest request, 128 @NonNull FontProviderHelper fontProviderHelper) { 129 super(new FontRequestMetadataLoader(context, request, fontProviderHelper)); 130 } 131 132 /** 133 * Sets the custom handler to be used for initialization. 134 * 135 * Since font fetch take longer time, the metadata loader will fetch the fonts on the background 136 * thread. You can pass your own handler for this background fetching. This handler is also used 137 * for retrying. 138 * 139 * @param handler A {@link Handler} to be used for initialization. Can be {@code null}. In case 140 * of {@code null}, the metadata loader creates own {@link HandlerThread} for 141 * initialization. 142 */ 143 public FontRequestEmojiCompatConfig setHandler(Handler handler) { 144 ((FontRequestMetadataLoader) getMetadataRepoLoader()).setHandler(handler); 145 return this; 146 } 147 148 /** 149 * Sets the retry policy. 150 * 151 * {@see RetryPolicy} 152 * @param policy The policy to be used when the font provider is not ready to give the font 153 * file. Can be {@code null}. In case of {@code null}, the metadata loader never 154 * retries. 155 */ 156 public FontRequestEmojiCompatConfig setRetryPolicy(RetryPolicy policy) { 157 ((FontRequestMetadataLoader) getMetadataRepoLoader()).setRetryPolicy(policy); 158 return this; 159 } 160 161 /** 162 * MetadataRepoLoader implementation that uses FontsContractCompat and TypefaceCompat to load a 163 * given FontRequest. 164 */ 165 private static class FontRequestMetadataLoader implements EmojiCompat.MetadataRepoLoader { 166 private final Context mContext; 167 private final FontRequest mRequest; 168 private final FontProviderHelper mFontProviderHelper; 169 170 private final Object mLock = new Object(); 171 @GuardedBy("mLock") 172 private Handler mHandler; 173 @GuardedBy("mLock") 174 private HandlerThread mThread; 175 @GuardedBy("mLock") 176 private @Nullable RetryPolicy mRetryPolicy; 177 178 // Following three variables must be touched only on the thread associated with mHandler. 179 private EmojiCompat.MetadataRepoLoaderCallback mCallback; 180 private ContentObserver mObserver; 181 private Runnable mHandleMetadataCreationRunner; 182 183 FontRequestMetadataLoader(@NonNull Context context, @NonNull FontRequest request, 184 @NonNull FontProviderHelper fontProviderHelper) { 185 Preconditions.checkNotNull(context, "Context cannot be null"); 186 Preconditions.checkNotNull(request, "FontRequest cannot be null"); 187 mContext = context.getApplicationContext(); 188 mRequest = request; 189 mFontProviderHelper = fontProviderHelper; 190 } 191 192 public void setHandler(Handler handler) { 193 synchronized (mLock) { 194 mHandler = handler; 195 } 196 } 197 198 public void setRetryPolicy(RetryPolicy policy) { 199 synchronized (mLock) { 200 mRetryPolicy = policy; 201 } 202 } 203 204 @Override 205 @RequiresApi(19) 206 public void load(@NonNull final EmojiCompat.MetadataRepoLoaderCallback loaderCallback) { 207 Preconditions.checkNotNull(loaderCallback, "LoaderCallback cannot be null"); 208 synchronized (mLock) { 209 if (mHandler == null) { 210 // Developer didn't give a thread for fetching. Create our own one. 211 mThread = new HandlerThread("emojiCompat", Process.THREAD_PRIORITY_BACKGROUND); 212 mThread.start(); 213 mHandler = new Handler(mThread.getLooper()); 214 } 215 mHandler.post(new Runnable() { 216 @Override 217 public void run() { 218 mCallback = loaderCallback; 219 createMetadata(); 220 } 221 }); 222 } 223 } 224 225 private FontsContractCompat.FontInfo retrieveFontInfo() { 226 final FontsContractCompat.FontFamilyResult result; 227 try { 228 result = mFontProviderHelper.fetchFonts(mContext, mRequest); 229 } catch (NameNotFoundException e) { 230 throw new RuntimeException("provider not found", e); 231 } 232 if (result.getStatusCode() != FontsContractCompat.FontFamilyResult.STATUS_OK) { 233 throw new RuntimeException("fetchFonts failed (" + result.getStatusCode() + ")"); 234 } 235 final FontsContractCompat.FontInfo[] fonts = result.getFonts(); 236 if (fonts == null || fonts.length == 0) { 237 throw new RuntimeException("fetchFonts failed (empty result)"); 238 } 239 return fonts[0]; // Assuming the GMS Core provides only one font file. 240 } 241 242 // Must be called on the mHandler. 243 @RequiresApi(19) 244 private void scheduleRetry(Uri uri, long waitMs) { 245 synchronized (mLock) { 246 if (mObserver == null) { 247 mObserver = new ContentObserver(mHandler) { 248 @Override 249 public void onChange(boolean selfChange, Uri uri) { 250 createMetadata(); 251 } 252 }; 253 mFontProviderHelper.registerObserver(mContext, uri, mObserver); 254 } 255 if (mHandleMetadataCreationRunner == null) { 256 mHandleMetadataCreationRunner = new Runnable() { 257 @Override 258 public void run() { 259 createMetadata(); 260 } 261 }; 262 } 263 mHandler.postDelayed(mHandleMetadataCreationRunner, waitMs); 264 } 265 } 266 267 // Must be called on the mHandler. 268 private void cleanUp() { 269 mCallback = null; 270 if (mObserver != null) { 271 mFontProviderHelper.unregisterObserver(mContext, mObserver); 272 mObserver = null; 273 } 274 synchronized (mLock) { 275 mHandler.removeCallbacks(mHandleMetadataCreationRunner); 276 if (mThread != null) { 277 mThread.quit(); 278 } 279 mHandler = null; 280 mThread = null; 281 } 282 } 283 284 // Must be called on the mHandler. 285 @RequiresApi(19) 286 private void createMetadata() { 287 if (mCallback == null) { 288 return; // Already handled or cancelled. Do nothing. 289 } 290 try { 291 final FontsContractCompat.FontInfo font = retrieveFontInfo(); 292 293 final int resultCode = font.getResultCode(); 294 if (resultCode == FontsContractCompat.Columns.RESULT_CODE_FONT_UNAVAILABLE) { 295 // The font provider is now downloading. Ask RetryPolicy for when to retry next. 296 synchronized (mLock) { 297 if (mRetryPolicy != null) { 298 final long delayMs = mRetryPolicy.getRetryDelay(); 299 if (delayMs >= 0) { 300 scheduleRetry(font.getUri(), delayMs); 301 return; 302 } 303 } 304 } 305 } 306 307 if (resultCode != FontsContractCompat.Columns.RESULT_CODE_OK) { 308 throw new RuntimeException("fetchFonts result is not OK. (" + resultCode + ")"); 309 } 310 311 // TODO: Good to add new API to create Typeface from FD not to open FD twice. 312 final Typeface typeface = mFontProviderHelper.buildTypeface(mContext, font); 313 final ByteBuffer buffer = TypefaceCompatUtil.mmap(mContext, null, font.getUri()); 314 if (buffer == null) { 315 throw new RuntimeException("Unable to open file."); 316 } 317 mCallback.onLoaded(MetadataRepo.create(typeface, buffer)); 318 cleanUp(); 319 } catch (Throwable t) { 320 mCallback.onFailed(t); 321 cleanUp(); 322 } 323 } 324 } 325 326 /** 327 * Delegate class for mocking FontsContractCompat.fetchFonts. 328 * @hide 329 */ 330 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) 331 public static class FontProviderHelper { 332 /** Calls FontsContractCompat.fetchFonts. */ 333 public FontFamilyResult fetchFonts(@NonNull Context context, 334 @NonNull FontRequest request) throws NameNotFoundException { 335 return FontsContractCompat.fetchFonts(context, null /* cancellation signal */, request); 336 } 337 338 /** Calls FontsContractCompat.buildTypeface. */ 339 public Typeface buildTypeface(@NonNull Context context, 340 @NonNull FontsContractCompat.FontInfo font) throws NameNotFoundException { 341 return FontsContractCompat.buildTypeface(context, null /* cancellation signal */, 342 new FontsContractCompat.FontInfo[] { font }); 343 } 344 345 /** Calls Context.getContentObserver().registerObserver */ 346 public void registerObserver(@NonNull Context context, @NonNull Uri uri, 347 @NonNull ContentObserver observer) { 348 context.getContentResolver().registerContentObserver( 349 uri, false /* notifyForDescendants */, observer); 350 351 } 352 /** Calls Context.getContentObserver().unregisterObserver */ 353 public void unregisterObserver(@NonNull Context context, 354 @NonNull ContentObserver observer) { 355 context.getContentResolver().unregisterContentObserver(observer); 356 } 357 }; 358 359 private static final FontProviderHelper DEFAULT_FONTS_CONTRACT = new FontProviderHelper(); 360 361} 362