| /* |
| * Copyright 2018 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.emoji.text; |
| |
| import android.content.Context; |
| import android.content.pm.PackageManager.NameNotFoundException; |
| import android.database.ContentObserver; |
| import android.graphics.Typeface; |
| import android.net.Uri; |
| import android.os.Handler; |
| import android.os.HandlerThread; |
| import android.os.Process; |
| import android.os.SystemClock; |
| |
| import androidx.annotation.GuardedBy; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.RequiresApi; |
| import androidx.annotation.RestrictTo; |
| import androidx.core.graphics.TypefaceCompatUtil; |
| import androidx.core.provider.FontRequest; |
| import androidx.core.provider.FontsContractCompat; |
| import androidx.core.provider.FontsContractCompat.FontFamilyResult; |
| import androidx.core.util.Preconditions; |
| |
| import java.nio.ByteBuffer; |
| |
| /** |
| * {@link EmojiCompat.Config} implementation that asynchronously fetches the required font and the |
| * metadata using a {@link FontRequest}. FontRequest should be constructed to fetch an EmojiCompat |
| * compatible emoji font. |
| * <p/> |
| */ |
| public class FontRequestEmojiCompatConfig extends EmojiCompat.Config { |
| |
| /** |
| * Retry policy used when the font provider is not ready to give the font file. |
| * |
| * To control the thread the retries are handled on, see |
| * {@link FontRequestEmojiCompatConfig#setHandler}. |
| */ |
| public abstract static class RetryPolicy { |
| /** |
| * Called each time the metadata loading fails. |
| * |
| * This is primarily due to a pending download of the font. |
| * If a value larger than zero is returned, metadata loader will retry after the given |
| * milliseconds. |
| * <br /> |
| * If {@code zero} is returned, metadata loader will retry immediately. |
| * <br/> |
| * If a value less than 0 is returned, the metadata loader will stop retrying and |
| * EmojiCompat will get into {@link EmojiCompat#LOAD_STATE_FAILED} state. |
| * <p/> |
| * Note that the retry may happen earlier than you specified if the font provider notifies |
| * that the download is completed. |
| * |
| * @return long milliseconds to wait until next retry |
| */ |
| public abstract long getRetryDelay(); |
| } |
| |
| /** |
| * A retry policy implementation that doubles the amount of time in between retries. |
| * |
| * If downloading hasn't finish within given amount of time, this policy give up and the |
| * EmojiCompat will get into {@link EmojiCompat#LOAD_STATE_FAILED} state. |
| */ |
| public static class ExponentialBackoffRetryPolicy extends RetryPolicy { |
| private final long mTotalMs; |
| private long mRetryOrigin; |
| |
| /** |
| * @param totalMs A total amount of time to wait in milliseconds. |
| */ |
| public ExponentialBackoffRetryPolicy(long totalMs) { |
| mTotalMs = totalMs; |
| } |
| |
| @Override |
| public long getRetryDelay() { |
| if (mRetryOrigin == 0) { |
| mRetryOrigin = SystemClock.uptimeMillis(); |
| // Since download may be completed after getting query result and before registering |
| // observer, requesting later at the same time. |
| return 0; |
| } else { |
| // Retry periodically since we can't trust notify change event. Some font provider |
| // may not notify us. |
| final long elapsedMillis = SystemClock.uptimeMillis() - mRetryOrigin; |
| if (elapsedMillis > mTotalMs) { |
| return -1; // Give up since download hasn't finished in 10 min. |
| } |
| // Wait until the same amount of the time from the first scheduled time, but adjust |
| // the minimum request interval is 1 sec and never exceeds 10 min in total. |
| return Math.min(Math.max(elapsedMillis, 1000), mTotalMs - elapsedMillis); |
| } |
| } |
| }; |
| |
| /** |
| * @param context Context instance, cannot be {@code null} |
| * @param request {@link FontRequest} to fetch the font asynchronously, cannot be {@code null} |
| */ |
| public FontRequestEmojiCompatConfig(@NonNull Context context, @NonNull FontRequest request) { |
| super(new FontRequestMetadataLoader(context, request, DEFAULT_FONTS_CONTRACT)); |
| } |
| |
| /** |
| * @hide |
| */ |
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public FontRequestEmojiCompatConfig(@NonNull Context context, @NonNull FontRequest request, |
| @NonNull FontProviderHelper fontProviderHelper) { |
| super(new FontRequestMetadataLoader(context, request, fontProviderHelper)); |
| } |
| |
| /** |
| * Sets the custom handler to be used for initialization. |
| * |
| * Since font fetch take longer time, the metadata loader will fetch the fonts on the background |
| * thread. You can pass your own handler for this background fetching. This handler is also used |
| * for retrying. |
| * |
| * @param handler A {@link Handler} to be used for initialization. Can be {@code null}. In case |
| * of {@code null}, the metadata loader creates own {@link HandlerThread} for |
| * initialization. |
| */ |
| public FontRequestEmojiCompatConfig setHandler(Handler handler) { |
| ((FontRequestMetadataLoader) getMetadataRepoLoader()).setHandler(handler); |
| return this; |
| } |
| |
| /** |
| * Sets the retry policy. |
| * |
| * {@see RetryPolicy} |
| * @param policy The policy to be used when the font provider is not ready to give the font |
| * file. Can be {@code null}. In case of {@code null}, the metadata loader never |
| * retries. |
| */ |
| public FontRequestEmojiCompatConfig setRetryPolicy(RetryPolicy policy) { |
| ((FontRequestMetadataLoader) getMetadataRepoLoader()).setRetryPolicy(policy); |
| return this; |
| } |
| |
| /** |
| * MetadataRepoLoader implementation that uses FontsContractCompat and TypefaceCompat to load a |
| * given FontRequest. |
| */ |
| private static class FontRequestMetadataLoader implements EmojiCompat.MetadataRepoLoader { |
| private final Context mContext; |
| private final FontRequest mRequest; |
| private final FontProviderHelper mFontProviderHelper; |
| |
| private final Object mLock = new Object(); |
| @GuardedBy("mLock") |
| private Handler mHandler; |
| @GuardedBy("mLock") |
| private HandlerThread mThread; |
| @GuardedBy("mLock") |
| private @Nullable RetryPolicy mRetryPolicy; |
| |
| // Following three variables must be touched only on the thread associated with mHandler. |
| private EmojiCompat.MetadataRepoLoaderCallback mCallback; |
| private ContentObserver mObserver; |
| private Runnable mHandleMetadataCreationRunner; |
| |
| FontRequestMetadataLoader(@NonNull Context context, @NonNull FontRequest request, |
| @NonNull FontProviderHelper fontProviderHelper) { |
| Preconditions.checkNotNull(context, "Context cannot be null"); |
| Preconditions.checkNotNull(request, "FontRequest cannot be null"); |
| mContext = context.getApplicationContext(); |
| mRequest = request; |
| mFontProviderHelper = fontProviderHelper; |
| } |
| |
| public void setHandler(Handler handler) { |
| synchronized (mLock) { |
| mHandler = handler; |
| } |
| } |
| |
| public void setRetryPolicy(RetryPolicy policy) { |
| synchronized (mLock) { |
| mRetryPolicy = policy; |
| } |
| } |
| |
| @Override |
| @RequiresApi(19) |
| public void load(@NonNull final EmojiCompat.MetadataRepoLoaderCallback loaderCallback) { |
| Preconditions.checkNotNull(loaderCallback, "LoaderCallback cannot be null"); |
| synchronized (mLock) { |
| if (mHandler == null) { |
| // Developer didn't give a thread for fetching. Create our own one. |
| mThread = new HandlerThread("emojiCompat", Process.THREAD_PRIORITY_BACKGROUND); |
| mThread.start(); |
| mHandler = new Handler(mThread.getLooper()); |
| } |
| mHandler.post(new Runnable() { |
| @Override |
| public void run() { |
| mCallback = loaderCallback; |
| createMetadata(); |
| } |
| }); |
| } |
| } |
| |
| private FontsContractCompat.FontInfo retrieveFontInfo() { |
| final FontsContractCompat.FontFamilyResult result; |
| try { |
| result = mFontProviderHelper.fetchFonts(mContext, mRequest); |
| } catch (NameNotFoundException e) { |
| throw new RuntimeException("provider not found", e); |
| } |
| if (result.getStatusCode() != FontsContractCompat.FontFamilyResult.STATUS_OK) { |
| throw new RuntimeException("fetchFonts failed (" + result.getStatusCode() + ")"); |
| } |
| final FontsContractCompat.FontInfo[] fonts = result.getFonts(); |
| if (fonts == null || fonts.length == 0) { |
| throw new RuntimeException("fetchFonts failed (empty result)"); |
| } |
| return fonts[0]; // Assuming the GMS Core provides only one font file. |
| } |
| |
| // Must be called on the mHandler. |
| @RequiresApi(19) |
| private void scheduleRetry(Uri uri, long waitMs) { |
| synchronized (mLock) { |
| if (mObserver == null) { |
| mObserver = new ContentObserver(mHandler) { |
| @Override |
| public void onChange(boolean selfChange, Uri uri) { |
| createMetadata(); |
| } |
| }; |
| mFontProviderHelper.registerObserver(mContext, uri, mObserver); |
| } |
| if (mHandleMetadataCreationRunner == null) { |
| mHandleMetadataCreationRunner = new Runnable() { |
| @Override |
| public void run() { |
| createMetadata(); |
| } |
| }; |
| } |
| mHandler.postDelayed(mHandleMetadataCreationRunner, waitMs); |
| } |
| } |
| |
| // Must be called on the mHandler. |
| private void cleanUp() { |
| mCallback = null; |
| if (mObserver != null) { |
| mFontProviderHelper.unregisterObserver(mContext, mObserver); |
| mObserver = null; |
| } |
| synchronized (mLock) { |
| mHandler.removeCallbacks(mHandleMetadataCreationRunner); |
| if (mThread != null) { |
| mThread.quit(); |
| } |
| mHandler = null; |
| mThread = null; |
| } |
| } |
| |
| // Must be called on the mHandler. |
| @RequiresApi(19) |
| private void createMetadata() { |
| if (mCallback == null) { |
| return; // Already handled or cancelled. Do nothing. |
| } |
| try { |
| final FontsContractCompat.FontInfo font = retrieveFontInfo(); |
| |
| final int resultCode = font.getResultCode(); |
| if (resultCode == FontsContractCompat.Columns.RESULT_CODE_FONT_UNAVAILABLE) { |
| // The font provider is now downloading. Ask RetryPolicy for when to retry next. |
| synchronized (mLock) { |
| if (mRetryPolicy != null) { |
| final long delayMs = mRetryPolicy.getRetryDelay(); |
| if (delayMs >= 0) { |
| scheduleRetry(font.getUri(), delayMs); |
| return; |
| } |
| } |
| } |
| } |
| |
| if (resultCode != FontsContractCompat.Columns.RESULT_CODE_OK) { |
| throw new RuntimeException("fetchFonts result is not OK. (" + resultCode + ")"); |
| } |
| |
| // TODO: Good to add new API to create Typeface from FD not to open FD twice. |
| final Typeface typeface = mFontProviderHelper.buildTypeface(mContext, font); |
| final ByteBuffer buffer = TypefaceCompatUtil.mmap(mContext, null, font.getUri()); |
| if (buffer == null) { |
| throw new RuntimeException("Unable to open file."); |
| } |
| mCallback.onLoaded(MetadataRepo.create(typeface, buffer)); |
| cleanUp(); |
| } catch (Throwable t) { |
| mCallback.onFailed(t); |
| cleanUp(); |
| } |
| } |
| } |
| |
| /** |
| * Delegate class for mocking FontsContractCompat.fetchFonts. |
| * @hide |
| */ |
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public static class FontProviderHelper { |
| /** Calls FontsContractCompat.fetchFonts. */ |
| public FontFamilyResult fetchFonts(@NonNull Context context, |
| @NonNull FontRequest request) throws NameNotFoundException { |
| return FontsContractCompat.fetchFonts(context, null /* cancellation signal */, request); |
| } |
| |
| /** Calls FontsContractCompat.buildTypeface. */ |
| public Typeface buildTypeface(@NonNull Context context, |
| @NonNull FontsContractCompat.FontInfo font) throws NameNotFoundException { |
| return FontsContractCompat.buildTypeface(context, null /* cancellation signal */, |
| new FontsContractCompat.FontInfo[] { font }); |
| } |
| |
| /** Calls Context.getContentObserver().registerObserver */ |
| public void registerObserver(@NonNull Context context, @NonNull Uri uri, |
| @NonNull ContentObserver observer) { |
| context.getContentResolver().registerContentObserver( |
| uri, false /* notifyForDescendants */, observer); |
| |
| } |
| /** Calls Context.getContentObserver().unregisterObserver */ |
| public void unregisterObserver(@NonNull Context context, |
| @NonNull ContentObserver observer) { |
| context.getContentResolver().unregisterContentObserver(observer); |
| } |
| }; |
| |
| private static final FontProviderHelper DEFAULT_FONTS_CONTRACT = new FontProviderHelper(); |
| |
| } |