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