• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
17 package androidx.emoji.text;
18 
19 import android.content.Context;
20 import android.content.pm.PackageManager.NameNotFoundException;
21 import android.database.ContentObserver;
22 import android.graphics.Typeface;
23 import android.net.Uri;
24 import android.os.Handler;
25 import android.os.HandlerThread;
26 import android.os.Process;
27 import android.os.SystemClock;
28 
29 import androidx.annotation.GuardedBy;
30 import androidx.annotation.NonNull;
31 import androidx.annotation.Nullable;
32 import androidx.annotation.RequiresApi;
33 import androidx.annotation.RestrictTo;
34 import androidx.core.graphics.TypefaceCompatUtil;
35 import androidx.core.provider.FontRequest;
36 import androidx.core.provider.FontsContractCompat;
37 import androidx.core.provider.FontsContractCompat.FontFamilyResult;
38 import androidx.core.util.Preconditions;
39 
40 import 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  */
48 public 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          */
getRetryDelay()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          */
ExponentialBackoffRetryPolicy(long totalMs)90         public ExponentialBackoffRetryPolicy(long totalMs) {
91             mTotalMs = totalMs;
92         }
93 
94         @Override
getRetryDelay()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      */
FontRequestEmojiCompatConfig(@onNull Context context, @NonNull FontRequest request)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)
FontRequestEmojiCompatConfig(@onNull Context context, @NonNull FontRequest request, @NonNull FontProviderHelper fontProviderHelper)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      */
setHandler(Handler handler)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      */
setRetryPolicy(RetryPolicy policy)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 
FontRequestMetadataLoader(@onNull Context context, @NonNull FontRequest request, @NonNull FontProviderHelper fontProviderHelper)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 
setHandler(Handler handler)192         public void setHandler(Handler handler) {
193             synchronized (mLock) {
194                 mHandler = handler;
195             }
196         }
197 
setRetryPolicy(RetryPolicy policy)198         public void setRetryPolicy(RetryPolicy policy) {
199             synchronized (mLock) {
200                 mRetryPolicy = policy;
201             }
202         }
203 
204         @Override
205         @RequiresApi(19)
load(@onNull final EmojiCompat.MetadataRepoLoaderCallback loaderCallback)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 
retrieveFontInfo()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)
scheduleRetry(Uri uri, long waitMs)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.
cleanUp()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)
createMetadata()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. */
fetchFonts(@onNull Context context, @NonNull FontRequest request)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. */
buildTypeface(@onNull Context context, @NonNull FontsContractCompat.FontInfo font)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 */
registerObserver(@onNull Context context, @NonNull Uri uri, @NonNull ContentObserver observer)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 */
unregisterObserver(@onNull Context context, @NonNull ContentObserver observer)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