From 670aaf1970346b32c659525456a3df33d678c022 Mon Sep 17 00:00:00 2001 From: M66B Date: Sat, 1 Apr 2023 08:57:04 +0200 Subject: [PATCH] Buiild emoji2 inline --- app/build.gradle | 7 +- .../emoji2/text/ConcurrencyHelpers.java | 108 + .../emoji2/text/DefaultEmojiCompatConfig.java | 343 ++++ .../emoji2/text/DefaultGlyphChecker.java | 89 + .../androidx/emoji2/text/EmojiCompat.java | 1792 +++++++++++++++++ .../emoji2/text/EmojiCompatInitializer.java | 208 ++ .../androidx/emoji2/text/EmojiDefaults.java | 35 + .../androidx/emoji2/text/EmojiExclusions.java | 89 + .../androidx/emoji2/text/EmojiProcessor.java | 1000 +++++++++ .../java/androidx/emoji2/text/EmojiSpan.java | 150 ++ .../text/FontRequestEmojiCompatConfig.java | 444 ++++ .../emoji2/text/MetadataListReader.java | 348 ++++ .../androidx/emoji2/text/MetadataRepo.java | 275 +++ .../emoji2/text/SpannableBuilder.java | 462 +++++ .../emoji2/text/TypefaceEmojiRasterizer.java | 331 +++ .../emoji2/text/TypefaceEmojiSpan.java | 150 ++ ...precomputeTextOnModificationSpannable.java | 182 ++ .../emoji2/text/flatbuffer/MetadataItem.java | 79 + .../emoji2/text/flatbuffer/MetadataList.java | 66 + 19 files changed, 6157 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/androidx/emoji2/text/ConcurrencyHelpers.java create mode 100644 app/src/main/java/androidx/emoji2/text/DefaultEmojiCompatConfig.java create mode 100644 app/src/main/java/androidx/emoji2/text/DefaultGlyphChecker.java create mode 100644 app/src/main/java/androidx/emoji2/text/EmojiCompat.java create mode 100644 app/src/main/java/androidx/emoji2/text/EmojiCompatInitializer.java create mode 100644 app/src/main/java/androidx/emoji2/text/EmojiDefaults.java create mode 100644 app/src/main/java/androidx/emoji2/text/EmojiExclusions.java create mode 100644 app/src/main/java/androidx/emoji2/text/EmojiProcessor.java create mode 100644 app/src/main/java/androidx/emoji2/text/EmojiSpan.java create mode 100644 app/src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java create mode 100644 app/src/main/java/androidx/emoji2/text/MetadataListReader.java create mode 100644 app/src/main/java/androidx/emoji2/text/MetadataRepo.java create mode 100644 app/src/main/java/androidx/emoji2/text/SpannableBuilder.java create mode 100644 app/src/main/java/androidx/emoji2/text/TypefaceEmojiRasterizer.java create mode 100644 app/src/main/java/androidx/emoji2/text/TypefaceEmojiSpan.java create mode 100644 app/src/main/java/androidx/emoji2/text/UnprecomputeTextOnModificationSpannable.java create mode 100644 app/src/main/java/androidx/emoji2/text/flatbuffer/MetadataItem.java create mode 100644 app/src/main/java/androidx/emoji2/text/flatbuffer/MetadataList.java diff --git a/app/build.gradle b/app/build.gradle index b6c32bb9a4..7fbfdc3231 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -424,6 +424,9 @@ configurations.all { } else if (details.requested.group == "org.simplejavamail") { print("Pinning " + details.requested.group + ":" + details.requested.name + "\n") details.useVersion "1.7.13" + } else if (details.requested.group == "com.google.flatbuffers") { + print("Pinning " + details.requested.group + ":" + details.requested.name + "\n") + details.useVersion "2.0.0" } } } @@ -436,6 +439,7 @@ dependencies { def core_version = "1.10.0-rc01" // 1.11.0-alpha01 def appcompat_version = "1.6.1" // 1.7.0-alpha02 def emoji_version = "1.3.0" // 1.4.0-alpha01 + def flatbuffers_version = "2.0.0" def activity_version = "1.7.0" def fragment_version = "1.5.6" // 1.6.0-alpha08 def windows_version = "1.0.0" // 1.1.0-alpha06 @@ -501,7 +505,8 @@ dependencies { // https://mvnrepository.com/artifact/androidx.fragment/fragment // https://mvnrepository.com/artifact/androidx.window/window-java implementation "androidx.appcompat:appcompat:$appcompat_version" - implementation "androidx.emoji2:emoji2:$emoji_version" + //implementation "androidx.emoji2:emoji2:$emoji_version" + implementation "com.google.flatbuffers:flatbuffers-java:$flatbuffers_version" implementation "androidx.activity:activity:$activity_version" implementation "androidx.fragment:fragment:$fragment_version" implementation "androidx.window:window-java:$windows_version" diff --git a/app/src/main/java/androidx/emoji2/text/ConcurrencyHelpers.java b/app/src/main/java/androidx/emoji2/text/ConcurrencyHelpers.java new file mode 100644 index 0000000000..d394ea8b44 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/ConcurrencyHelpers.java @@ -0,0 +1,108 @@ +/* + * Copyright 2021 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.emoji2.text; + +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.Process; + +import androidx.annotation.DoNotInline; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import java.util.concurrent.Executor; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Various (internal) helpers for interacting with threads and event loops + */ +class ConcurrencyHelpers { + + // It is expected that all callers of this internal API call shutdown on completion, allow 15 + // seconds for retry delay before spinning down the thread. + private static final int FONT_LOAD_TIMEOUT_SECONDS = 15 /* seconds */; + + private ConcurrencyHelpers() { /* can't instantiate */ } + + /** + * Background thread worker with an explicit thread name. + * + * It is expected that callers explicitly shut down the returned ThreadPoolExecutor as soon + * as they have completed font loading. + * + * @param name name of thread + * @return ThreadPoolExecutor limited to one thread with a timeout of 15 seconds. + */ + @SuppressWarnings("ThreadPriorityCheck") + static ThreadPoolExecutor createBackgroundPriorityExecutor(@NonNull String name) { + ThreadFactory threadFactory = runnable -> { + Thread t = new Thread(runnable, name); + t.setPriority(Process.THREAD_PRIORITY_BACKGROUND); + return t; + }; + ThreadPoolExecutor executor = new ThreadPoolExecutor( + 0 /* corePoolSize */, + 1 /* maximumPoolSize */, + FONT_LOAD_TIMEOUT_SECONDS /* keepAliveTime */, + TimeUnit.SECONDS /* keepAliveTime TimeUnit */, + new LinkedBlockingDeque<>() /* unbounded queue*/, + threadFactory + ); + executor.allowCoreThreadTimeOut(true); + return executor; + } + + /** + * @return Main thread handler, with createAsync if API level supports it. + */ + static Handler mainHandlerAsync() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return Handler28Impl.createAsync(Looper.getMainLooper()); + } else { + return new Handler(Looper.getMainLooper()); + } + } + + /** + * @deprecated Exists only for upgrade path, remove with + * {@link FontRequestEmojiCompatConfig#setHandler(Handler)} + * + * @param handler a background thread handler + * @return an executor that posts all work to that handler + */ + @NonNull + @Deprecated + static Executor convertHandlerToExecutor(@NonNull Handler handler) { + return handler::post; + } + + @RequiresApi(28) + static class Handler28Impl { + private Handler28Impl() { + // Non-instantiable. + } + + @DoNotInline + public static Handler createAsync(Looper looper) { + return Handler.createAsync(looper); + } + } +} diff --git a/app/src/main/java/androidx/emoji2/text/DefaultEmojiCompatConfig.java b/app/src/main/java/androidx/emoji2/text/DefaultEmojiCompatConfig.java new file mode 100644 index 0000000000..c874512c27 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/DefaultEmojiCompatConfig.java @@ -0,0 +1,343 @@ +/* + * Copyright 2021 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.emoji2.text; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.ProviderInfo; +import android.content.pm.ResolveInfo; +import android.content.pm.Signature; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.annotation.VisibleForTesting; +import androidx.core.provider.FontRequest; +import androidx.core.util.Preconditions; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * The default config will use downloadable fonts to fetch the emoji compat font file. + * + *

It will automatically fetch the emoji compat font from a {@code ContentProvider} that is + * installed on the devices system image, if present.

+ * + *

You should use this if you want the default emoji font from a system installed + * downloadable fonts provider. This is the recommended behavior for all applications unless + * they install a custom emoji font.

+ * + *

You may need to specialize the configuration beyond this default config in some + * situations:

+ * + * + *

The downloadable font provider used by {@code DefaultEmojiCompatConfig} always satisfies + * the following contract:

+ *
    + *
  1. It MUST provide an intent filter for {@code androidx.content.action.LOAD_EMOJI_FONT}. + *
  2. + *
  3. It MUST respond to the query {@code emojicompat-emoji-font} with a valid emoji compat + * font file including metadata.
  4. + *
  5. It MUST provide fonts via the same contract as downloadable fonts.
  6. + *
  7. It MUST be installed in the system image.
  8. + *
+ */ +public final class DefaultEmojiCompatConfig { + /** + * This class cannot be instantiated. + * + * @see DefaultEmojiCompatConfig#create + */ + private DefaultEmojiCompatConfig() { + } + + /** + * Get the default emoji compat config for this device. + * + * You may further configure the returned config before passing it to {@link EmojiCompat#init}. + * + * Each call to this method will return a new EmojiCompat.Config, so changes to the returned + * object will not modify future return values. + * + * @param context context for lookup + * @return A valid config for downloading the emoji compat font, or null if no font provider + * could be found. + */ + @Nullable + public static FontRequestEmojiCompatConfig create(@NonNull Context context) { + return (FontRequestEmojiCompatConfig) new DefaultEmojiCompatConfigFactory(null) + .create(context); + } + + /** + * Actual factory for generating default emoji configs, does service locator lookup internally. + * + * @see DefaultEmojiCompatConfig#create + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + public static class DefaultEmojiCompatConfigFactory { + private static final @NonNull String TAG = "emoji2.text.DefaultEmojiConfig"; + private static final @NonNull String INTENT_LOAD_EMOJI_FONT = + "androidx.content.action.LOAD_EMOJI_FONT"; + private static final @NonNull String DEFAULT_EMOJI_QUERY = "emojicompat-emoji-font"; + private final DefaultEmojiCompatConfigHelper mHelper; + + /** + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + public DefaultEmojiCompatConfigFactory(@Nullable DefaultEmojiCompatConfigHelper helper) { + mHelper = helper != null ? helper : getHelperForApi(); + } + + /** + * @see DefaultEmojiCompatConfig#create + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + @Nullable + public EmojiCompat.Config create(@NonNull Context context) { + return configOrNull(context, (FontRequest) queryForDefaultFontRequest(context)); + } + + /** + * Create a new Config if fontRequest is not null + * @param context context for the config + * @param fontRequest optional font request + * @return a new config if fontRequest is not null + */ + @Nullable + private EmojiCompat.Config configOrNull(@NonNull Context context, + @Nullable FontRequest fontRequest) { + if (fontRequest == null) { + return null; + } else { + return new FontRequestEmojiCompatConfig(context, fontRequest); + } + } + + /** + * Find the installed font provider and return a FontInfo that describes it. + * @param context context for getting package manager + * @return valid FontRequest, or null if no provider could be found + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + @Nullable + @VisibleForTesting + FontRequest queryForDefaultFontRequest(@NonNull Context context) { + PackageManager packageManager = context.getPackageManager(); + // throw here since the developer has provided an atypical Context + Preconditions.checkNotNull(packageManager, + "Package manager required to locate emoji font provider"); + ProviderInfo providerInfo = queryDefaultInstalledContentProvider(packageManager); + if (providerInfo == null) return null; + + try { + return generateFontRequestFrom(providerInfo, packageManager); + } catch (PackageManager.NameNotFoundException e) { + Log.wtf(TAG, e); + return null; + } + } + + /** + * Look up a ContentProvider that provides emoji fonts that's installed with the system. + * + * @param packageManager package manager from a Context + * @return a ResolveInfo for a system installed content provider, or null if none found + */ + @Nullable + private ProviderInfo queryDefaultInstalledContentProvider( + @NonNull PackageManager packageManager) { + List providers = mHelper.queryIntentContentProviders(packageManager, + new Intent(INTENT_LOAD_EMOJI_FONT), 0); + + for (ResolveInfo resolveInfo : providers) { + ProviderInfo providerInfo = mHelper.getProviderInfo(resolveInfo); + if (hasFlagSystem(providerInfo)) { + return providerInfo; + } + } + return null; + } + + /** + * @param providerInfo optional ProviderInfo that describes a content provider + * @return true if this provider info is from an application with + * {@link ApplicationInfo#FLAG_SYSTEM} + */ + private boolean hasFlagSystem(@Nullable ProviderInfo providerInfo) { + return providerInfo != null + && providerInfo.applicationInfo != null + && (providerInfo.applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) + == ApplicationInfo.FLAG_SYSTEM; + } + + /** + * Generate a full FontRequest from a ResolveInfo that describes a ContentProvider. + * + * @param providerInfo description of content provider to generate a FontRequest from + * @return a valid font request + * @throws NullPointerException if the passed resolveInfo has a null providerInfo. + */ + @NonNull + private FontRequest generateFontRequestFrom( + @NonNull ProviderInfo providerInfo, + @NonNull PackageManager packageManager + ) throws PackageManager.NameNotFoundException { + String providerAuthority = providerInfo.authority; + String providerPackage = providerInfo.packageName; + + Signature[] signingSignatures = mHelper.getSigningSignatures(packageManager, + providerPackage); + List> signatures = convertToByteArray(signingSignatures); + return new FontRequest(providerAuthority, providerPackage, DEFAULT_EMOJI_QUERY, + signatures); + } + + /** + * Convert signatures into a form usable by a FontConfig + */ + @NonNull + private List> convertToByteArray(@NonNull Signature[] signatures) { + List shaList = new ArrayList<>(); + for (Signature signature : signatures) { + shaList.add(signature.toByteArray()); + } + return Collections.singletonList(shaList); + } + + /** + * @return the right DefaultEmojiCompatConfigHelper for the device API + */ + @NonNull + private static DefaultEmojiCompatConfigHelper getHelperForApi() { + if (Build.VERSION.SDK_INT >= 28) { + return new DefaultEmojiCompatConfigHelper_API28(); + } else if (Build.VERSION.SDK_INT >= 19) { + return new DefaultEmojiCompatConfigHelper_API19(); + } else { + return new DefaultEmojiCompatConfigHelper(); + } + } + } + + /** + * Helper to lookup signatures in package manager. + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + public static class DefaultEmojiCompatConfigHelper { + /** + * Get the signing signatures for a package in package manager. + */ + @SuppressWarnings("deprecation") // replaced in API 28 + @NonNull + public Signature[] getSigningSignatures(@NonNull PackageManager packageManager, + @NonNull String providerPackage) throws PackageManager.NameNotFoundException { + PackageInfo packageInfoForSignatures = packageManager.getPackageInfo(providerPackage, + PackageManager.GET_SIGNATURES); + return packageInfoForSignatures.signatures; + } + + /** + * Get the content provider by intent. + */ + @NonNull + public List queryIntentContentProviders(@NonNull PackageManager packageManager, + @NonNull Intent intent, int flags) { + return Collections.emptyList(); + } + + /** + * Get a ProviderInfo, if present, from a ResolveInfo + * @param resolveInfo the subject + * @return resolveInfo.providerInfo above API 19 + */ + @Nullable + public ProviderInfo getProviderInfo(@NonNull ResolveInfo resolveInfo) { + throw new IllegalStateException("Unable to get provider info prior to API 19"); + } + } + + /** + * Actually do lookups > API 19 + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + @RequiresApi(19) + public static class DefaultEmojiCompatConfigHelper_API19 + extends DefaultEmojiCompatConfigHelper { + @NonNull + @Override + @SuppressWarnings("deprecation") + public List queryIntentContentProviders(@NonNull PackageManager packageManager, + @NonNull Intent intent, int flags) { + return packageManager.queryIntentContentProviders(intent, flags); + } + + @Nullable + @Override + public ProviderInfo getProviderInfo(@NonNull ResolveInfo resolveInfo) { + return resolveInfo.providerInfo; + } + } + + /** + * Helper to lookup signatures in package manager > API 28 + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + @RequiresApi(28) + public static class DefaultEmojiCompatConfigHelper_API28 + extends DefaultEmojiCompatConfigHelper_API19 { + @SuppressWarnings("deprecation") // using deprecated API to match exact behavior in core + @Override + @NonNull + public Signature[] getSigningSignatures(@NonNull PackageManager packageManager, + @NonNull String providerPackage) + throws PackageManager.NameNotFoundException { + // This uses the deprecated GET_SIGNATURES currently to match the behavior in Core. + // When that behavior changes, we will need to update this method. + + // Alternatively, you may at that time introduce a new config option that allows + // skipping signature validations to avoid this code sync. + PackageInfo packageInfoForSignatures = packageManager.getPackageInfo(providerPackage, + PackageManager.GET_SIGNATURES); + return packageInfoForSignatures.signatures; + } + } +} diff --git a/app/src/main/java/androidx/emoji2/text/DefaultGlyphChecker.java b/app/src/main/java/androidx/emoji2/text/DefaultGlyphChecker.java new file mode 100644 index 0000000000..644c026655 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/DefaultGlyphChecker.java @@ -0,0 +1,89 @@ +/* + * Copyright 2022 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.emoji2.text; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY; + +import android.os.Build; +import android.text.TextPaint; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; +import androidx.core.graphics.PaintCompat; + +/** + * Utility class that checks if the system can render a given glyph. + * + * @hide + */ +@AnyThread +@RestrictTo(LIBRARY) +class DefaultGlyphChecker implements EmojiCompat.GlyphChecker { + /** + * Default text size for {@link #mTextPaint}. + */ + private static final int PAINT_TEXT_SIZE = 10; + + /** + * Used to create strings required by + * {@link PaintCompat#hasGlyph(android.graphics.Paint, String)}. + */ + private static final ThreadLocal sStringBuilder = new ThreadLocal<>(); + + /** + * TextPaint used during {@link PaintCompat#hasGlyph(android.graphics.Paint, String)} check. + */ + private final TextPaint mTextPaint; + + DefaultGlyphChecker() { + mTextPaint = new TextPaint(); + mTextPaint.setTextSize(PAINT_TEXT_SIZE); + } + + @Override + public boolean hasGlyph( + @NonNull CharSequence charSequence, + int start, + int end, + int sdkAdded + ) { + // For pre M devices, heuristic in PaintCompat can result in false positives. we are + // adding another heuristic using the sdkAdded field. if the emoji was added to OS + // at a later version we assume that the system probably cannot render it. + if (Build.VERSION.SDK_INT < 23 && sdkAdded > Build.VERSION.SDK_INT) { + return false; + } + + final StringBuilder builder = getStringBuilder(); + builder.setLength(0); + + while (start < end) { + builder.append(charSequence.charAt(start)); + start++; + } + + return PaintCompat.hasGlyph(mTextPaint, builder.toString()); + } + + private static StringBuilder getStringBuilder() { + if (sStringBuilder.get() == null) { + sStringBuilder.set(new StringBuilder()); + } + return sStringBuilder.get(); + } +} diff --git a/app/src/main/java/androidx/emoji2/text/EmojiCompat.java b/app/src/main/java/androidx/emoji2/text/EmojiCompat.java new file mode 100644 index 0000000000..960479608f --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/EmojiCompat.java @@ -0,0 +1,1792 @@ +/* + * Copyright 2021 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.emoji2.text; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY; +import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; +import static androidx.annotation.RestrictTo.Scope.TESTS; + +import android.app.Application; +import android.content.Context; +import android.graphics.Color; +import android.graphics.Paint; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.method.KeyListener; +import android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import androidx.annotation.AnyThread; +import androidx.annotation.CheckResult; +import androidx.annotation.ColorInt; +import androidx.annotation.GuardedBy; +import androidx.annotation.IntDef; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.collection.ArraySet; +import androidx.core.util.Preconditions; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * Main class to keep Android devices up to date with the newest emojis by adding {@link EmojiSpan}s + * to a given {@link CharSequence}. + *

+ * By default, EmojiCompat is initialized by {@link EmojiCompatInitializer}, which performs + * deferred font loading to avoid potential app startup delays. The default behavior is to load + * the font shortly after the first Activity resumes. EmojiCompatInitializer will configure + * EmojiCompat to use the system emoji font provider via {@link DefaultEmojiCompatConfig} and + * always creates a new background thread for font loading. + *

+ * EmojiCompat will only allow one instance to be initialized and any calls to + * {@link #init(Config)} after the first one will have no effect. As a result, configuration options + * may not be provided when using {@link EmojiCompatInitializer}. To provide a custom configuration, + * disable {@link EmojiCompatInitializer} in the manifest with: + * + *

+ *     <provider
+ *         android:name="androidx.startup.InitializationProvider"
+ *         android:authorities="${applicationId}.androidx-startup"
+ *         android:exported="false"
+ *         tools:node="merge">
+ *         <meta-data android:name="androidx.emoji2.text.EmojiCompatInitializer"
+ *                   tools:node="remove" />
+ *     </provider>
+ * 
+ * + * When not using EmojiCompatInitializer, EmojiCompat must to be initialized manually using + * {@link #init(EmojiCompat.Config)}. It is recommended to make the initialization as early as + * possible in your app, such as from {@link Application#onCreate()}. + *

+ * {@link #init(Config)} is fast and may be called from the main thread on the path to + * displaying the first activity. However, loading the emoji font takes significant resources on a + * background thread, so it is suggested to use {@link #LOAD_STRATEGY_MANUAL} in all manual + * configurations to defer font loading until after the first screen displays. Font loading may + * be started by calling {@link #load()}}. See the implementation {@link EmojiCompatInitializer} + * for ideas when building a manual configuration. + *

+ * After initialization the {@link #get()} function can be used to get the configured instance and + * the {@link #process(CharSequence)} function can be used to update a CharSequence with emoji + * EmojiSpans. + *

+ *

CharSequence processedSequence = EmojiCompat.get().process("some string")
+ *

+ * During loading information about emojis is not available. Before the + * EmojiCompat instance has finished loading, calls to functions such as {@link + * EmojiCompat#process(CharSequence)} will throw an exception. It is safe to call process when + * {@link #getLoadState()} returns {@link #LOAD_STATE_SUCCEEDED}. To register a callback when + * loading completes use {@link InitCallback}. + *

+ + */ +@AnyThread +public class EmojiCompat { + /** + * Key in {@link EditorInfo#extras} that represents the emoji metadata version used by the + * widget. The existence of the value means that the widget is using EmojiCompat. + *

+ * If exists, the value for the key is an {@code int} and can be used to query EmojiCompat to + * see whether the widget has the ability to display a certain emoji using + * {@link #hasEmojiGlyph(CharSequence, int)}. + */ + public static final String EDITOR_INFO_METAVERSION_KEY = + "android.support.text.emoji.emojiCompat_metadataVersion"; + + /** + * Key in {@link EditorInfo#extras} that represents {@link + * EmojiCompat.Config#setReplaceAll(boolean)} configuration parameter. The key is added only if + * EmojiCompat is used by the widget. If exists, the value is a boolean. + */ + public static final String EDITOR_INFO_REPLACE_ALL_KEY = + "android.support.text.emoji.emojiCompat_replaceAll"; + + /** + * EmojiCompat instance is constructed, however the initialization did not start yet. + * + * @see #getLoadState() + */ + public static final int LOAD_STATE_DEFAULT = 3; + + /** + * EmojiCompat is initializing. + * + * @see #getLoadState() + */ + // note: this may be returned as the value of mLoadState before constructor finishes due to + // double-check lock + public static final int LOAD_STATE_LOADING = 0; + + /** + * EmojiCompat successfully initialized. + * + * @see #getLoadState() + */ + public static final int LOAD_STATE_SUCCEEDED = 1; + + /** + * An unrecoverable error occurred during initialization of EmojiCompat. Calls to functions + * such as {@link #process(CharSequence)} will fail. + * + * @see #getLoadState() + */ + public static final int LOAD_STATE_FAILED = 2; + + /** + * @hide + */ + @RestrictTo(LIBRARY) + @IntDef({LOAD_STATE_DEFAULT, LOAD_STATE_LOADING, LOAD_STATE_SUCCEEDED, LOAD_STATE_FAILED}) + @Retention(RetentionPolicy.SOURCE) + private @interface LoadState { + } + + /** + * Replace strategy that uses the value given in {@link EmojiCompat.Config}. + * + * @see #process(CharSequence, int, int, int, int) + */ + public static final int REPLACE_STRATEGY_DEFAULT = 0; + + /** + * Replace strategy to add {@link EmojiSpan}s for all emoji that were found. + * + * @see #process(CharSequence, int, int, int, int) + */ + public static final int REPLACE_STRATEGY_ALL = 1; + + /** + * Replace strategy to add {@link EmojiSpan}s only for emoji that do not exist in the system. + */ + public static final int REPLACE_STRATEGY_NON_EXISTENT = 2; + + /** + * @hide + */ + @RestrictTo(LIBRARY) + @IntDef({REPLACE_STRATEGY_DEFAULT, REPLACE_STRATEGY_NON_EXISTENT, REPLACE_STRATEGY_ALL}) + @Retention(RetentionPolicy.SOURCE) + public @interface ReplaceStrategy { + } + + /** + * {@link EmojiCompat} will start loading metadata when {@link #init(Config)} is called. + * + * @see Config#setMetadataLoadStrategy(int) + */ + public static final int LOAD_STRATEGY_DEFAULT = 0; + + /** + * {@link EmojiCompat} will wait for {@link #load()} to be called by developer in order to + * start loading metadata. + * + * @see Config#setMetadataLoadStrategy(int) + */ + public static final int LOAD_STRATEGY_MANUAL = 1; + + /** + * @hide + */ + @RestrictTo(LIBRARY) + @IntDef({LOAD_STRATEGY_DEFAULT, LOAD_STRATEGY_MANUAL}) + @Retention(RetentionPolicy.SOURCE) + public @interface LoadStrategy { + } + + /** + * @hide + */ + @RestrictTo(LIBRARY) + @IntDef({EMOJI_UNSUPPORTED, EMOJI_SUPPORTED, + EMOJI_FALLBACK}) + @Retention(RetentionPolicy.SOURCE) + public @interface CodepointSequenceMatchResult { + } + + /** + * Result of {@link #getEmojiMatch(CharSequence, int)} that means no part of this codepoint + * sequence will ever generate an {@link EmojiSpan} at the requested metadata level. + * + * This return value implies: + * - EmojiCompat will always defer to system emoji font + * - System emoji font may or may not support this emoji + * - This application MAY render this emoji + * + * This can be used by keyboards to learn that EmojiCompat does not support this codepoint + * sequence at this metadata version. The system emoji font is not checked by this method, + * and this result will be returned even if the system emoji font supports the emoji. This may + * happen if the application is using an older version of the emoji compat font than the + * system emoji font. + * + * Keyboards may optionally determine that the system emoji font will support the emoji, for + * example by building a internal lookup table or calling + * {@link androidx.core.graphics.PaintCompat#hasGlyph(Paint, String)} to query the system + * emoji font. Keyboards may use a lookup table to optimize this check, however they should be + * aware that OEMs may add or remove emoji from the system emoji font. + * + * Keyboards may finally decide: + * - If the system emoji font DOES NOT support the emoji, then the emoji IS NOT supported by + * this application. + * - If the system emoji font DOES support the emoji, then the emoji IS supported by this + * application. + * - If system emoji font is support is UNKNOWN, then assume the emoji IS NOT supported by + * this application. + */ + public static final int EMOJI_UNSUPPORTED = 0; + + /** + * Result of {@link #getEmojiMatch(CharSequence, int)} that means this codepoint can be drawn + * by an {@link EmojiSpan} at this metadata level. + * + * No further checks are required by keyboards for this result. The emoji is always supported + * by this application. + * + * This return value implies: + * - EmojiCompat can draw this emoji + * - System emoji font may or may not support this emoji + * - This application WILL render this emoji + * + * This result implies that EmojiCompat can successfully display this emoji. The system emoji + * font is not checked by this method, and this result may be returned even if the platform + * also supports the emoji sequence. + * + * If the application passes {@link EmojiCompat#REPLACE_STRATEGY_ALL} of true, then an + * {@link EmojiSpan} will always be generated for this emoji. + * + * If the application passes {@link EmojiCompat#REPLACE_STRATEGY_ALL} of false, then an + * {@link EmojiSpan} will only be generated if + * {@link androidx.core.graphics.PaintCompat#hasGlyph(Paint, String)} + * returns false for this emoji. + */ + public static final int EMOJI_SUPPORTED = 1; + + /** + * Result of {@link #getEmojiMatch(CharSequence, int)} that means the full codepoint sequence + * is not known to emojicompat, but at least one subsequence is an emoji that is known at + * this metadata level. + * + * Keyboards may decide that this emoji is not supported by the application when this result is + * returned, with no further processing. + * + * This return value implies: + * - EmojiCompat will decompose this ZWJ sequence into multiple glyphs when replaceAll=true + * - EmojiCompat MAY defer to platform when replaceAll=false + * - System emoji font may or may not support this emoji + * - This application MAY render this emoji + * + * This return value is only ever returned for ZWJ sequences. To understand this result + * consider when it may be returned for the multi-skin-tone handshake introduced in emoji 14. + * + *

+     *     U+1FAF1 // unknown @ requested metadata level
+     *     U+1F3FB // metadata level 1
+     *     U+200D  // not displayed (ZWJ)
+     *     U+1FAF2 // unknown @ requested metadata level
+     *     U+1F3FD // metadata level 1
+     * 
+ * + * In this codepoint sequence, U+1F3FB and U+1F3FD are known from metadata level 1. When an + * application is using a metadata level that doesn't understand this ZWJ and provides + * {@link EmojiCompat#REPLACE_STRATEGY_ALL} true, the color emoji are matched and replaced + * with {@link EmojiSpan}. The system emoji font, even if it supports this ZWJ sequence, is + * never queried and the added EmojiSpans force fallback rendering for the ZWJ sequence. + * + * The glyph will only display correctly for this application if ALL of the following + * requirements are met: + * - {@link EmojiCompat#REPLACE_STRATEGY_ALL} is false + * - {@link androidx.core.graphics.PaintCompat#hasGlyph(Paint, String)} returns true for each + * emoji subsequence known at this metadata level + * - {@link androidx.core.graphics.PaintCompat#hasGlyph(Paint, String)} returns true for the + * full sequence + * + * Given this return value for the multi-skin-tone handshake above, if + * {@link EmojiCompat#REPLACE_STRATEGY_ALL} is false then the emoji will display if the + * entire emoji sequence is matched by + * {@link androidx.core.graphics.PaintCompat#hasGlyph(Paint, String)} because U+1F3FB and + * U+1F3FD are both in the system emoji font. + * + * Keyboards that wish to determine if the glyph will display correctly by the application in + * response to this return value should consider building an internal lookup for new ZWJ + * sequences instead of repeatedly calling + * {@link androidx.core.graphics.PaintCompat#hasGlyph(Paint, String)} for each emoji + * subsequence. + */ + public static final int EMOJI_FALLBACK = 2; + + /** + * @hide + */ + @RestrictTo(LIBRARY) + static final int EMOJI_COUNT_UNLIMITED = Integer.MAX_VALUE; + + private static final Object INSTANCE_LOCK = new Object(); + private static final Object CONFIG_LOCK = new Object(); + + @GuardedBy("INSTANCE_LOCK") + private static volatile @Nullable EmojiCompat sInstance; + @GuardedBy("CONFIG_LOCK") + private static volatile boolean sHasDoneDefaultConfigLookup; + + private final @NonNull ReadWriteLock mInitLock; + + @GuardedBy("mInitLock") + private final @NonNull Set mInitCallbacks; + + @GuardedBy("mInitLock") + @LoadState + private volatile int mLoadState; + + /** + * Handler with main looper to run the callbacks on. + */ + private final @NonNull Handler mMainHandler; + + /** + * Helper class for pre 19 compatibility. + */ + private final @NonNull CompatInternal mHelper; + + /** + * Metadata loader instance given in the Config instance. + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + final @NonNull MetadataRepoLoader mMetadataLoader; + + private @NonNull final SpanFactory mSpanFactory; + + /** + * @see Config#setReplaceAll(boolean) + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + final boolean mReplaceAll; + + /** + * @see Config#setUseEmojiAsDefaultStyle(boolean) + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + final boolean mUseEmojiAsDefaultStyle; + + /** + * @see Config#setUseEmojiAsDefaultStyle(boolean, List) + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + final @Nullable int[] mEmojiAsDefaultStyleExceptions; + + /** + * @see Config#setEmojiSpanIndicatorEnabled(boolean) + */ + private final boolean mEmojiSpanIndicatorEnabled; + + /** + * @see Config#setEmojiSpanIndicatorColor(int) + */ + private final int mEmojiSpanIndicatorColor; + + /** + * @see Config#setMetadataLoadStrategy(int) + */ + @LoadStrategy private final int mMetadataLoadStrategy; + + /** + * @see Config#setGlyphChecker(GlyphChecker) + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + private final GlyphChecker mGlyphChecker; + + private static final String NOT_INITIALIZED_ERROR_TEXT = "EmojiCompat is not initialized.\n" + + "\n" + + "You must initialize EmojiCompat prior to referencing the EmojiCompat instance.\n" + + "\n" + + "The most likely cause of this error is disabling the EmojiCompatInitializer\n" + + "either explicitly in AndroidManifest.xml, or by including\n" + + "androidx.emoji2:emoji2-bundled.\n" + + "\n" + + "Automatic initialization is typically performed by EmojiCompatInitializer. If\n" + + "you are not expecting to initialize EmojiCompat manually in your application,\n" + + "please check to ensure it has not been removed from your APK's manifest. You can\n" + + "do this in Android Studio using Build > Analyze APK.\n" + + "\n" + + "In the APK Analyzer, ensure that the startup entry for\n" + + "EmojiCompatInitializer and InitializationProvider is present in\n" + + " AndroidManifest.xml. If it is missing or contains tools:node=\"remove\", and you\n" + + "intend to use automatic configuration, verify:\n" + + "\n" + + " 1. Your application does not include emoji2-bundled\n" + + " 2. All modules do not contain an exclusion manifest rule for\n" + + " EmojiCompatInitializer or InitializationProvider. For more information\n" + + " about manifest exclusions see the documentation for the androidx startup\n" + + " library.\n" + + "\n" + + "If you intend to use emoji2-bundled, please call EmojiCompat.init. You can\n" + + "learn more in the documentation for BundledEmojiCompatConfig.\n" + + "\n" + + "If you intended to perform manual configuration, it is recommended that you call\n" + + "EmojiCompat.init immediately on application startup.\n" + + "\n" + + "If you still cannot resolve this issue, please open a bug with your specific\n" + + "configuration to help improve error message."; + + /** + * Private constructor for singleton instance. + * + * @see #init(Config) + */ + private EmojiCompat(@NonNull final Config config) { + mInitLock = new ReentrantReadWriteLock(); + mLoadState = LOAD_STATE_DEFAULT; + mReplaceAll = config.mReplaceAll; + mUseEmojiAsDefaultStyle = config.mUseEmojiAsDefaultStyle; + mEmojiAsDefaultStyleExceptions = config.mEmojiAsDefaultStyleExceptions; + mEmojiSpanIndicatorEnabled = config.mEmojiSpanIndicatorEnabled; + mEmojiSpanIndicatorColor = config.mEmojiSpanIndicatorColor; + mMetadataLoader = config.mMetadataLoader; + mMetadataLoadStrategy = config.mMetadataLoadStrategy; + mGlyphChecker = config.mGlyphChecker; + mMainHandler = new Handler(Looper.getMainLooper()); + mInitCallbacks = new ArraySet<>(); + SpanFactory localSpanFactory = config.mSpanFactory; + mSpanFactory = localSpanFactory != null ? localSpanFactory : new DefaultSpanFactory(); + if (config.mInitCallbacks != null && !config.mInitCallbacks.isEmpty()) { + mInitCallbacks.addAll(config.mInitCallbacks); + } + mHelper = Build.VERSION.SDK_INT < 19 ? new CompatInternal(this) : new CompatInternal19( + this); + loadMetadata(); + } + + /** + * Initialize the singleton instance with the default system-provided configuration. + * + *

This is the recommended configuration for most applications. For more details see + * {@link DefaultEmojiCompatConfig}.

+ * + *

This call will use {@link DefaultEmojiCompatConfig} to lookup the default emoji font + * provider installed on the system and use that, if present. If there is no default font + * provider onthe system, this call will have no effect.

+ * + *

Note: EmojiCompat may only be initialized once, and will return the same instance + * afterwords.

+ * + * @return Default EmojiCompat for this device, or null if there is no provider on the system. + */ + @Nullable + public static EmojiCompat init(@NonNull Context context) { + return init(context, null); + } + + /** + * @hide + */ + @RestrictTo(LIBRARY) + @Nullable + @SuppressWarnings("GuardedBy") /* double-check lock; volatile; threadsafe obj */ + public static EmojiCompat init(@NonNull Context context, + @Nullable DefaultEmojiCompatConfig.DefaultEmojiCompatConfigFactory defaultFactory) { + EmojiCompat.Config config; + if (sHasDoneDefaultConfigLookup) { + // sInstance is safe to return outside the lock because + // 1) static fields are volatile + // 2) all fields on EmojiCompat are final, or guarded by a lock + // 3) we only write this after sInstance is settled by the call to `init` + return sInstance; + } else { + DefaultEmojiCompatConfig.DefaultEmojiCompatConfigFactory factory = + defaultFactory != null ? defaultFactory : + new DefaultEmojiCompatConfig.DefaultEmojiCompatConfigFactory(null); + config = factory.create(context); + } + synchronized (CONFIG_LOCK) { + if (!sHasDoneDefaultConfigLookup) { + // sDefaultConfigLookup allows us to early-exit above, as well as avoid repeated + // calls to create in the case where the font provider is not found + if (config != null) { + init(config); + } + // write this after init to allow safe early-exit + sHasDoneDefaultConfigLookup = true; + + } + return sInstance; + } + } + + /** + * Initialize the singleton instance with a configuration. When used on devices running API 18 + * or below, the singleton instance is immediately moved into {@link #LOAD_STATE_SUCCEEDED} + * state without loading any metadata. When called for the first time, the library will create + * the singleton instance and any call after that will not create a new instance and return + * immediately. + * + * @see EmojiCompat.Config + */ + @SuppressWarnings("GuardedBy") /* double-check lock; volatile sInstance; threadsafe obj */ + @NonNull + public static EmojiCompat init(@NonNull final Config config) { + // copy to local for null-checker + EmojiCompat localInstance = sInstance; + if (localInstance == null) { + synchronized (INSTANCE_LOCK) { + localInstance = sInstance; + if (localInstance == null) { + localInstance = new EmojiCompat(config); + sInstance = localInstance; + } + } + } + return localInstance; + } + + /** + * Return true if EmojiCompat has been configured by a successful call to + * {@link EmojiCompat#init}. + * + * You can use this to check if {@link EmojiCompat#get()} will return a valid EmojiCompat + * instance. + * + * This function does not check the {@link #getLoadState()} and will return true even if the + * font is still loading, or has failed to load. + * + * @return true if EmojiCompat has been successfully initialized. + */ + @SuppressWarnings("GuardedBy") // same rationale as double-check lock + public static boolean isConfigured() { + // Note: this is true immediately after calling .init(Config). + // + // These are three situations this may return false + // 1) An app has disabled EmojiCompatInitializer and does not intend to call .init. + // 2) EmojiCompatInitializer did not find a configuration + // 3) EmojiCompatInitializer was disable or failed, and the app will call .init. In the + // future it will return true. + // + // In case one and two, this method will always return false for the duration of the + // application lifecycle. + // + // In case three, this will return true at some future point. There is no callback + // mechanism to learn about the init call due to the high potential for leaked references + // in a static context if it's actually case 2 (when using manual callback registration). + // + // It is recommended that applications call init prior to creating any screens that + // may show emoji or user generated content. + return sInstance != null; + } + + + /** + * Used by the tests to reset EmojiCompat with a new configuration. Every time it is called a + * new instance is created with the new configuration. + * + * @hide + */ + @NonNull + public static EmojiCompat reset(@NonNull final Config config) { + synchronized (INSTANCE_LOCK) { + EmojiCompat localInstance = new EmojiCompat(config); + sInstance = localInstance; + return localInstance; + } + } + + /** + * Used by the tests to reset EmojiCompat with a new singleton instance. + * + * @hide + */ + @RestrictTo(TESTS) + @Nullable + public static EmojiCompat reset(@Nullable final EmojiCompat emojiCompat) { + synchronized (INSTANCE_LOCK) { + sInstance = emojiCompat; + return sInstance; + } + } + + /** + * Reset default configuration lookup flag, for tests. + * + * @hide + */ + @RestrictTo(TESTS) + public static void skipDefaultConfigurationLookup(boolean shouldSkip) { + synchronized (CONFIG_LOCK) { + sHasDoneDefaultConfigLookup = shouldSkip; + } + } + + /** + * Return singleton EmojiCompat instance. Should be called after + * {@link #init(EmojiCompat.Config)} is called to initialize the singleton instance. + * + * @return EmojiCompat instance + * + * @throws IllegalStateException if called before {@link #init(EmojiCompat.Config)} + */ + @NonNull + public static EmojiCompat get() { + synchronized (INSTANCE_LOCK) { + EmojiCompat localInstance = sInstance; + Preconditions.checkState(localInstance != null, NOT_INITIALIZED_ERROR_TEXT); + return localInstance; + } + } + + /** + * When {@link Config#setMetadataLoadStrategy(int)} is set to {@link #LOAD_STRATEGY_MANUAL}, + * this function starts loading the metadata. Calling the function when + * {@link Config#setMetadataLoadStrategy(int)} is {@code not} set to + * {@link #LOAD_STRATEGY_MANUAL} will throw an exception. The load will {@code not} start if: + *
    + *
  • the metadata is already loaded successfully and {@link #getLoadState()} is + * {@link #LOAD_STATE_SUCCEEDED}. + *
  • + *
  • a previous load attempt is not finished yet and {@link #getLoadState()} is + * {@link #LOAD_STATE_LOADING}.
  • + *
+ * + * @throws IllegalStateException when {@link Config#setMetadataLoadStrategy(int)} is not set + * to {@link #LOAD_STRATEGY_MANUAL} + */ + public void load() { + Preconditions.checkState(mMetadataLoadStrategy == LOAD_STRATEGY_MANUAL, + "Set metadataLoadStrategy to LOAD_STRATEGY_MANUAL to execute manual loading"); + if (isInitialized()) return; + + mInitLock.writeLock().lock(); + try { + if (mLoadState == LOAD_STATE_LOADING) return; + mLoadState = LOAD_STATE_LOADING; + } finally { + mInitLock.writeLock().unlock(); + } + + mHelper.loadMetadata(); + } + + private void loadMetadata() { + mInitLock.writeLock().lock(); + try { + if (mMetadataLoadStrategy == LOAD_STRATEGY_DEFAULT) { + mLoadState = LOAD_STATE_LOADING; + } + } finally { + mInitLock.writeLock().unlock(); + } + + if (getLoadState() == LOAD_STATE_LOADING) { + mHelper.loadMetadata(); + } + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void onMetadataLoadSuccess() { + final Collection initCallbacks = new ArrayList<>(); + mInitLock.writeLock().lock(); + try { + mLoadState = LOAD_STATE_SUCCEEDED; + initCallbacks.addAll(mInitCallbacks); + mInitCallbacks.clear(); + } finally { + mInitLock.writeLock().unlock(); + } + + mMainHandler.post(new ListenerDispatcher(initCallbacks, mLoadState)); + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void onMetadataLoadFailed(@Nullable final Throwable throwable) { + final Collection initCallbacks = new ArrayList<>(); + mInitLock.writeLock().lock(); + try { + mLoadState = LOAD_STATE_FAILED; + initCallbacks.addAll(mInitCallbacks); + mInitCallbacks.clear(); + } finally { + mInitLock.writeLock().unlock(); + } + mMainHandler.post(new ListenerDispatcher(initCallbacks, mLoadState, throwable)); + } + + /** + * Registers an initialization callback. If the initialization is already completed by the time + * the listener is added, the callback functions are called immediately. Callbacks are called on + * the main looper. + *

+ * When used on devices running API 18 or below, {@link InitCallback#onInitialized()} is called + * without loading any metadata. In such cases {@link InitCallback#onFailed(Throwable)} is never + * called. + * + * @param initCallback the initialization callback to register, cannot be {@code null} + * + * @see #unregisterInitCallback(InitCallback) + */ + @SuppressWarnings("ExecutorRegistration") + public void registerInitCallback(@NonNull InitCallback initCallback) { + Preconditions.checkNotNull(initCallback, "initCallback cannot be null"); + + mInitLock.writeLock().lock(); + try { + if (mLoadState == LOAD_STATE_SUCCEEDED || mLoadState == LOAD_STATE_FAILED) { + mMainHandler.post(new ListenerDispatcher(initCallback, mLoadState)); + } else { + mInitCallbacks.add(initCallback); + } + } finally { + mInitLock.writeLock().unlock(); + } + } + + /** + * Unregisters a callback that was added before. + * + * @param initCallback the callback to be removed, cannot be {@code null} + */ + public void unregisterInitCallback(@NonNull InitCallback initCallback) { + Preconditions.checkNotNull(initCallback, "initCallback cannot be null"); + mInitLock.writeLock().lock(); + try { + mInitCallbacks.remove(initCallback); + } finally { + mInitLock.writeLock().unlock(); + } + } + + /** + * Returns loading state of the EmojiCompat instance. When used on devices running API 18 or + * below always returns {@link #LOAD_STATE_SUCCEEDED}. + * + * @return one of {@link #LOAD_STATE_DEFAULT}, {@link #LOAD_STATE_LOADING}, + * {@link #LOAD_STATE_SUCCEEDED}, {@link #LOAD_STATE_FAILED} + */ + public @LoadState int getLoadState() { + mInitLock.readLock().lock(); + try { + return mLoadState; + } finally { + mInitLock.readLock().unlock(); + } + } + + /** + * @return {@code true} if EmojiCompat is successfully initialized + */ + private boolean isInitialized() { + return getLoadState() == LOAD_STATE_SUCCEEDED; + } + + /** + * @return whether a background should be drawn for the emoji for debugging + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + public boolean isEmojiSpanIndicatorEnabled() { + return mEmojiSpanIndicatorEnabled; + } + + /** + * @return color of background drawn if {@link EmojiCompat#isEmojiSpanIndicatorEnabled} is true + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + public @ColorInt int getEmojiSpanIndicatorColor() { + return mEmojiSpanIndicatorColor; + } + + /** + * Together with {@link #getEmojiEnd(CharSequence, int)}, if the character at {@code offset} is + * part of an emoji, returns the index range of that emoji, start index inclusively/end index + * exclusively so that {@code charSequence.subSequence(start, end)} will return that emoji. + * E.g., getEmojiStart/End("AB😀", 1) will return (-1,-1) since 'B' is not part an emoji; + * getEmojiStart/End("AB😀", 3) will return [2,4), note that "😀" contains 2 Chars. + * Returns -1 otherwise. + * @param charSequence the whole sequence + * @param offset index of the emoji to look up + * @return the start index inclusively/end index exclusively + */ + public int getEmojiStart(@NonNull final CharSequence charSequence, + @IntRange(from = 0) int offset) { + return mHelper.getEmojiStart(charSequence, offset); + } + + /** + * see {@link #getEmojiStart(CharSequence, int)}. + */ + public int getEmojiEnd(@NonNull final CharSequence charSequence, + @IntRange(from = 0) int offset) { + return mHelper.getEmojiEnd(charSequence, offset); + } + + /** + * Handles onKeyDown commands from a {@link KeyListener} and if {@code keyCode} is one of + * {@link KeyEvent#KEYCODE_DEL} or {@link KeyEvent#KEYCODE_FORWARD_DEL} it tries to delete an + * {@link EmojiSpan} from an {@link Editable}. Returns {@code true} if an {@link EmojiSpan} is + * deleted with the characters it covers. + *

+ * If there is a selection where selection start is not equal to selection end, does not + * delete. + *

+ * When used on devices running API 18 or below, always returns {@code false}. + * + * @param editable Editable instance passed to {@link KeyListener#onKeyDown(android.view.View, + * Editable, int, KeyEvent)} + * @param keyCode keyCode passed to {@link KeyListener#onKeyDown(android.view.View, Editable, + * int, KeyEvent)} + * @param event KeyEvent passed to {@link KeyListener#onKeyDown(android.view.View, Editable, + * int, KeyEvent)} + * + * @return {@code true} if an {@link EmojiSpan} is deleted + */ + public static boolean handleOnKeyDown(@NonNull final Editable editable, final int keyCode, + @NonNull final KeyEvent event) { + if (Build.VERSION.SDK_INT >= 19) { + return EmojiProcessor.handleOnKeyDown(editable, keyCode, event); + } else { + return false; + } + } + + /** + * Handles deleteSurroundingText commands from {@link InputConnection} and tries to delete an + * {@link EmojiSpan} from an {@link Editable}. Returns {@code true} if an {@link EmojiSpan} is + * deleted. + *

+ * If there is a selection where selection start is not equal to selection end, does not + * delete. + *

+ * When used on devices running API 18 or below, always returns {@code false}. + * + * @param inputConnection InputConnection instance + * @param editable TextView.Editable instance + * @param beforeLength the number of characters before the cursor to be deleted + * @param afterLength the number of characters after the cursor to be deleted + * @param inCodePoints {@code true} if length parameters are in codepoints + * + * @return {@code true} if an {@link EmojiSpan} is deleted + */ + public static boolean handleDeleteSurroundingText( + @NonNull final InputConnection inputConnection, @NonNull final Editable editable, + @IntRange(from = 0) final int beforeLength, @IntRange(from = 0) final int afterLength, + final boolean inCodePoints) { + if (Build.VERSION.SDK_INT >= 19) { + return EmojiProcessor.handleDeleteSurroundingText(inputConnection, editable, + beforeLength, afterLength, inCodePoints); + } else { + return false; + } + } + + /** + * Returns {@code true} if EmojiCompat is capable of rendering an emoji. When used on devices + * running API 18 or below, always returns {@code false}. + * + * @deprecated use getEmojiMatch which returns more accurate lookup information. + * + * @param sequence CharSequence representing the emoji + * + * @return {@code true} if EmojiCompat can render given emoji, cannot be {@code null} + * + * @throws IllegalStateException if not initialized yet + */ + @Deprecated + public boolean hasEmojiGlyph(@NonNull final CharSequence sequence) { + Preconditions.checkState(isInitialized(), "Not initialized yet"); + Preconditions.checkNotNull(sequence, "sequence cannot be null"); + return mHelper.hasEmojiGlyph(sequence); + } + + /** + * Returns {@code true} if EmojiCompat is capable of rendering an emoji at the given metadata + * version. When used on devices running API 18 or below, always returns {@code false}. + * + * @deprecated use getEmojiMatch which returns more accurate lookup information. + * + * @param sequence CharSequence representing the emoji + * @param metadataVersion the metadata version to check against, should be greater than or + * equal to {@code 0}, + * + * @return {@code true} if EmojiCompat can render given emoji, cannot be {@code null} + * + * @throws IllegalStateException if not initialized yet + */ + @Deprecated + public boolean hasEmojiGlyph(@NonNull final CharSequence sequence, + @IntRange(from = 0) final int metadataVersion) { + Preconditions.checkState(isInitialized(), "Not initialized yet"); + Preconditions.checkNotNull(sequence, "sequence cannot be null"); + return mHelper.hasEmojiGlyph(sequence, metadataVersion); + } + + /** + * Attempts to lookup the entire sequence at the specified metadata version and returns what + * the runtime match behavior would be. + * + * To be used by keyboards to show or hide emoji in response to specific metadata support. + * + * @see #EMOJI_SUPPORTED + * @see #EMOJI_UNSUPPORTED + * @see #EMOJI_FALLBACK + * + * @param sequence CharSequence representing an emoji + * @param metadataVersion the metada version to check against, should be greater than or + * equal to {@code 0}, + * @return A match result, or decomposes if replaceAll would cause partial subsequence matches. + */ + @CodepointSequenceMatchResult + public int getEmojiMatch(@NonNull CharSequence sequence, + @IntRange(from = 0) final int metadataVersion) { + Preconditions.checkState(isInitialized(), "Not initialized yet"); + Preconditions.checkNotNull(sequence, "sequence cannot be null"); + return mHelper.getEmojiMatch(sequence, metadataVersion); + } + + /** + * Checks a given CharSequence for emojis, and adds EmojiSpans if any emojis are found. When + * used on devices running API 18 or below, returns the given {@code charSequence} without + * processing it. + * + * @param charSequence CharSequence to add the EmojiSpans + * + * @throws IllegalStateException if not initialized yet + * @see #process(CharSequence, int, int) + */ + @Nullable + @CheckResult + public CharSequence process(@Nullable final CharSequence charSequence) { + // since charSequence might be null here we have to check it. Passing through here to the + // main function so that it can do all the checks including isInitialized. It will also + // be the main point that decides what to return. + + @IntRange(from = 0) final int length = charSequence == null ? 0 : charSequence.length(); + return process(charSequence, 0, length); + } + + /** + * Checks a given CharSequence for emojis, and adds EmojiSpans if any emojis are found. + *

+ *

    + *
  • If no emojis are found, {@code charSequence} given as the input is returned without + * any changes. i.e. charSequence is a String, and no emojis are found, the same String is + * returned.
  • + *
  • If the given input is not a Spannable (such as String), and at least one emoji is found + * a new {@link android.text.Spannable} instance is returned.
  • + *
  • If the given input is a Spannable, the same instance is returned.
  • + *
+ * When used on devices running API 18 or below, returns the given {@code charSequence} without + * processing it. + * + * @param charSequence CharSequence to add the EmojiSpans, cannot be {@code null} + * @param start start index in the charSequence to look for emojis, should be greater than or + * equal to {@code 0}, also less than or equal to {@code charSequence.length()} + * @param end end index in the charSequence to look for emojis, should be greater than or equal + * to {@code start} parameter, also less than or equal to + * {@code charSequence.length()} + * + * @throws IllegalStateException if not initialized yet + * @throws IllegalArgumentException in the following cases: + * {@code start < 0}, {@code end < 0}, {@code end < start}, + * {@code start > charSequence.length()}, + * {@code end > charSequence.length()} + */ + @Nullable + @CheckResult + public CharSequence process(@Nullable final CharSequence charSequence, + @IntRange(from = 0) final int start, @IntRange(from = 0) final int end) { + return process(charSequence, start, end, EMOJI_COUNT_UNLIMITED); + } + + /** + * Checks a given CharSequence for emojis, and adds EmojiSpans if any emojis are found. + *

+ *

    + *
  • If no emojis are found, {@code charSequence} given as the input is returned without + * any changes. i.e. charSequence is a String, and no emojis are found, the same String is + * returned.
  • + *
  • If the given input is not a Spannable (such as String), and at least one emoji is found + * a new {@link android.text.Spannable} instance is returned.
  • + *
  • If the given input is a Spannable, the same instance is returned.
  • + *
+ * When used on devices running API 18 or below, returns the given {@code charSequence} without + * processing it. + * + * @param charSequence CharSequence to add the EmojiSpans, cannot be {@code null} + * @param start start index in the charSequence to look for emojis, should be greater than or + * equal to {@code 0}, also less than or equal to {@code charSequence.length()} + * @param end end index in the charSequence to look for emojis, should be greater than or + * equal to {@code start} parameter, also less than or equal to + * {@code charSequence.length()} + * @param maxEmojiCount maximum number of emojis in the {@code charSequence}, should be greater + * than or equal to {@code 0} + * + * @throws IllegalStateException if not initialized yet + * @throws IllegalArgumentException in the following cases: + * {@code start < 0}, {@code end < 0}, {@code end < start}, + * {@code start > charSequence.length()}, + * {@code end > charSequence.length()} + * {@code maxEmojiCount < 0} + */ + @Nullable + @CheckResult + public CharSequence process(@Nullable final CharSequence charSequence, + @IntRange(from = 0) final int start, @IntRange(from = 0) final int end, + @IntRange(from = 0) final int maxEmojiCount) { + return process(charSequence, start, end, maxEmojiCount, REPLACE_STRATEGY_DEFAULT); + } + + /** + * Checks a given CharSequence for emojis, and adds EmojiSpans if any emojis are found. + *

+ *

    + *
  • If no emojis are found, {@code charSequence} given as the input is returned without + * any changes. i.e. charSequence is a String, and no emojis are found, the same String is + * returned.
  • + *
  • If the given input is not a Spannable (such as String), and at least one emoji is found + * a new {@link android.text.Spannable} instance is returned.
  • + *
  • If the given input is a Spannable, the same instance is returned.
  • + *
+ * When used on devices running API 18 or below, returns the given {@code charSequence} without + * processing it. + * + * @param charSequence CharSequence to add the EmojiSpans, cannot be {@code null} + * @param start start index in the charSequence to look for emojis, should be greater than or + * equal to {@code 0}, also less than or equal to {@code charSequence.length()} + * @param end end index in the charSequence to look for emojis, should be greater than or + * equal to {@code start} parameter, also less than or equal to + * {@code charSequence.length()} + * @param maxEmojiCount maximum number of emojis in the {@code charSequence}, should be greater + * than or equal to {@code 0} + * @param replaceStrategy whether to replace all emoji with {@link EmojiSpan}s, should be one of + * {@link #REPLACE_STRATEGY_DEFAULT}, + * {@link #REPLACE_STRATEGY_NON_EXISTENT}, + * {@link #REPLACE_STRATEGY_ALL} + * + * @throws IllegalStateException if not initialized yet + * @throws IllegalArgumentException in the following cases: + * {@code start < 0}, {@code end < 0}, {@code end < start}, + * {@code start > charSequence.length()}, + * {@code end > charSequence.length()} + * {@code maxEmojiCount < 0} + */ + @Nullable + @CheckResult + public CharSequence process(@Nullable final CharSequence charSequence, + @IntRange(from = 0) final int start, @IntRange(from = 0) final int end, + @IntRange(from = 0) final int maxEmojiCount, @ReplaceStrategy int replaceStrategy) { + Preconditions.checkState(isInitialized(), "Not initialized yet"); + Preconditions.checkArgumentNonnegative(start, "start cannot be negative"); + Preconditions.checkArgumentNonnegative(end, "end cannot be negative"); + Preconditions.checkArgumentNonnegative(maxEmojiCount, "maxEmojiCount cannot be negative"); + Preconditions.checkArgument(start <= end, "start should be <= than end"); + + // early return since there is nothing to do + if (charSequence == null) { + return null; + } + + Preconditions.checkArgument(start <= charSequence.length(), + "start should be < than charSequence length"); + Preconditions.checkArgument(end <= charSequence.length(), + "end should be < than charSequence length"); + + // early return since there is nothing to do + if (charSequence.length() == 0 || start == end) { + return charSequence; + } + + final boolean replaceAll; + switch (replaceStrategy) { + case REPLACE_STRATEGY_ALL: + replaceAll = true; + break; + case REPLACE_STRATEGY_NON_EXISTENT: + replaceAll = false; + break; + case REPLACE_STRATEGY_DEFAULT: + default: + replaceAll = mReplaceAll; + break; + } + + return mHelper.process(charSequence, start, end, maxEmojiCount, replaceAll); + } + + /** + * Returns signature for the currently loaded emoji assets. The signature is a SHA that is + * constructed using emoji assets. Can be used to detect if currently loaded asset is different + * then previous executions. When used on devices running API 18 or below, returns empty string. + * + * @throws IllegalStateException if not initialized yet + */ + @NonNull + public String getAssetSignature() { + Preconditions.checkState(isInitialized(), "Not initialized yet"); + return mHelper.getAssetSignature(); + } + + /** + * Updates the EditorInfo attributes in order to communicate information to Keyboards. When + * used on devices running API 18 or below, does not update EditorInfo attributes. + * + * This is called from EditText integrations that use EmojiEditTextHelper. Custom + * widgets that allow IME not subclassing EditText should call this method when creating an + * input connection. + * + * When EmojiCompat is not in {@link #LOAD_STATE_SUCCEEDED}, this method has no effect. + * + * Calling this method on API levels below API 19 will have no effect, as EmojiCompat may + * never be configured. However, it is always safe to call, even on older API levels. + * + * @param outAttrs EditorInfo instance passed to + * {@link android.widget.TextView#onCreateInputConnection(EditorInfo)} + * + * @see #EDITOR_INFO_METAVERSION_KEY + * @see #EDITOR_INFO_REPLACE_ALL_KEY + */ + public void updateEditorInfo(@NonNull final EditorInfo outAttrs) { + //noinspection ConstantConditions + if (!isInitialized() || outAttrs == null) { + return; + } + if (outAttrs.extras == null) { + outAttrs.extras = new Bundle(); + } + mHelper.updateEditorInfoAttrs(outAttrs); + } + + /** + * Factory class that creates the EmojiSpans. + * + * By default it creates {@link TypefaceEmojiSpan}. + * + * Apps should use this only if they want to control the drawing of EmojiSpans for non-standard + * emoji display (for example, resizing or repositioning emoji). + */ + public interface SpanFactory { + /** + * Create EmojiSpan instance. + * + * @param rasterizer TypefaceEmojiRasterizer instance, which can draw the emoji onto a + * Canvas. + * + * @return EmojiSpan instance that can use TypefaceEmojiRasterizer to draw emoji. + */ + @RequiresApi(19) + @NonNull + EmojiSpan createSpan(@NonNull TypefaceEmojiRasterizer rasterizer); + } + + + /** + * @hide + */ + @RestrictTo(LIBRARY) + public static class DefaultSpanFactory implements SpanFactory { + + /** + * Returns a TypefaceEmojiSpan. + * + * @param rasterizer TypefaceEmojiRasterizer instance, which can draw the emoji onto a + * Canvas. + * + * @return {@link TypefaceEmojiSpan} + */ + @RequiresApi(19) + @NonNull + @Override + public EmojiSpan createSpan(@NonNull TypefaceEmojiRasterizer rasterizer) { + return new TypefaceEmojiSpan(rasterizer); + } + } + + /** + * Listener class for the initialization of the EmojiCompat. + */ + public abstract static class InitCallback { + /** + * Called when EmojiCompat is initialized and the emoji data is loaded. When used on devices + * running API 18 or below, this function is always called. + */ + public void onInitialized() { + } + + /** + * Called when an unrecoverable error occurs during EmojiCompat initialization. When used on + * devices running API 18 or below, this function is never called. + */ + public void onFailed(@SuppressWarnings("unused") @Nullable Throwable throwable) { + } + } + + /** + * Interface to load emoji metadata. + */ + public interface MetadataRepoLoader { + /** + * Start loading the metadata. When the loading operation is finished {@link + * MetadataRepoLoaderCallback#onLoaded(MetadataRepo)} or + * {@link MetadataRepoLoaderCallback#onFailed(Throwable)} should be called. When used on + * devices running API 18 or below, this function is never called. + * + * @param loaderCallback callback to signal the loading state + */ + @SuppressWarnings("ExecutorRegistration") + void load(@NonNull MetadataRepoLoaderCallback loaderCallback); + } + + /** + * Interface to check if a given emoji exists on the system. + */ + public interface GlyphChecker { + /** + * Return {@code true} if the emoji that is in {@code charSequence} between + * {@code start}(inclusive) and {@code end}(exclusive) can be rendered on the system + * using the default Typeface. + * + *

This function is called after an emoji is identified in the given {@code charSequence} + * and EmojiCompat wants to know if that emoji can be rendered on the system. The result + * of this call will be cached and the same emoji sequence won't be asked for the same + * EmojiCompat instance. + * + *

When the function returns {@code true}, it will mean that the system can render the + * emoji. In that case if {@link Config#setReplaceAll} is set to {@code false}, then no + * {@link EmojiSpan} will be added in the final emoji processing result. + * + *

When the function returns {@code false}, it will mean that the system cannot render + * the given emoji, therefore an {@link EmojiSpan} will be added to the final emoji + * processing result. + * + *

The default implementation of this class uses + * {@link androidx.core.graphics.PaintCompat#hasGlyph(Paint, String)} function to check + * if the emoji can be rendered on the system. This is required even if EmojiCompat + * knows about the SDK Version that the emoji was added on AOSP. Just the {@code sdkAdded} + * information is not enough to reliably decide if emoji can be rendered since this + * information may not be consistent across all the OEMs and all the Android versions. + * + *

With this interface you can apply your own heuristics to check if the emoji can be + * rendered on the system. For example, if you'd like to rely on the {@code sdkAdded} + * information, and some predefined OEMs, it is possible to write the following code + * snippet. + * + * {@sample frameworks/support/samples/SupportEmojiDemos/src/main/java/com/example/android/support/text/emoji/sample/GlyphCheckerSample.java glyphchecker} + * + * @param charSequence the CharSequence that is being processed + * @param start the inclusive starting offset for the emoji in the {@code charSequence} + * @param end the exclusive end offset for the emoji in the {@code charSequence} + * @param sdkAdded the API version that the emoji was added in AOSP + * + * @return true if the given sequence can be rendered as a single glyph, otherwise false. + */ + boolean hasGlyph( + @NonNull CharSequence charSequence, + @IntRange(from = 0) int start, + @IntRange(from = 0) int end, + @IntRange(from = 0) int sdkAdded + ); + } + + /** + * Callback to inform EmojiCompat about the state of the metadata load. Passed to + * MetadataRepoLoader during {@link MetadataRepoLoader#load(MetadataRepoLoaderCallback)} call. + */ + public abstract static class MetadataRepoLoaderCallback { + /** + * Called by {@link MetadataRepoLoader} when metadata is loaded successfully. + * + * @param metadataRepo MetadataRepo instance, cannot be {@code null} + */ + public abstract void onLoaded(@NonNull MetadataRepo metadataRepo); + + /** + * Called by {@link MetadataRepoLoader} if an error occurs while loading the metadata. + * + * @param throwable the exception that caused the failure, {@code nullable} + */ + public abstract void onFailed(@Nullable Throwable throwable); + } + + /** + * Configuration class for EmojiCompat. Changes to the values will be ignored after + * {@link #init(Config)} is called. + * + * @see #init(EmojiCompat.Config) + */ + public abstract static class Config { + @SuppressWarnings("WeakerAccess") /* synthetic access */ + @NonNull + final MetadataRepoLoader mMetadataLoader; + + /** + * Used to create new EmojiSpans. + * + * May be set by developer using config to fully customize emoji display. + */ + SpanFactory mSpanFactory; + @SuppressWarnings("WeakerAccess") /* synthetic access */ + boolean mReplaceAll; + @SuppressWarnings("WeakerAccess") /* synthetic access */ + boolean mUseEmojiAsDefaultStyle; + @SuppressWarnings("WeakerAccess") /* synthetic access */ + @Nullable + int[] mEmojiAsDefaultStyleExceptions; + @SuppressWarnings("WeakerAccess") /* synthetic access */ + @Nullable + Set mInitCallbacks; + @SuppressWarnings("WeakerAccess") /* synthetic access */ + boolean mEmojiSpanIndicatorEnabled; + @SuppressWarnings("WeakerAccess") /* synthetic access */ + int mEmojiSpanIndicatorColor = Color.GREEN; + @SuppressWarnings("WeakerAccess") /* synthetic access */ + @LoadStrategy int mMetadataLoadStrategy = LOAD_STRATEGY_DEFAULT; + @SuppressWarnings("WeakerAccess") /* synthetic access */ + @NonNull + GlyphChecker mGlyphChecker = new DefaultGlyphChecker(); + + /** + * Default constructor. + * + * @param metadataLoader MetadataRepoLoader instance, cannot be {@code null} + */ + protected Config(@NonNull final MetadataRepoLoader metadataLoader) { + Preconditions.checkNotNull(metadataLoader, "metadataLoader cannot be null."); + mMetadataLoader = metadataLoader; + } + + /** + * Registers an initialization callback. + * + * @param initCallback the initialization callback to register, cannot be {@code null} + * + * @return EmojiCompat.Config instance + */ + @SuppressWarnings("ExecutorRegistration") + @NonNull + public Config registerInitCallback(@NonNull InitCallback initCallback) { + Preconditions.checkNotNull(initCallback, "initCallback cannot be null"); + if (mInitCallbacks == null) { + mInitCallbacks = new ArraySet<>(); + } + + mInitCallbacks.add(initCallback); + + return this; + } + + /** + * Unregisters a callback that was added before. + * + * @param initCallback the initialization callback to be removed, cannot be {@code null} + * + * @return EmojiCompat.Config instance + */ + @NonNull + public Config unregisterInitCallback(@NonNull InitCallback initCallback) { + Preconditions.checkNotNull(initCallback, "initCallback cannot be null"); + if (mInitCallbacks != null) { + mInitCallbacks.remove(initCallback); + } + return this; + } + + /** + * Determines whether EmojiCompat should replace all the emojis it finds with the + * EmojiSpans. By default EmojiCompat tries its best to understand if the system already + * can render an emoji and do not replace those emojis. + * + * @param replaceAll replace all emojis found with EmojiSpans + * + * @return EmojiCompat.Config instance + */ + @NonNull + public Config setReplaceAll(final boolean replaceAll) { + mReplaceAll = replaceAll; + return this; + } + + /** + * Determines whether EmojiCompat should use the emoji presentation style for emojis + * that have text style as default. By default, the text style would be used, unless these + * are followed by the U+FE0F variation selector. + * Details about emoji presentation and text presentation styles can be found here: + * http://unicode.org/reports/tr51/#Presentation_Style + * If useEmojiAsDefaultStyle is true, the emoji presentation style will be used for all + * emojis, including potentially unexpected ones (such as digits or other keycap emojis). If + * this is not the expected behaviour, method + * {@link #setUseEmojiAsDefaultStyle(boolean, List)} can be used to specify the + * exception emojis that should be still presented as text style. + * + * @param useEmojiAsDefaultStyle whether to use the emoji style presentation for all emojis + * that would be presented as text style by default + */ + @NonNull + public Config setUseEmojiAsDefaultStyle(final boolean useEmojiAsDefaultStyle) { + return setUseEmojiAsDefaultStyle(useEmojiAsDefaultStyle, + null); + } + + /** + * @see #setUseEmojiAsDefaultStyle(boolean) + * + * @param emojiAsDefaultStyleExceptions Contains the exception emojis which will be still + * presented as text style even if the + * useEmojiAsDefaultStyle flag is set to {@code true}. + * This list will be ignored if useEmojiAsDefaultStyle + * is {@code false}. Note that emojis with default + * emoji style presentation will remain emoji style + * regardless the value of useEmojiAsDefaultStyle or + * whether they are included in the exceptions list or + * not. When no exception is wanted, the method + * {@link #setUseEmojiAsDefaultStyle(boolean)} should + * be used instead. + */ + @NonNull + public Config setUseEmojiAsDefaultStyle(final boolean useEmojiAsDefaultStyle, + @Nullable final List emojiAsDefaultStyleExceptions) { + mUseEmojiAsDefaultStyle = useEmojiAsDefaultStyle; + if (mUseEmojiAsDefaultStyle && emojiAsDefaultStyleExceptions != null) { + mEmojiAsDefaultStyleExceptions = new int[emojiAsDefaultStyleExceptions.size()]; + int i = 0; + for (Integer exception : emojiAsDefaultStyleExceptions) { + mEmojiAsDefaultStyleExceptions[i++] = exception; + } + Arrays.sort(mEmojiAsDefaultStyleExceptions); + } else { + mEmojiAsDefaultStyleExceptions = null; + } + return this; + } + + /** + * Determines whether a background will be drawn for the emojis that are found and + * replaced by EmojiCompat. Should be used only for debugging purposes. The indicator color + * can be set using {@link #setEmojiSpanIndicatorColor(int)}. + * + * @param emojiSpanIndicatorEnabled when {@code true} a background is drawn for each emoji + * that is replaced + */ + @NonNull + public Config setEmojiSpanIndicatorEnabled(boolean emojiSpanIndicatorEnabled) { + mEmojiSpanIndicatorEnabled = emojiSpanIndicatorEnabled; + return this; + } + + /** + * Sets the color used as emoji span indicator. The default value is + * {@link Color#GREEN Color.GREEN}. + * + * @see #setEmojiSpanIndicatorEnabled(boolean) + */ + @NonNull + public Config setEmojiSpanIndicatorColor(@ColorInt int color) { + mEmojiSpanIndicatorColor = color; + return this; + } + + /** + * Determines the strategy to start loading the metadata. By default {@link EmojiCompat} + * will start loading the metadata during {@link EmojiCompat#init(Config)}. When set to + * {@link EmojiCompat#LOAD_STRATEGY_MANUAL}, you should call {@link EmojiCompat#load()} to + * initiate metadata loading. + *

+ * Default implementations of {@link EmojiCompat.MetadataRepoLoader} start a thread + * during their {@link EmojiCompat.MetadataRepoLoader#load} functions. Just instantiating + * and starting a thread might take time especially in older devices. Since + * {@link EmojiCompat#init(Config)} has to be called before any EmojiCompat widgets are + * inflated, this results in time spent either on your Application.onCreate or Activity + * .onCreate. If you'd like to gain more control on when to start loading the metadata + * and be able to call {@link EmojiCompat#init(Config)} with absolute minimum time cost you + * can use {@link EmojiCompat#LOAD_STRATEGY_MANUAL}. + *

+ * When set to {@link EmojiCompat#LOAD_STRATEGY_MANUAL}, {@link EmojiCompat} will wait + * for {@link #load()} to be called by the developer in order to start loading metadata, + * therefore you should call {@link EmojiCompat#load()} to initiate metadata loading. + * {@link #load()} can be called from any thread. + *

+         * EmojiCompat.Config config = new FontRequestEmojiCompatConfig(context, fontRequest)
+         *         .setMetadataLoadStrategy(EmojiCompat.LOAD_STRATEGY_MANUAL);
+         *
+         * // EmojiCompat will not start loading metadata and MetadataRepoLoader#load(...)
+         * // will not be called
+         * EmojiCompat.init(config);
+         *
+         * // At any time (i.e. idle time or executorService is ready)
+         * // call EmojiCompat#load() to start loading metadata.
+         * executorService.execute(() -> EmojiCompat.get().load());
+         * 
+ * + * @param strategy one of {@link EmojiCompat#LOAD_STRATEGY_DEFAULT}, + * {@link EmojiCompat#LOAD_STRATEGY_MANUAL} + * + */ + @NonNull + public Config setMetadataLoadStrategy(@LoadStrategy int strategy) { + mMetadataLoadStrategy = strategy; + return this; + } + + /** + * Set the span factory used to actually draw emoji replacements. + * + * @param factory custum span factory that can draw the emoji replacements + * @return this + */ + @NonNull + public Config setSpanFactory(@NonNull SpanFactory factory) { + mSpanFactory = factory; + return this; + } + + /** + * The interface that is used by EmojiCompat in order to check if a given emoji can be + * rendered by the system. + * + * @param glyphChecker {@link GlyphChecker} instance to be used. + */ + @NonNull + public Config setGlyphChecker(@NonNull GlyphChecker glyphChecker) { + Preconditions.checkNotNull(glyphChecker, "GlyphChecker cannot be null"); + mGlyphChecker = glyphChecker; + return this; + } + + /** + * Returns the {@link MetadataRepoLoader}. + */ + @NonNull + protected final MetadataRepoLoader getMetadataRepoLoader() { + return mMetadataLoader; + } + } + + /** + * Runnable to call success/failure case for the listeners. + */ + private static class ListenerDispatcher implements Runnable { + private final List mInitCallbacks; + private final Throwable mThrowable; + private final int mLoadState; + + @SuppressWarnings("ArraysAsListWithZeroOrOneArgument") + ListenerDispatcher(@NonNull final InitCallback initCallback, + @LoadState final int loadState) { + this(Arrays.asList(Preconditions.checkNotNull(initCallback, + "initCallback cannot be null")), loadState, null); + } + + ListenerDispatcher(@NonNull final Collection initCallbacks, + @LoadState final int loadState) { + this(initCallbacks, loadState, null); + } + + ListenerDispatcher(@NonNull final Collection initCallbacks, + @LoadState final int loadState, + @Nullable final Throwable throwable) { + Preconditions.checkNotNull(initCallbacks, "initCallbacks cannot be null"); + mInitCallbacks = new ArrayList<>(initCallbacks); + mLoadState = loadState; + mThrowable = throwable; + } + + @Override + public void run() { + final int size = mInitCallbacks.size(); + switch (mLoadState) { + case LOAD_STATE_SUCCEEDED: + for (int i = 0; i < size; i++) { + mInitCallbacks.get(i).onInitialized(); + } + break; + case LOAD_STATE_FAILED: + default: + for (int i = 0; i < size; i++) { + mInitCallbacks.get(i).onFailed(mThrowable); + } + break; + } + } + } + + /** + * Internal helper class to behave no-op for certain functions. + */ + private static class CompatInternal { + final EmojiCompat mEmojiCompat; + + CompatInternal(EmojiCompat emojiCompat) { + mEmojiCompat = emojiCompat; + } + + void loadMetadata() { + // Moves into LOAD_STATE_SUCCESS state immediately. + mEmojiCompat.onMetadataLoadSuccess(); + } + + boolean hasEmojiGlyph(@NonNull final CharSequence sequence) { + // Since no metadata is loaded, EmojiCompat cannot detect or render any emojis. + return false; + } + + boolean hasEmojiGlyph(@NonNull final CharSequence sequence, final int metadataVersion) { + // Since no metadata is loaded, EmojiCompat cannot detect or render any emojis. + return false; + } + + int getEmojiStart(@NonNull final CharSequence cs, @IntRange(from = 0) final int offset) { + // Since no metadata is loaded, EmojiCompat cannot detect any emojis. + return -1; + } + + int getEmojiEnd(@NonNull final CharSequence cs, @IntRange(from = 0) final int offset) { + // Since no metadata is loaded, EmojiCompat cannot detect any emojis. + return -1; + } + + CharSequence process(@NonNull final CharSequence charSequence, + @IntRange(from = 0) final int start, @IntRange(from = 0) final int end, + @IntRange(from = 0) final int maxEmojiCount, boolean replaceAll) { + // Returns the given charSequence as it is. + return charSequence; + } + + void updateEditorInfoAttrs(@NonNull final EditorInfo outAttrs) { + // Does not add any EditorInfo attributes. + } + + String getAssetSignature() { + return ""; + } + + @CodepointSequenceMatchResult + public int getEmojiMatch(CharSequence sequence, int metadataVersion) { + return EMOJI_UNSUPPORTED; + } + } + + @RequiresApi(19) + private static final class CompatInternal19 extends CompatInternal { + /** + * Responsible to process a CharSequence and add the spans. @{code Null} until the time the + * metadata is loaded. + */ + private volatile EmojiProcessor mProcessor; + + /** + * Keeps the information about emojis. Null until the time the data is loaded. + */ + private volatile MetadataRepo mMetadataRepo; + + + CompatInternal19(EmojiCompat emojiCompat) { + super(emojiCompat); + } + + @Override + void loadMetadata() { + try { + final MetadataRepoLoaderCallback callback = new MetadataRepoLoaderCallback() { + @Override + public void onLoaded(@NonNull MetadataRepo metadataRepo) { + onMetadataLoadSuccess(metadataRepo); + } + + @Override + public void onFailed(@Nullable Throwable throwable) { + mEmojiCompat.onMetadataLoadFailed(throwable); + } + }; + mEmojiCompat.mMetadataLoader.load(callback); + } catch (Throwable t) { + mEmojiCompat.onMetadataLoadFailed(t); + } + } + + @SuppressWarnings("SyntheticAccessor") + void onMetadataLoadSuccess(@NonNull final MetadataRepo metadataRepo) { + //noinspection ConstantConditions + if (metadataRepo == null) { + mEmojiCompat.onMetadataLoadFailed( + new IllegalArgumentException("metadataRepo cannot be null")); + return; + } + + mMetadataRepo = metadataRepo; + mProcessor = new EmojiProcessor( + mMetadataRepo, + mEmojiCompat.mSpanFactory, + mEmojiCompat.mGlyphChecker, + mEmojiCompat.mUseEmojiAsDefaultStyle, + mEmojiCompat.mEmojiAsDefaultStyleExceptions, + EmojiExclusions.getEmojiExclusions() + ); + + mEmojiCompat.onMetadataLoadSuccess(); + } + + @Override + boolean hasEmojiGlyph(@NonNull CharSequence sequence) { + return mProcessor.getEmojiMatch(sequence) == EMOJI_SUPPORTED; + } + + @Override + boolean hasEmojiGlyph(@NonNull CharSequence sequence, int metadataVersion) { + int emojiMatch = mProcessor.getEmojiMatch(sequence, metadataVersion); + return emojiMatch == EMOJI_SUPPORTED; + } + + @Override + public int getEmojiMatch(CharSequence sequence, int metadataVersion) { + return mProcessor.getEmojiMatch(sequence, metadataVersion); + } + + @Override + int getEmojiStart(@NonNull final CharSequence sequence, final int offset) { + return mProcessor.getEmojiStart(sequence, offset); + } + + @Override + int getEmojiEnd(@NonNull final CharSequence sequence, final int offset) { + return mProcessor.getEmojiEnd(sequence, offset); + } + + @Override + CharSequence process(@NonNull CharSequence charSequence, int start, int end, + int maxEmojiCount, boolean replaceAll) { + return mProcessor.process(charSequence, start, end, maxEmojiCount, replaceAll); + } + + @Override + void updateEditorInfoAttrs(@NonNull EditorInfo outAttrs) { + outAttrs.extras.putInt(EDITOR_INFO_METAVERSION_KEY, mMetadataRepo.getMetadataVersion()); + outAttrs.extras.putBoolean(EDITOR_INFO_REPLACE_ALL_KEY, mEmojiCompat.mReplaceAll); + } + + @Override + String getAssetSignature() { + final String sha = mMetadataRepo.getMetadataList().sourceSha(); + return sha == null ? "" : sha; + } + } +} diff --git a/app/src/main/java/androidx/emoji2/text/EmojiCompatInitializer.java b/app/src/main/java/androidx/emoji2/text/EmojiCompatInitializer.java new file mode 100644 index 0000000000..f4823bf095 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/EmojiCompatInitializer.java @@ -0,0 +1,208 @@ +/* + * Copyright 2021 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.emoji2.text; + +import android.content.Context; +import android.os.Build; +import android.os.Handler; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.WorkerThread; +import androidx.core.os.TraceCompat; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.ProcessLifecycleInitializer; +import androidx.startup.AppInitializer; +import androidx.startup.Initializer; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * Initializer for configuring EmojiCompat with the system installed downloadable font provider. + * + *

This initializer will initialize EmojiCompat immediately then defer loading the font for a + * short delay to avoid delaying application startup. Typically, the font will be loaded shortly + * after the first screen of your application loads, which means users may see system emoji + * briefly prior to the compat font loading.

+ * + *

This is the recommended configuration for all apps that don't need specialized configuration, + * and don't need to control the background thread that initialization runs on. For more information + * see {@link androidx.emoji2.text.DefaultEmojiCompatConfig}.

+ * + *

In addition to the reasons listed in {@code DefaultEmojiCompatConfig} you may wish to disable + * this automatic configuration if you intend to call initialization from an existing background + * thread pool in your application.

+ * + *

This is enabled by default by including the {@code :emoji2:emoji2} gradle artifact. To + * disable the default configuration (and allow manual configuration) add this to your manifest:

+ * + *
+ *     <provider
+ *         android:name="androidx.startup.InitializationProvider"
+ *         android:authorities="${applicationId}.androidx-startup"
+ *         android:exported="false"
+ *         tools:node="merge">
+ *         <meta-data android:name="androidx.emoji2.text.EmojiCompatInitializer"
+ *                   tools:node="remove" />
+ *     </provider>
+ * 
+ * + * This initializer depends on {@link ProcessLifecycleInitializer}. + * + * @see androidx.emoji2.text.DefaultEmojiCompatConfig + */ +public class EmojiCompatInitializer implements Initializer { + private static final long STARTUP_THREAD_CREATION_DELAY_MS = 500L; + private static final String S_INITIALIZER_THREAD_NAME = "EmojiCompatInitializer"; + + /** + * Initialize EmojiCompat with the app's context. + * + * @param context application context + * @return result of default init + */ + @SuppressWarnings("AutoBoxing") + @NonNull + @Override + public Boolean create(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= 19) { + EmojiCompat.init(new BackgroundDefaultConfig(context)); + delayUntilFirstResume(context); + return true; + } + return false; + } + + /** + * Wait until the first frame of the application to do anything. + * + * This allows startup code to run before the delay is scheduled. + */ + @RequiresApi(19) + void delayUntilFirstResume(@NonNull Context context) { + // schedule delay after first Activity resumes + AppInitializer appInitializer = AppInitializer.getInstance(context); + LifecycleOwner lifecycleOwner = appInitializer + .initializeComponent(ProcessLifecycleInitializer.class); + Lifecycle lifecycle = lifecycleOwner.getLifecycle(); + lifecycle.addObserver(new DefaultLifecycleObserver() { + @Override + public void onResume(@NonNull LifecycleOwner owner) { + loadEmojiCompatAfterDelay(); + lifecycle.removeObserver(this); + } + }); + } + + @RequiresApi(19) + void loadEmojiCompatAfterDelay() { + final Handler mainHandler = ConcurrencyHelpers.mainHandlerAsync(); + mainHandler.postDelayed(new LoadEmojiCompatRunnable(), STARTUP_THREAD_CREATION_DELAY_MS); + } + + /** + * Dependes on ProcessLifecycleInitializer + */ + @NonNull + @Override + public List>> dependencies() { + return Collections.singletonList(ProcessLifecycleInitializer.class); + } + + static class LoadEmojiCompatRunnable implements Runnable { + @Override + public void run() { + try { + // this is main thread, so mark what we're doing (this trace includes thread + // start time in BackgroundLoadingLoader.load + TraceCompat.beginSection("EmojiCompat.EmojiCompatInitializer.run"); + if (EmojiCompat.isConfigured()) { + EmojiCompat.get().load(); + } + } finally { + TraceCompat.endSection(); + } + } + } + + @RequiresApi(19) + static class BackgroundDefaultConfig extends EmojiCompat.Config { + protected BackgroundDefaultConfig(Context context) { + super(new BackgroundDefaultLoader(context)); + setMetadataLoadStrategy(EmojiCompat.LOAD_STRATEGY_MANUAL); + } + } + + @RequiresApi(19) + static class BackgroundDefaultLoader implements EmojiCompat.MetadataRepoLoader { + private final Context mContext; + + BackgroundDefaultLoader(Context context) { + mContext = context.getApplicationContext(); + } + + @Override + public void load(@NonNull EmojiCompat.MetadataRepoLoaderCallback loaderCallback) { + ThreadPoolExecutor executor = ConcurrencyHelpers.createBackgroundPriorityExecutor( + S_INITIALIZER_THREAD_NAME); + executor.execute(() -> doLoad(loaderCallback, executor)); + } + + @WorkerThread + void doLoad(@NonNull EmojiCompat.MetadataRepoLoaderCallback loaderCallback, + @NonNull ThreadPoolExecutor executor) { + try { + FontRequestEmojiCompatConfig config = DefaultEmojiCompatConfig.create(mContext); + if (config == null) { + throw new RuntimeException("EmojiCompat font provider not available on this " + + "device."); + } + config.setLoadingExecutor(executor); + config.getMetadataRepoLoader().load(new EmojiCompat.MetadataRepoLoaderCallback() { + @Override + public void onLoaded(@NonNull MetadataRepo metadataRepo) { + try { + // main thread is notified before returning, so we can quit now + loaderCallback.onLoaded(metadataRepo); + } finally { + executor.shutdown(); + } + } + + @Override + public void onFailed(@Nullable Throwable throwable) { + try { + // main thread is notified before returning, so we can quit now + loaderCallback.onFailed(throwable); + } finally { + executor.shutdown(); + } + } + }); + } catch (Throwable t) { + loaderCallback.onFailed(t); + executor.shutdown(); + } + } + } + +} diff --git a/app/src/main/java/androidx/emoji2/text/EmojiDefaults.java b/app/src/main/java/androidx/emoji2/text/EmojiDefaults.java new file mode 100644 index 0000000000..9818af1d8e --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/EmojiDefaults.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021 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.emoji2.text; + +import androidx.annotation.RestrictTo; + +/** + * Defaults for emojicompat + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY) +public class EmojiDefaults { + + private EmojiDefaults() {} + + /** + * Default value for maxEmojiCount if not specified. + */ + public static final int MAX_EMOJI_COUNT = Integer.MAX_VALUE; +} diff --git a/app/src/main/java/androidx/emoji2/text/EmojiExclusions.java b/app/src/main/java/androidx/emoji2/text/EmojiExclusions.java new file mode 100644 index 0000000000..f93959c831 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/EmojiExclusions.java @@ -0,0 +1,89 @@ +/* + * Copyright 2021 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.emoji2.text; + +import android.annotation.SuppressLint; +import android.os.Build; + +import androidx.annotation.DoNotInline; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.Set; + +class EmojiExclusions { + private EmojiExclusions() { /* cannot instantiate */ } + + @NonNull + static Set getEmojiExclusions() { + if (Build.VERSION.SDK_INT >= 34) { + return EmojiExclusions_Api34.getExclusions(); + } else { + return EmojiExclusions_Reflections.getExclusions(); + } + } + + @RequiresApi(34) + private static class EmojiExclusions_Api34 { + private EmojiExclusions_Api34() { /* cannot instantiate */ } + + @NonNull + @DoNotInline + static Set getExclusions() { + // TODO: Call directly when API34 is published + return EmojiExclusions_Reflections.getExclusions(); + } + } + + private static class EmojiExclusions_Reflections { + private EmojiExclusions_Reflections() { /* cannot instantiate */ } + + /** + * Attempt to reflectively call EmojiExclusion + * + * If anything goes wrong, return Collections.emptySet. + */ + @SuppressWarnings("unchecked") + // will be checked after platform API for 34 published + @SuppressLint({ "BanUncheckedReflection" }) + @NonNull + static Set getExclusions() { + try { + Class clazz = Class.forName("android.text.EmojiConsistency"); + Method method = clazz.getMethod("getEmojiConsistencySet"); + Object result = method.invoke(null); + if (result == null) { + return Collections.emptySet(); + } + // validate the result type before exposing it to caller + Set resultList = (Set) result; + for (Object item : resultList) { + if (!(item instanceof int[])) { + return Collections.emptySet(); + } + } + return (Set) resultList; + } catch (Throwable ignore) { + return Collections.emptySet(); + + } + } + } + +} diff --git a/app/src/main/java/androidx/emoji2/text/EmojiProcessor.java b/app/src/main/java/androidx/emoji2/text/EmojiProcessor.java new file mode 100644 index 0000000000..6efcd7a99b --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/EmojiProcessor.java @@ -0,0 +1,1000 @@ +/* + * Copyright 2021 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.emoji2.text; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY; + +import android.text.Editable; +import android.text.Selection; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.method.KeyListener; +import android.text.method.MetaKeyKeyListener; +import android.view.KeyEvent; +import android.view.inputmethod.InputConnection; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +/** + * Processes the CharSequence and adds the emojis. + * + * @hide + */ +@AnyThread +@RestrictTo(LIBRARY) +@RequiresApi(19) +final class EmojiProcessor { + + /** + * State transition commands. + */ + @IntDef({ACTION_ADVANCE_BOTH, ACTION_ADVANCE_END, ACTION_FLUSH}) + @Retention(RetentionPolicy.SOURCE) + private @interface Action { + } + + private interface EmojiProcessCallback { + /** + * Invoked on every emoji found during {@link #process}. + * Returning {@code false} can abort this {@link #process} loop. + */ + boolean handleEmoji(@NonNull CharSequence charSequence, int start, int end, + TypefaceEmojiRasterizer metadata); + + /** + * @return the result after process. + */ + T getResult(); + } + + /** + * Advance the end pointer in CharSequence and reset the start to be the end. + */ + private static final int ACTION_ADVANCE_BOTH = 1; + + /** + * Advance end pointer in CharSequence. + */ + private static final int ACTION_ADVANCE_END = 2; + + /** + * Add a new emoji with the metadata in {@link ProcessorSm#getFlushMetadata()}. Advance end + * pointer in CharSequence and reset the start to be the end. + */ + private static final int ACTION_FLUSH = 3; + + /** + * The max number of characters look around in {@link #getEmojiStart(CharSequence, int)} and + * {@link #getEmojiEnd(CharSequence, int)}. + */ + private static final int MAX_LOOK_AROUND_CHARACTER = 16; + + /** + * Factory used to create EmojiSpans. + */ + @NonNull + private final EmojiCompat.SpanFactory mSpanFactory; + + /** + * Emoji metadata repository. + */ + @NonNull + private final MetadataRepo mMetadataRepo; + + /** + * Utility class that checks if the system can render a given glyph. + */ + @NonNull + private EmojiCompat.GlyphChecker mGlyphChecker; + + /** + * @see EmojiCompat.Config#setUseEmojiAsDefaultStyle(boolean) + */ + private final boolean mUseEmojiAsDefaultStyle; + + /** + * @see EmojiCompat.Config#setUseEmojiAsDefaultStyle(boolean, List) + */ + @Nullable + private final int[] mEmojiAsDefaultStyleExceptions; + + EmojiProcessor( + @NonNull final MetadataRepo metadataRepo, + @NonNull final EmojiCompat.SpanFactory spanFactory, + @NonNull final EmojiCompat.GlyphChecker glyphChecker, + final boolean useEmojiAsDefaultStyle, + @Nullable final int[] emojiAsDefaultStyleExceptions, + @NonNull Set emojiExclusions) { + mSpanFactory = spanFactory; + mMetadataRepo = metadataRepo; + mGlyphChecker = glyphChecker; + mUseEmojiAsDefaultStyle = useEmojiAsDefaultStyle; + mEmojiAsDefaultStyleExceptions = emojiAsDefaultStyleExceptions; + initExclusions(emojiExclusions); + } + + private void initExclusions(@NonNull Set emojiExclusions) { + if (emojiExclusions.isEmpty()) { + return; + } + for (int[] codepoints : emojiExclusions) { + String emoji = new String(codepoints, 0, codepoints.length); + MarkExclusionCallback callback = new MarkExclusionCallback(emoji); + process(emoji, 0, emoji.length(), 1, true, callback); + } + } + + @EmojiCompat.CodepointSequenceMatchResult + int getEmojiMatch(@NonNull final CharSequence charSequence) { + return getEmojiMatch(charSequence, mMetadataRepo.getMetadataVersion()); + } + + @EmojiCompat.CodepointSequenceMatchResult + int getEmojiMatch(@NonNull final CharSequence charSequence, + final int metadataVersion) { + final ProcessorSm sm = new ProcessorSm(mMetadataRepo.getRootNode(), + mUseEmojiAsDefaultStyle, mEmojiAsDefaultStyleExceptions); + final int end = charSequence.length(); + int currentOffset = 0; + int potentialSubsequenceMatch = 0; + int subsequenceMatch = 0; + + while (currentOffset < end) { + final int codePoint = Character.codePointAt(charSequence, currentOffset); + final int action = sm.check(codePoint); + TypefaceEmojiRasterizer currentNode = sm.getCurrentMetadata(); + switch (action) { + case ACTION_FLUSH: { + // this happens when matching new unknown ZWJ sequences that are comprised of + // known emoji + currentNode = sm.getFlushMetadata(); + if (currentNode.getCompatAdded() <= metadataVersion) { + subsequenceMatch++; + } + break; + } + case ACTION_ADVANCE_BOTH: { + currentOffset += Character.charCount(codePoint); + // state machine decided to skip previous entries + potentialSubsequenceMatch = 0; + break; + } case ACTION_ADVANCE_END: { + currentOffset += Character.charCount(codePoint); + break; + } + } + if (currentNode != null && currentNode.getCompatAdded() <= metadataVersion) { + potentialSubsequenceMatch++; + } + } + + if (subsequenceMatch != 0) { + // if we matched multiple emoji on the first pass, then the current emoji font + // doesn't know about the codepoint sequence, and will decompose when REPLACE_ALL = true + return EmojiCompat.EMOJI_FALLBACK; + } + + if (sm.isInFlushableState()) { + // We matched exactly one emoji + // EmojiCompat can completely handle this sequence + TypefaceEmojiRasterizer exactMatch = sm.getCurrentMetadata(); + if (exactMatch.getCompatAdded() <= metadataVersion) { + return EmojiCompat.EMOJI_SUPPORTED; + } + } + // if we get here than we definitely do not know the emoji, decide if we will decompose + if (potentialSubsequenceMatch == 0) { + return EmojiCompat.EMOJI_UNSUPPORTED; + } else { + return EmojiCompat.EMOJI_FALLBACK; + } + } + + + /** + * see {@link EmojiCompat#getEmojiStart(CharSequence, int)}. + */ + int getEmojiStart(@NonNull final CharSequence charSequence, @IntRange(from = 0) int offset) { + if (offset < 0 || offset >= charSequence.length()) { + return -1; + } + + if (charSequence instanceof Spanned) { + final Spanned spanned = (Spanned) charSequence; + final EmojiSpan[] spans = spanned.getSpans(offset, offset + 1, EmojiSpan.class); + if (spans.length > 0) { + return spanned.getSpanStart(spans[0]); + } + } + + // TODO: come up with some heuristic logic to better determine the interval + final int start = Math.max(0, offset - MAX_LOOK_AROUND_CHARACTER); + final int end = Math.min(charSequence.length(), offset + MAX_LOOK_AROUND_CHARACTER); + return process(charSequence, start, end, EmojiCompat.EMOJI_COUNT_UNLIMITED, true, + new EmojiProcessLookupCallback(offset)).start; + } + + /** + * see {@link EmojiCompat#getEmojiStart(CharSequence, int)}. + */ + int getEmojiEnd(@NonNull final CharSequence charSequence, @IntRange(from = 0) int offset) { + if (offset < 0 || offset >= charSequence.length()) { + return -1; + } + + if (charSequence instanceof Spanned) { + final Spanned spanned = (Spanned) charSequence; + final EmojiSpan[] spans = spanned.getSpans(offset, offset + 1, EmojiSpan.class); + if (spans.length > 0) { + return spanned.getSpanEnd(spans[0]); + } + } + + // TODO: come up with some heuristic logic to better determine the interval + final int start = Math.max(0, offset - MAX_LOOK_AROUND_CHARACTER); + final int end = Math.min(charSequence.length(), offset + MAX_LOOK_AROUND_CHARACTER); + return process(charSequence, start, end, EmojiCompat.EMOJI_COUNT_UNLIMITED, true, + new EmojiProcessLookupCallback(offset)).end; + } + + /** + * Checks a given CharSequence for emojis, and adds EmojiSpans if any emojis are found. + *

+ *

    + *
  • If no emojis are found, {@code charSequence} given as the input is returned without + * any changes. i.e. charSequence is a String, and no emojis are found, the same String is + * returned.
  • + *
  • If the given input is not a Spannable (such as String), and at least one emoji is found + * a new {@link android.text.Spannable} instance is returned.
  • + *
  • If the given input is a Spannable, the same instance is returned.
  • + *
+ * + * @param charSequence CharSequence to add the EmojiSpans, cannot be {@code null} + * @param start start index in the charSequence to look for emojis, should be greater than or + * equal to {@code 0}, also less than {@code charSequence.length()} + * @param end end index in the charSequence to look for emojis, should be greater than or + * equal to {@code start} parameter, also less than {@code charSequence.length()} + * @param maxEmojiCount maximum number of emojis in the {@code charSequence}, should be greater + * than or equal to {@code 0} + * @param replaceAll whether to replace all emoji with {@link EmojiSpan}s + */ + CharSequence process(@NonNull final CharSequence charSequence, @IntRange(from = 0) int start, + @IntRange(from = 0) int end, @IntRange(from = 0) int maxEmojiCount, + final boolean replaceAll) { + final boolean isSpannableBuilder = charSequence instanceof SpannableBuilder; + if (isSpannableBuilder) { + ((SpannableBuilder) charSequence).beginBatchEdit(); + } + + try { + UnprecomputeTextOnModificationSpannable spannable = null; + // if it is a spannable already, use the same instance to add/remove EmojiSpans. + // otherwise wait until the first EmojiSpan found in order to change the result + // into a Spannable. + if (isSpannableBuilder || charSequence instanceof Spannable) { + spannable = new UnprecomputeTextOnModificationSpannable((Spannable) charSequence); + } else if (charSequence instanceof Spanned) { + // check if there are any EmojiSpans as cheap as possible + // start-1, end+1 will return emoji span that starts/ends at start/end indices + final int nextSpanTransition = ((Spanned) charSequence).nextSpanTransition( + start - 1, end + 1, EmojiSpan.class); + + if (nextSpanTransition <= end) { + spannable = new UnprecomputeTextOnModificationSpannable(charSequence); + } + } + + if (spannable != null) { + final EmojiSpan[] spans = spannable.getSpans(start, end, EmojiSpan.class); + if (spans != null && spans.length > 0) { + // remove existing spans, and realign the start, end according to spans + // if start or end is in the middle of an emoji they should be aligned + final int length = spans.length; + for (int index = 0; index < length; index++) { + final EmojiSpan span = spans[index]; + final int spanStart = spannable.getSpanStart(span); + final int spanEnd = spannable.getSpanEnd(span); + // Remove span only when its spanStart is NOT equal to current end. + // During add operation an emoji at index 0 is added with 0-1 as start and + // end indices. Therefore if there are emoji spans at [0-1] and [1-2] + // and end is 1, the span between 0-1 should be deleted, not 1-2. + if (spanStart != end) { + spannable.removeSpan(span); + } + start = Math.min(spanStart, start); + end = Math.max(spanEnd, end); + } + } + } + + if (start == end || start >= charSequence.length()) { + return charSequence; + } + + // calculate max number of emojis that can be added. since getSpans call is a relatively + // expensive operation, do it only when maxEmojiCount is not unlimited. + if (maxEmojiCount != EmojiCompat.EMOJI_COUNT_UNLIMITED && spannable != null) { + maxEmojiCount -= spannable.getSpans(0, spannable.length(), EmojiSpan.class).length; + } + + spannable = process(charSequence, start, end, maxEmojiCount, replaceAll, + new EmojiProcessAddSpanCallback(spannable, mSpanFactory)); + + // if nothing was written, always return the source + if (spannable != null) { + return spannable.getUnwrappedSpannable(); + } else { + return charSequence; + } + } finally { + if (isSpannableBuilder) { + ((SpannableBuilder) charSequence).endBatchEdit(); + } + } + } + + private T process(@NonNull final CharSequence charSequence, @IntRange(from = 0) int start, + @IntRange(from = 0) int end, @IntRange(from = 0) int maxEmojiCount, + final boolean processAll, final EmojiProcessCallback emojiProcessCallback) { + int addedCount = 0; + final ProcessorSm sm = new ProcessorSm(mMetadataRepo.getRootNode(), + mUseEmojiAsDefaultStyle, mEmojiAsDefaultStyleExceptions); + + int currentOffset = start; + int codePoint = Character.codePointAt(charSequence, currentOffset); + boolean keepProcessing = true; + + while (currentOffset < end && addedCount < maxEmojiCount && keepProcessing) { + final int action = sm.check(codePoint); + + switch (action) { + case ACTION_ADVANCE_BOTH: + start += Character.charCount(Character.codePointAt(charSequence, start)); + currentOffset = start; + if (currentOffset < end) { + codePoint = Character.codePointAt(charSequence, currentOffset); + } + break; + case ACTION_ADVANCE_END: + currentOffset += Character.charCount(codePoint); + if (currentOffset < end) { + codePoint = Character.codePointAt(charSequence, currentOffset); + } + break; + case ACTION_FLUSH: + if (processAll || !hasGlyph(charSequence, start, currentOffset, + sm.getFlushMetadata())) { + keepProcessing = emojiProcessCallback.handleEmoji(charSequence, start, + currentOffset, sm.getFlushMetadata()); + addedCount++; + } + start = currentOffset; + break; + } + } + + // After the last codepoint is consumed the state machine might be in a state where it + // identified an emoji before. i.e. abc[women-emoji] when the last codepoint is consumed + // state machine is waiting to see if there is an emoji sequence (i.e. ZWJ). + // Need to check if it is in such a state. + if (sm.isInFlushableState() && addedCount < maxEmojiCount && keepProcessing) { + if (processAll || !hasGlyph(charSequence, start, currentOffset, + sm.getCurrentMetadata())) { + emojiProcessCallback.handleEmoji(charSequence, start, + currentOffset, sm.getCurrentMetadata()); + addedCount++; + } + } + + return emojiProcessCallback.getResult(); + } + + /** + * Handles onKeyDown commands from a {@link KeyListener} and if {@code keyCode} is one of + * {@link KeyEvent#KEYCODE_DEL} or {@link KeyEvent#KEYCODE_FORWARD_DEL} it tries to delete an + * {@link EmojiSpan} from an {@link Editable}. Returns {@code true} if an {@link EmojiSpan} is + * deleted with the characters it covers. + *

+ * If there is a selection where selection start is not equal to selection end, does not + * delete. + * + * @param editable Editable instance passed to {@link KeyListener#onKeyDown(android.view.View, + * Editable, int, KeyEvent)} + * @param keyCode keyCode passed to {@link KeyListener#onKeyDown(android.view.View, Editable, + * int, KeyEvent)} + * @param event KeyEvent passed to {@link KeyListener#onKeyDown(android.view.View, Editable, + * int, KeyEvent)} + * + * @return {@code true} if an {@link EmojiSpan} is deleted + */ + static boolean handleOnKeyDown(@NonNull final Editable editable, final int keyCode, + @NonNull final KeyEvent event) { + final boolean handled; + switch (keyCode) { + case KeyEvent.KEYCODE_DEL: + handled = delete(editable, event, false /*forwardDelete*/); + break; + case KeyEvent.KEYCODE_FORWARD_DEL: + handled = delete(editable, event, true /*forwardDelete*/); + break; + default: + handled = false; + break; + } + + if (handled) { + MetaKeyKeyListener.adjustMetaAfterKeypress(editable); + return true; + } + + return false; + } + + private static boolean delete(@NonNull final Editable content, @NonNull final KeyEvent event, + final boolean forwardDelete) { + if (hasModifiers(event)) { + return false; + } + + final int start = Selection.getSelectionStart(content); + final int end = Selection.getSelectionEnd(content); + if (hasInvalidSelection(start, end)) { + return false; + } + + final EmojiSpan[] spans = content.getSpans(start, end, EmojiSpan.class); + if (spans != null && spans.length > 0) { + final int length = spans.length; + for (int index = 0; index < length; index++) { + final EmojiSpan span = spans[index]; + final int spanStart = content.getSpanStart(span); + final int spanEnd = content.getSpanEnd(span); + if ((forwardDelete && spanStart == start) + || (!forwardDelete && spanEnd == start) + || (start > spanStart && start < spanEnd)) { + content.delete(spanStart, spanEnd); + return true; + } + } + } + + return false; + } + + /** + * Handles deleteSurroundingText commands from {@link InputConnection} and tries to delete an + * {@link EmojiSpan} from an {@link Editable}. Returns {@code true} if an {@link EmojiSpan} is + * deleted. + *

+ * If there is a selection where selection start is not equal to selection end, does not + * delete. + * + * @param inputConnection InputConnection instance + * @param editable TextView.Editable instance + * @param beforeLength the number of characters before the cursor to be deleted + * @param afterLength the number of characters after the cursor to be deleted + * @param inCodePoints {@code true} if length parameters are in codepoints + * + * @return {@code true} if an {@link EmojiSpan} is deleted + */ + static boolean handleDeleteSurroundingText(@NonNull final InputConnection inputConnection, + @NonNull final Editable editable, @IntRange(from = 0) final int beforeLength, + @IntRange(from = 0) final int afterLength, final boolean inCodePoints) { + //noinspection ConstantConditions + if (editable == null || inputConnection == null) { + return false; + } + + if (beforeLength < 0 || afterLength < 0) { + return false; + } + + final int selectionStart = Selection.getSelectionStart(editable); + final int selectionEnd = Selection.getSelectionEnd(editable); + + if (hasInvalidSelection(selectionStart, selectionEnd)) { + return false; + } + + int start; + int end; + if (inCodePoints) { + // go backwards in terms of codepoints + start = CodepointIndexFinder.findIndexBackward(editable, selectionStart, + Math.max(beforeLength, 0)); + end = CodepointIndexFinder.findIndexForward(editable, selectionEnd, + Math.max(afterLength, 0)); + + if (start == CodepointIndexFinder.INVALID_INDEX + || end == CodepointIndexFinder.INVALID_INDEX) { + return false; + } + } else { + start = Math.max(selectionStart - beforeLength, 0); + end = Math.min(selectionEnd + afterLength, editable.length()); + } + + final EmojiSpan[] spans = editable.getSpans(start, end, EmojiSpan.class); + if (spans != null && spans.length > 0) { + final int length = spans.length; + for (int index = 0; index < length; index++) { + final EmojiSpan span = spans[index]; + int spanStart = editable.getSpanStart(span); + int spanEnd = editable.getSpanEnd(span); + start = Math.min(spanStart, start); + end = Math.max(spanEnd, end); + } + + start = Math.max(start, 0); + end = Math.min(end, editable.length()); + + inputConnection.beginBatchEdit(); + editable.delete(start, end); + inputConnection.endBatchEdit(); + return true; + } + + return false; + } + + private static boolean hasInvalidSelection(final int start, final int end) { + return start == -1 || end == -1 || start != end; + } + + private static boolean hasModifiers(@NonNull KeyEvent event) { + return !KeyEvent.metaStateHasNoModifiers(event.getMetaState()); + } + + /** + * Checks whether the current OS can render a given emoji. Used by the system to decide if an + * emoji span should be added. If the system cannot render it, an emoji span will be added. + * Used only for the case where replaceAll is set to {@code false}. + * + * @param charSequence the CharSequence that the emoji is in + * @param start start index of the emoji in the CharSequence + * @param end end index of the emoji in the CharSequence + * @param rasterizer TypefaceEmojiRasterizer instance for the emoji + * + * @return {@code true} if the OS can render emoji, {@code false} otherwise + */ + private boolean hasGlyph(final CharSequence charSequence, int start, final int end, + final TypefaceEmojiRasterizer rasterizer) { + // if the existence is not calculated yet + if (rasterizer.getHasGlyph() == TypefaceEmojiRasterizer.HAS_GLYPH_UNKNOWN) { + final boolean hasGlyph = mGlyphChecker.hasGlyph(charSequence, start, end, + rasterizer.getSdkAdded()); + rasterizer.setHasGlyph(hasGlyph); + } + + return rasterizer.getHasGlyph() == TypefaceEmojiRasterizer.HAS_GLYPH_EXISTS; + } + + /** + * State machine for walking over the metadata trie. + */ + static final class ProcessorSm { + + private static final int STATE_DEFAULT = 1; + private static final int STATE_WALKING = 2; + + private int mState = STATE_DEFAULT; + + /** + * Root of the trie + */ + private final MetadataRepo.Node mRootNode; + + /** + * Pointer to the node after last codepoint. + */ + private MetadataRepo.Node mCurrentNode; + + /** + * The node where ACTION_FLUSH is called. Required since after flush action is + * returned mCurrentNode is reset to be the root. + */ + private MetadataRepo.Node mFlushNode; + + /** + * The code point that was checked. + */ + private int mLastCodepoint; + + /** + * Level for mCurrentNode. Root is 0. + */ + private int mCurrentDepth; + + /** + * @see EmojiCompat.Config#setUseEmojiAsDefaultStyle(boolean) + */ + private final boolean mUseEmojiAsDefaultStyle; + + /** + * @see EmojiCompat.Config#setUseEmojiAsDefaultStyle(boolean, List) + */ + private final int[] mEmojiAsDefaultStyleExceptions; + + ProcessorSm(MetadataRepo.Node rootNode, boolean useEmojiAsDefaultStyle, + int[] emojiAsDefaultStyleExceptions) { + mRootNode = rootNode; + mCurrentNode = rootNode; + mUseEmojiAsDefaultStyle = useEmojiAsDefaultStyle; + mEmojiAsDefaultStyleExceptions = emojiAsDefaultStyleExceptions; + } + + @Action + int check(final int codePoint) { + final int action; + MetadataRepo.Node node = mCurrentNode.get(codePoint); + switch (mState) { + case STATE_WALKING: + if (node != null) { + mCurrentNode = node; + mCurrentDepth += 1; + action = ACTION_ADVANCE_END; + } else { + if (isTextStyle(codePoint)) { + action = reset(); + } else if (isEmojiStyle(codePoint)) { + action = ACTION_ADVANCE_END; + } else if (mCurrentNode.getData() != null) { + if (mCurrentDepth == 1) { + if (shouldUseEmojiPresentationStyleForSingleCodepoint()) { + mFlushNode = mCurrentNode; + action = ACTION_FLUSH; + reset(); + } else { + action = reset(); + } + } else { + mFlushNode = mCurrentNode; + action = ACTION_FLUSH; + reset(); + } + } else { + action = reset(); + } + } + break; + case STATE_DEFAULT: + default: + if (node == null) { + action = reset(); + } else { + mState = STATE_WALKING; + mCurrentNode = node; + mCurrentDepth = 1; + action = ACTION_ADVANCE_END; + } + break; + } + + mLastCodepoint = codePoint; + return action; + } + + @Action + private int reset() { + mState = STATE_DEFAULT; + mCurrentNode = mRootNode; + mCurrentDepth = 0; + return ACTION_ADVANCE_BOTH; + } + + /** + * @return the metadata node when ACTION_FLUSH is returned + */ + TypefaceEmojiRasterizer getFlushMetadata() { + return mFlushNode.getData(); + } + + /** + * @return current pointer to the metadata node in the trie + */ + TypefaceEmojiRasterizer getCurrentMetadata() { + return mCurrentNode.getData(); + } + + /** + * Need for the case where input is consumed, but action_flush was not called. For example + * when the char sequence has single codepoint character which is a default emoji. State + * machine will wait for the next. + * + * @return whether the current state requires an emoji to be added + */ + boolean isInFlushableState() { + return mState == STATE_WALKING && mCurrentNode.getData() != null + && (mCurrentDepth > 1 || shouldUseEmojiPresentationStyleForSingleCodepoint()); + } + + private boolean shouldUseEmojiPresentationStyleForSingleCodepoint() { + if (mCurrentNode.getData().isDefaultEmoji()) { + // The codepoint is emoji style by default. + return true; + } + if (isEmojiStyle(mLastCodepoint)) { + // The codepoint was followed by the emoji style variation selector. + return true; + } + if (mUseEmojiAsDefaultStyle) { + // Emoji presentation style for text style default emojis is enabled. We have + // to check that the current codepoint is not an exception. + if (mEmojiAsDefaultStyleExceptions == null) { + return true; + } + final int codepoint = mCurrentNode.getData().getCodepointAt(0); + final int index = Arrays.binarySearch(mEmojiAsDefaultStyleExceptions, codepoint); + if (index < 0) { + // Index is negative, so the codepoint was not found in the array of exceptions. + return true; + } + } + return false; + } + + /** + * @param codePoint CodePoint to check + * + * @return {@code true} if the codepoint is a emoji style standardized variation selector + */ + private static boolean isEmojiStyle(int codePoint) { + return codePoint == 0xFE0F; + } + + /** + * @param codePoint CodePoint to check + * + * @return {@code true} if the codepoint is a text style standardized variation selector + */ + private static boolean isTextStyle(int codePoint) { + return codePoint == 0xFE0E; + } + } + + /** + * Copy of BaseInputConnection findIndexBackward and findIndexForward functions. + */ + @RequiresApi(19) + private static final class CodepointIndexFinder { + private static final int INVALID_INDEX = -1; + + private CodepointIndexFinder() {} + + /** + * Find start index of the character in {@code cs} that is {@code numCodePoints} behind + * starting from {@code from}. + * + * @param cs CharSequence to work on + * @param from the index to start going backwards + * @param numCodePoints the number of codepoints + * + * @return start index of the character + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + static int findIndexBackward(final CharSequence cs, final int from, + final int numCodePoints) { + int currentIndex = from; + boolean waitingHighSurrogate = false; + final int length = cs.length(); + if (currentIndex < 0 || length < currentIndex) { + return INVALID_INDEX; // The starting point is out of range. + } + if (numCodePoints < 0) { + return INVALID_INDEX; // Basically this should not happen. + } + int remainingCodePoints = numCodePoints; + while (true) { + if (remainingCodePoints == 0) { + return currentIndex; // Reached to the requested length in code points. + } + + --currentIndex; + if (currentIndex < 0) { + if (waitingHighSurrogate) { + return INVALID_INDEX; // An invalid surrogate pair is found. + } + return 0; // Reached to the R of the text w/o any invalid surrogate + // pair. + } + final char c = cs.charAt(currentIndex); + if (waitingHighSurrogate) { + if (!Character.isHighSurrogate(c)) { + return INVALID_INDEX; // An invalid surrogate pair is found. + } + waitingHighSurrogate = false; + --remainingCodePoints; + continue; + } + if (!Character.isSurrogate(c)) { + --remainingCodePoints; + continue; + } + if (Character.isHighSurrogate(c)) { + return INVALID_INDEX; // A invalid surrogate pair is found. + } + waitingHighSurrogate = true; + } + } + + /** + * Find start index of the character in {@code cs} that is {@code numCodePoints} ahead + * starting from {@code from}. + * + * @param cs CharSequence to work on + * @param from the index to start going forward + * @param numCodePoints the number of codepoints + * + * @return start index of the character + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + static int findIndexForward(final CharSequence cs, final int from, + final int numCodePoints) { + int currentIndex = from; + boolean waitingLowSurrogate = false; + final int length = cs.length(); + if (currentIndex < 0 || length < currentIndex) { + return INVALID_INDEX; // The starting point is out of range. + } + if (numCodePoints < 0) { + return INVALID_INDEX; // Basically this should not happen. + } + int remainingCodePoints = numCodePoints; + + while (true) { + if (remainingCodePoints == 0) { + return currentIndex; // Reached to the requested length in code points. + } + + if (currentIndex >= length) { + if (waitingLowSurrogate) { + return INVALID_INDEX; // An invalid surrogate pair is found. + } + return length; // Reached to the end of the text w/o any invalid surrogate + // pair. + } + final char c = cs.charAt(currentIndex); + if (waitingLowSurrogate) { + if (!Character.isLowSurrogate(c)) { + return INVALID_INDEX; // An invalid surrogate pair is found. + } + --remainingCodePoints; + waitingLowSurrogate = false; + ++currentIndex; + continue; + } + if (!Character.isSurrogate(c)) { + --remainingCodePoints; + ++currentIndex; + continue; + } + if (Character.isLowSurrogate(c)) { + return INVALID_INDEX; // A invalid surrogate pair is found. + } + waitingLowSurrogate = true; + ++currentIndex; + } + } + } + + private static class EmojiProcessAddSpanCallback + implements EmojiProcessCallback { + @Nullable + public UnprecomputeTextOnModificationSpannable spannable; + private final EmojiCompat.SpanFactory mSpanFactory; + + EmojiProcessAddSpanCallback(@Nullable UnprecomputeTextOnModificationSpannable spannable, + EmojiCompat.SpanFactory spanFactory) { + this.spannable = spannable; + this.mSpanFactory = spanFactory; + } + + @Override + public boolean handleEmoji(@NonNull CharSequence charSequence, int start, int end, + TypefaceEmojiRasterizer metadata) { + if (metadata.isPreferredSystemRender()) { + return true; + } + if (spannable == null) { + spannable = new UnprecomputeTextOnModificationSpannable( + charSequence instanceof Spannable + ? (Spannable) charSequence + : new SpannableString(charSequence)); + } + final EmojiSpan span = mSpanFactory.createSpan(metadata); + spannable.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return true; + } + + @Override + public UnprecomputeTextOnModificationSpannable getResult() { + return spannable; + } + } + + private static class EmojiProcessLookupCallback + implements EmojiProcessCallback { + private final int mOffset; + + public int start = -1; + + public int end = -1; + + EmojiProcessLookupCallback(int offset) { + this.mOffset = offset; + } + + @Override + public boolean handleEmoji(@NonNull CharSequence charSequence, int start, int end, + TypefaceEmojiRasterizer metadata) { + if (start <= mOffset && mOffset < end) { + this.start = start; + this.end = end; + return false; + } + + return end <= mOffset; + } + + @Override + public EmojiProcessLookupCallback getResult() { + return this; + } + } + + /** + * Mark exclusinos for any emoji matched by this callback + */ + private static class MarkExclusionCallback + implements EmojiProcessCallback { + + private final String mExclusion; + + MarkExclusionCallback(String emoji) { + mExclusion = emoji; + } + + @Override + public boolean handleEmoji(@NonNull CharSequence charSequence, int start, int end, + TypefaceEmojiRasterizer metadata) { + if (TextUtils.equals(charSequence.subSequence(start, end), mExclusion)) { + metadata.setExclusion(true); + return false; + } else { + return true; + } + } + + @Override + public MarkExclusionCallback getResult() { + return this; + } + } +} diff --git a/app/src/main/java/androidx/emoji2/text/EmojiSpan.java b/app/src/main/java/androidx/emoji2/text/EmojiSpan.java new file mode 100644 index 0000000000..7c334c6057 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/EmojiSpan.java @@ -0,0 +1,150 @@ +/* + * Copyright 2021 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.emoji2.text; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY; +import static androidx.annotation.RestrictTo.Scope.TESTS; + +import android.annotation.SuppressLint; +import android.graphics.Paint; +import android.text.style.ReplacementSpan; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.core.util.Preconditions; + +/** + * Base span class for the emoji replacement. When an emoji is found and needs to be replaced in a + * CharSequence, an instance of this class is added to the CharSequence. + */ +@RequiresApi(19) +public abstract class EmojiSpan extends ReplacementSpan { + + /** + * Temporary object to calculate the size of the span. + */ + private final Paint.FontMetricsInt mTmpFontMetrics = new Paint.FontMetricsInt(); + + /** + * Information about emoji. This is not parcelled since we do not want multiple objects + * representing same emoji to be in memory. When unparcelled, EmojiSpan tries to set it back + * using the singleton EmojiCompat instance. + */ + @NonNull + private final TypefaceEmojiRasterizer mRasterizer; + + /** + * Cached width of the span. Width is calculated according to the font metrics. + */ + private short mWidth = -1; + + /** + * Cached height of the span. Height is calculated according to the font metrics. + */ + private short mHeight = -1; + + /** + * Cached ratio of current font height to emoji image height. + */ + private float mRatio = 1.0f; + + /** + * Default constructor. + * + * @param rasterizer information about the emoji, cannot be {@code null} + * + * @hide + */ + @RestrictTo(LIBRARY) + EmojiSpan(@NonNull final TypefaceEmojiRasterizer rasterizer) { + Preconditions.checkNotNull(rasterizer, "rasterizer cannot be null"); + mRasterizer = rasterizer; + } + + @Override + public int getSize(@NonNull final Paint paint, + @SuppressLint("UnknownNullness") @SuppressWarnings("MissingNullability") + final CharSequence text, + final int start, + final int end, + @Nullable final Paint.FontMetricsInt fm) { + paint.getFontMetricsInt(mTmpFontMetrics); + final int fontHeight = Math.abs(mTmpFontMetrics.descent - mTmpFontMetrics.ascent); + + mRatio = fontHeight * 1.0f / mRasterizer.getHeight(); + mHeight = (short) (mRasterizer.getHeight() * mRatio); + mWidth = (short) (mRasterizer.getWidth() * mRatio); + + if (fm != null) { + fm.ascent = mTmpFontMetrics.ascent; + fm.descent = mTmpFontMetrics.descent; + fm.top = mTmpFontMetrics.top; + fm.bottom = mTmpFontMetrics.bottom; + } + + return mWidth; + } + + /** + * Get the rasterizer that draws this emoji. + * + * @return rasterizer to draw emoji + */ + @NonNull + public final TypefaceEmojiRasterizer getTypefaceRasterizer() { + return mRasterizer; + } + + /** + * @return width of the span + * + * @hide + */ + @RestrictTo(LIBRARY) + final int getWidth() { + return mWidth; + } + + /** + * @return height of the span + * + * @hide + */ + @RestrictTo(TESTS) + public final int getHeight() { + return mHeight; + } + + /** + * @hide + */ + @RestrictTo(LIBRARY) + final float getRatio() { + return mRatio; + } + + /** + * @return unique id for the emoji that this EmojiSpan is used for + * + * @hide + */ + @RestrictTo(TESTS) + public final int getId() { + return getTypefaceRasterizer().getId(); + } +} diff --git a/app/src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java b/app/src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java new file mode 100644 index 0000000000..143c06f1b0 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java @@ -0,0 +1,444 @@ +/* + * Copyright 2021 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.emoji2.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.SystemClock; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.annotation.WorkerThread; +import androidx.core.graphics.TypefaceCompatUtil; +import androidx.core.os.TraceCompat; +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; +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * {@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. + *

+ */ +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#setLoadingExecutor}. + */ + 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. + *
+ * If {@code zero} is returned, metadata loader will retry immediately. + *
+ * 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. + *

+ * 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) + public FontRequestEmojiCompatConfig(@NonNull Context context, @NonNull FontRequest request, + @NonNull FontProviderHelper fontProviderHelper) { + super(new FontRequestMetadataLoader(context, request, fontProviderHelper)); + } + + /** + * Sets the custom executor to be used for initialization. + * + * Since font loading is too slow for the main thread, the metadata loader will fetch the fonts + * on a background thread. By default, FontRequestEmojiCompatConfig will create its own + * single threaded Executor, which causes a thread to be created. + * + * You can pass your own executor to control which thread the font is loaded on, and avoid an + * extra thread creation. + * + * @param executor background executor for performing font load + */ + @NonNull + public FontRequestEmojiCompatConfig setLoadingExecutor(@NonNull Executor executor) { + ((FontRequestMetadataLoader) getMetadataRepoLoader()).setExecutor(executor); + return this; + } + + /** + * Please us {@link #setLoadingExecutor(Executor)} instead to set background loading thread. + * + * This was deprecated in emoji2 1.0.0-alpha04. + * + * If migrating from androidx.emoji please prefer to use an existing background executor for + * setLoadingExecutor. + * + * Note: This method will no longer have any effect if passed null, which is a breaking + * change from androidx.emoji. + * + * @deprecated please call setLoadingExecutor instead + * + * @param handler background thread handler to wrap in an Executor, if null this method will + * do nothing + */ + @Deprecated + @NonNull + @SuppressWarnings("deprecation") + public FontRequestEmojiCompatConfig setHandler(@Nullable Handler handler) { + if (handler == null) { + // this is a breaking behavior change from androidx.emoji, we no longer support + // clearing executors + return this; + } + setLoadingExecutor(ConcurrencyHelpers.convertHandlerToExecutor(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. + */ + @NonNull + public FontRequestEmojiCompatConfig setRetryPolicy(@Nullable 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 static final String S_TRACE_BUILD_TYPEFACE = + "EmojiCompat.FontRequestEmojiCompatConfig.buildTypeface"; + @NonNull + private final Context mContext; + @NonNull + private final FontRequest mRequest; + @NonNull + private final FontProviderHelper mFontProviderHelper; + @NonNull + private final Object mLock = new Object(); + + @GuardedBy("mLock") + @Nullable + private Handler mMainHandler; + @GuardedBy("mLock") + @Nullable + private Executor mExecutor; + @GuardedBy("mLock") + @Nullable + private ThreadPoolExecutor mMyThreadPoolExecutor; + @GuardedBy("mLock") + @Nullable + private RetryPolicy mRetryPolicy; + + @GuardedBy("mLock") + @Nullable + EmojiCompat.MetadataRepoLoaderCallback mCallback; + @GuardedBy("mLock") + @Nullable + private ContentObserver mObserver; + @GuardedBy("mLock") + @Nullable + private Runnable mMainHandlerLoadCallback; + + 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 setExecutor(@NonNull Executor executor) { + synchronized (mLock) { + mExecutor = executor; + } + } + + public void setRetryPolicy(@Nullable 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) { + mCallback = loaderCallback; + } + loadInternal(); + } + + @RequiresApi(19) + void loadInternal() { + synchronized (mLock) { + if (mCallback == null) { + // do nothing; loading is already complete + return; + } + if (mExecutor == null) { + mMyThreadPoolExecutor = ConcurrencyHelpers.createBackgroundPriorityExecutor( + "emojiCompat"); + mExecutor = mMyThreadPoolExecutor; + } + mExecutor.execute(this::createMetadata); + } + } + + @WorkerThread + 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. + } + + @RequiresApi(19) + @WorkerThread + private void scheduleRetry(Uri uri, long waitMs) { + synchronized (mLock) { + Handler handler = mMainHandler; + if (handler == null) { + handler = ConcurrencyHelpers.mainHandlerAsync(); + mMainHandler = handler; + } + if (mObserver == null) { + mObserver = new ContentObserver(handler) { + @Override + public void onChange(boolean selfChange, Uri uri) { + loadInternal(); + } + }; + mFontProviderHelper.registerObserver(mContext, uri, mObserver); + } + if (mMainHandlerLoadCallback == null) { + mMainHandlerLoadCallback = this::loadInternal; + } + handler.postDelayed(mMainHandlerLoadCallback, waitMs); + } + } + + // Must be called on the mHandler. + private void cleanUp() { + synchronized (mLock) { + mCallback = null; + if (mObserver != null) { + mFontProviderHelper.unregisterObserver(mContext, mObserver); + mObserver = null; + } + if (mMainHandler != null) { + mMainHandler.removeCallbacks(mMainHandlerLoadCallback); + } + mMainHandler = null; + if (mMyThreadPoolExecutor != null) { + // if we made the executor, shut it down + mMyThreadPoolExecutor.shutdown(); + } + mExecutor = null; + mMyThreadPoolExecutor = null; + } + } + + // Must be called on the mHandler. + @RequiresApi(19) + @SuppressWarnings("WeakerAccess") /* synthetic access */ + @WorkerThread + void createMetadata() { + synchronized (mLock) { + 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 + ")"); + } + + final MetadataRepo metadataRepo; + try { + TraceCompat.beginSection(S_TRACE_BUILD_TYPEFACE); + // 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 || typeface == null) { + throw new RuntimeException("Unable to open file."); + } + metadataRepo = MetadataRepo.create(typeface, buffer); + } finally { + TraceCompat.endSection(); + } + synchronized (mLock) { + if (mCallback != null) { + mCallback.onLoaded(metadataRepo); + } + } + cleanUp(); + } catch (Throwable t) { + synchronized (mLock) { + if (mCallback != null) { + mCallback.onFailed(t); + } + } + cleanUp(); + } + } + } + + /** + * Delegate class for mocking FontsContractCompat.fetchFonts. + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + public static class FontProviderHelper { + /** Calls FontsContractCompat.fetchFonts. */ + @NonNull + public FontFamilyResult fetchFonts(@NonNull Context context, + @NonNull FontRequest request) throws NameNotFoundException { + return FontsContractCompat.fetchFonts(context, null /* cancellation signal */, request); + } + + /** Calls FontsContractCompat.buildTypeface. */ + @Nullable + 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(); + +} diff --git a/app/src/main/java/androidx/emoji2/text/MetadataListReader.java b/app/src/main/java/androidx/emoji2/text/MetadataListReader.java new file mode 100644 index 0000000000..2ed42e0700 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/MetadataListReader.java @@ -0,0 +1,348 @@ +/* + * Copyright 2021 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.emoji2.text; + +import android.content.res.AssetManager; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.emoji2.text.flatbuffer.MetadataList; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Reads the emoji metadata from a given InputStream or ByteBuffer. + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY) +@AnyThread +@RequiresApi(19) +class MetadataListReader { + + /** + * Meta tag for emoji metadata. This string is used by the font update script to insert the + * emoji meta into the font. This meta table contains the list of all emojis which are stored in + * binary format using FlatBuffers. This flat list is later converted by the system into a trie. + * {@code int} representation for "Emji" + * + * @see MetadataRepo + */ + private static final int EMJI_TAG = 'E' << 24 | 'm' << 16 | 'j' << 8 | 'i'; + + /** + * Deprecated meta tag name. Do not use, kept for compatibility reasons, will be removed soon. + */ + private static final int EMJI_TAG_DEPRECATED = 'e' << 24 | 'm' << 16 | 'j' << 8 | 'i'; + + /** + * The name of the meta table in the font. int representation for "meta" + */ + private static final int META_TABLE_NAME = 'm' << 24 | 'e' << 16 | 't' << 8 | 'a'; + + /** + * Construct MetadataList from an input stream. Does not close the given InputStream, therefore + * it is caller's responsibility to properly close the stream. + * + * @param inputStream InputStream to read emoji metadata from + */ + static MetadataList read(InputStream inputStream) throws IOException { + final OpenTypeReader openTypeReader = new InputStreamOpenTypeReader(inputStream); + final OffsetInfo offsetInfo = findOffsetInfo(openTypeReader); + // skip to where metadata is + openTypeReader.skip((int) (offsetInfo.getStartOffset() - openTypeReader.getPosition())); + // allocate a ByteBuffer and read into it since FlatBuffers can read only from a ByteBuffer + final ByteBuffer buffer = ByteBuffer.allocate((int) offsetInfo.getLength()); + final int numRead = inputStream.read(buffer.array()); + if (numRead != offsetInfo.getLength()) { + throw new IOException("Needed " + offsetInfo.getLength() + " bytes, got " + numRead); + } + + return MetadataList.getRootAsMetadataList(buffer); + } + + /** + * Construct MetadataList from a byte buffer. + * + * @param byteBuffer ByteBuffer to read emoji metadata from + */ + static MetadataList read(final ByteBuffer byteBuffer) throws IOException { + final ByteBuffer newBuffer = byteBuffer.duplicate(); + final OpenTypeReader reader = new ByteBufferReader(newBuffer); + final OffsetInfo offsetInfo = findOffsetInfo(reader); + // skip to where metadata is + newBuffer.position((int) offsetInfo.getStartOffset()); + return MetadataList.getRootAsMetadataList(newBuffer); + } + + /** + * Construct MetadataList from an asset. + * + * @param assetManager AssetManager instance + * @param assetPath asset manager path of the file that the Typeface and metadata will be + * created from + */ + static MetadataList read(AssetManager assetManager, String assetPath) + throws IOException { + try (InputStream inputStream = assetManager.open(assetPath)) { + return read(inputStream); + } + } + + /** + * Finds the start offset and length of the emoji metadata in the font. + * + * @return OffsetInfo which contains start offset and length of the emoji metadata in the font + * + * @throws IOException + */ + private static OffsetInfo findOffsetInfo(OpenTypeReader reader) throws IOException { + // skip sfnt version + reader.skip(OpenTypeReader.UINT32_BYTE_COUNT); + // start of Table Count + final int tableCount = reader.readUnsignedShort(); + if (tableCount > 100) { + //something is wrong quit + throw new IOException("Cannot read metadata."); + } + //skip to beginning of tables data + reader.skip(OpenTypeReader.UINT16_BYTE_COUNT * 3); + + long metaOffset = -1; + for (int i = 0; i < tableCount; i++) { + final int tag = reader.readTag(); + // skip checksum + reader.skip(OpenTypeReader.UINT32_BYTE_COUNT); + final long offset = reader.readUnsignedInt(); + // skip mLength + reader.skip(OpenTypeReader.UINT32_BYTE_COUNT); + if (META_TABLE_NAME == tag) { + metaOffset = offset; + break; + } + } + + if (metaOffset != -1) { + // skip to the beginning of meta tables. + reader.skip((int) (metaOffset - reader.getPosition())); + // skip minorVersion, majorVersion, flags, reserved, + reader.skip( + OpenTypeReader.UINT16_BYTE_COUNT * 2 + OpenTypeReader.UINT32_BYTE_COUNT * 2); + final long mapsCount = reader.readUnsignedInt(); + for (int i = 0; i < mapsCount; i++) { + final int tag = reader.readTag(); + final long dataOffset = reader.readUnsignedInt(); + final long dataLength = reader.readUnsignedInt(); + if (EMJI_TAG == tag || EMJI_TAG_DEPRECATED == tag) { + return new OffsetInfo(dataOffset + metaOffset, dataLength); + } + } + } + + throw new IOException("Cannot read metadata."); + } + + /** + * Start offset and length of the emoji metadata in the font. + */ + private static class OffsetInfo { + private final long mStartOffset; + private final long mLength; + + OffsetInfo(long startOffset, long length) { + mStartOffset = startOffset; + mLength = length; + } + + long getStartOffset() { + return mStartOffset; + } + + long getLength() { + return mLength; + } + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + static int toUnsignedShort(final short value) { + return value & 0xFFFF; + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + static long toUnsignedInt(final int value) { + return value & 0xFFFFFFFFL; + } + + private interface OpenTypeReader { + int UINT16_BYTE_COUNT = 2; + int UINT32_BYTE_COUNT = 4; + + /** + * Reads an {@code OpenType uint16}. + * + * @throws IOException + */ + int readUnsignedShort() throws IOException; + + /** + * Reads an {@code OpenType uint32}. + * + * @throws IOException + */ + long readUnsignedInt() throws IOException; + + /** + * Reads an {@code OpenType Tag}. + * + * @throws IOException + */ + int readTag() throws IOException; + + /** + * Skip the given amount of numOfBytes + * + * @throws IOException + */ + void skip(int numOfBytes) throws IOException; + + /** + * @return the position of the reader + */ + long getPosition(); + } + + /** + * Reads {@code OpenType} data from an {@link InputStream}. + */ + private static class InputStreamOpenTypeReader implements OpenTypeReader { + + private final @NonNull byte[] mByteArray; + private final @NonNull ByteBuffer mByteBuffer; + private final @NonNull InputStream mInputStream; + private long mPosition = 0; + + /** + * Constructs the reader with the given InputStream. Does not close the InputStream, it is + * caller's responsibility to close it. + * + * @param inputStream InputStream to read from + */ + InputStreamOpenTypeReader(@NonNull final InputStream inputStream) { + mInputStream = inputStream; + mByteArray = new byte[UINT32_BYTE_COUNT]; + mByteBuffer = ByteBuffer.wrap(mByteArray); + mByteBuffer.order(ByteOrder.BIG_ENDIAN); + } + + @Override + public int readUnsignedShort() throws IOException { + mByteBuffer.position(0); + read(UINT16_BYTE_COUNT); + return toUnsignedShort(mByteBuffer.getShort()); + } + + @Override + public long readUnsignedInt() throws IOException { + mByteBuffer.position(0); + read(UINT32_BYTE_COUNT); + return toUnsignedInt(mByteBuffer.getInt()); + } + + @Override + public int readTag() throws IOException { + mByteBuffer.position(0); + read(UINT32_BYTE_COUNT); + return mByteBuffer.getInt(); + } + + @Override + public void skip(int numOfBytes) throws IOException { + while (numOfBytes > 0) { + int skipped = (int) mInputStream.skip(numOfBytes); + if (skipped < 1) { + throw new IOException("Skip didn't move at least 1 byte forward"); + } + numOfBytes -= skipped; + mPosition += skipped; + } + } + + @Override + public long getPosition() { + return mPosition; + } + + private void read(@IntRange(from = 0, to = UINT32_BYTE_COUNT) final int numOfBytes) + throws IOException { + if (mInputStream.read(mByteArray, 0, numOfBytes) != numOfBytes) { + throw new IOException("read failed"); + } + mPosition += numOfBytes; + } + } + + /** + * Reads OpenType data from a ByteBuffer. + */ + private static class ByteBufferReader implements OpenTypeReader { + + private final @NonNull ByteBuffer mByteBuffer; + + /** + * Constructs the reader with the given ByteBuffer. + * + * @param byteBuffer ByteBuffer to read from + */ + ByteBufferReader(@NonNull final ByteBuffer byteBuffer) { + mByteBuffer = byteBuffer; + mByteBuffer.order(ByteOrder.BIG_ENDIAN); + } + + @Override + public int readUnsignedShort() throws IOException { + return toUnsignedShort(mByteBuffer.getShort()); + } + + @Override + public long readUnsignedInt() throws IOException { + return toUnsignedInt(mByteBuffer.getInt()); + } + + @Override + public int readTag() throws IOException { + return mByteBuffer.getInt(); + } + + @Override + public void skip(final int numOfBytes) throws IOException { + mByteBuffer.position(mByteBuffer.position() + numOfBytes); + } + + @Override + public long getPosition() { + return mByteBuffer.position(); + } + } + + private MetadataListReader() { + } +} diff --git a/app/src/main/java/androidx/emoji2/text/MetadataRepo.java b/app/src/main/java/androidx/emoji2/text/MetadataRepo.java new file mode 100644 index 0000000000..f08ae06653 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/MetadataRepo.java @@ -0,0 +1,275 @@ +/* + * Copyright 2021 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.emoji2.text; + +import android.content.res.AssetManager; +import android.graphics.Typeface; +import android.util.SparseArray; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.annotation.VisibleForTesting; +import androidx.core.os.TraceCompat; +import androidx.core.util.Preconditions; +import androidx.emoji2.text.flatbuffer.MetadataList; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * Class to hold the emoji metadata required to process and draw emojis. + */ +@AnyThread +@RequiresApi(19) +public final class MetadataRepo { + /** + * The default children size of the root node. + */ + private static final int DEFAULT_ROOT_SIZE = 1024; + private static final String S_TRACE_CREATE_REPO = "EmojiCompat.MetadataRepo.create"; + + /** + * MetadataList that contains the emoji metadata. + */ + private final @NonNull MetadataList mMetadataList; + + /** + * char presentation of all TypefaceEmojiRasterizer's in a single array. All emojis we have are + * mapped to Private Use Area A, in the range U+F0000..U+FFFFD. Therefore each emoji takes 2 + * chars. + */ + private final @NonNull char[] mEmojiCharArray; + + /** + * Empty root node of the trie. + */ + private final @NonNull Node mRootNode; + + /** + * Typeface to be used to render emojis. + */ + private final @NonNull Typeface mTypeface; + + /** + * Private constructor that is called by one of {@code create} methods. + * + * @param typeface Typeface to be used to render emojis + * @param metadataList MetadataList that contains the emoji metadata + */ + private MetadataRepo(@NonNull final Typeface typeface, + @NonNull final MetadataList metadataList) { + mTypeface = typeface; + mMetadataList = metadataList; + mRootNode = new Node(DEFAULT_ROOT_SIZE); + mEmojiCharArray = new char[mMetadataList.listLength() * 2]; + constructIndex(mMetadataList); + } + + /** + * Construct MetadataRepo with empty metadata. + * + * This should only be used from tests. + * @hide + */ + @NonNull + @RestrictTo(RestrictTo.Scope.TESTS) + public static MetadataRepo create(@NonNull final Typeface typeface) { + try { + TraceCompat.beginSection(S_TRACE_CREATE_REPO); + return new MetadataRepo(typeface, new MetadataList()); + } finally { + TraceCompat.endSection(); + } + } + + /** + * Construct MetadataRepo from an input stream. The library does not close the given + * InputStream, therefore it is caller's responsibility to properly close the stream. + * + * @param typeface Typeface to be used to render emojis + * @param inputStream InputStream to read emoji metadata from + */ + @NonNull + public static MetadataRepo create(@NonNull final Typeface typeface, + @NonNull final InputStream inputStream) throws IOException { + try { + TraceCompat.beginSection(S_TRACE_CREATE_REPO); + return new MetadataRepo(typeface, MetadataListReader.read(inputStream)); + } finally { + TraceCompat.endSection(); + } + } + + /** + * Construct MetadataRepo from a byte buffer. The position of the ByteBuffer will change, it is + * caller's responsibility to reposition the buffer if required. + * + * @param typeface Typeface to be used to render emojis + * @param byteBuffer ByteBuffer to read emoji metadata from + */ + @NonNull + public static MetadataRepo create(@NonNull final Typeface typeface, + @NonNull final ByteBuffer byteBuffer) throws IOException { + try { + TraceCompat.beginSection(S_TRACE_CREATE_REPO); + return new MetadataRepo(typeface, MetadataListReader.read(byteBuffer)); + } finally { + TraceCompat.endSection(); + } + } + + /** + * Construct MetadataRepo from an asset. + * + * @param assetManager AssetManager instance + * @param assetPath asset manager path of the file that the Typeface and metadata will be + * created from + */ + @NonNull + public static MetadataRepo create(@NonNull final AssetManager assetManager, + @NonNull final String assetPath) throws IOException { + try { + TraceCompat.beginSection(S_TRACE_CREATE_REPO); + final Typeface typeface = Typeface.createFromAsset(assetManager, assetPath); + return new MetadataRepo(typeface, + MetadataListReader.read(assetManager, assetPath)); + } finally { + TraceCompat.endSection(); + } + } + + /** + * Read emoji metadata list and construct the trie. + */ + private void constructIndex(final MetadataList metadataList) { + int length = metadataList.listLength(); + for (int i = 0; i < length; i++) { + final TypefaceEmojiRasterizer metadata = new TypefaceEmojiRasterizer(this, i); + //since all emojis are mapped to a single codepoint in Private Use Area A they are 2 + //chars wide + //noinspection ResultOfMethodCallIgnored + Character.toChars(metadata.getId(), mEmojiCharArray, i * 2); + put(metadata); + } + } + + /** + * @hide + */ + @NonNull + @RestrictTo(RestrictTo.Scope.LIBRARY) + Typeface getTypeface() { + return mTypeface; + } + + /** + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + int getMetadataVersion() { + return mMetadataList.version(); + } + + /** + * @hide + */ + @NonNull + @RestrictTo(RestrictTo.Scope.LIBRARY) + Node getRootNode() { + return mRootNode; + } + + /** + * @hide + */ + @NonNull + @RestrictTo(RestrictTo.Scope.LIBRARY) + public char[] getEmojiCharArray() { + return mEmojiCharArray; + } + + /** + * @hide + */ + @NonNull + @RestrictTo(RestrictTo.Scope.LIBRARY) + public MetadataList getMetadataList() { + return mMetadataList; + } + + /** + * Add a TypefaceEmojiRasterizer to the index. + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + @VisibleForTesting + void put(@NonNull final TypefaceEmojiRasterizer data) { + Preconditions.checkNotNull(data, "emoji metadata cannot be null"); + Preconditions.checkArgument(data.getCodepointsLength() > 0, + "invalid metadata codepoint length"); + + mRootNode.put(data, 0, data.getCodepointsLength() - 1); + } + + /** + * Trie node that holds mapping from emoji codepoint(s) to TypefaceEmojiRasterizer. + * + * A single codepoint emoji is represented by a child of the root node. + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + static class Node { + private final SparseArray mChildren; + private TypefaceEmojiRasterizer mData; + + private Node() { + this(1); + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + Node(final int defaultChildrenSize) { + mChildren = new SparseArray<>(defaultChildrenSize); + } + + Node get(final int key) { + return mChildren == null ? null : mChildren.get(key); + } + + final TypefaceEmojiRasterizer getData() { + return mData; + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void put(@NonNull final TypefaceEmojiRasterizer data, final int start, final int end) { + Node node = get(data.getCodepointAt(start)); + if (node == null) { + node = new Node(); + mChildren.put(data.getCodepointAt(start), node); + } + + if (end > start) { + node.put(data, start + 1, end); + } else { + node.mData = data; + } + } + } +} diff --git a/app/src/main/java/androidx/emoji2/text/SpannableBuilder.java b/app/src/main/java/androidx/emoji2/text/SpannableBuilder.java new file mode 100644 index 0000000000..89116c7d3d --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/SpannableBuilder.java @@ -0,0 +1,462 @@ +/* + * Copyright 2021 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.emoji2.text; + +import android.annotation.SuppressLint; +import android.os.Build; +import android.text.Editable; +import android.text.SpanWatcher; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.TextWatcher; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import androidx.core.util.Preconditions; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * When setSpan functions is called on EmojiSpannableBuilder, it checks if the mObject is instance + * of the DynamicLayout$ChangeWatcher. if so, it wraps it into another listener mObject + * (WatcherWrapper) that implements the same interfaces. + *

+ * During a span change event WatcherWrapper’s functions are fired, it checks if the span is an + * EmojiSpan, and prevents the ChangeWatcher being fired for that span. WatcherWrapper informs + * ChangeWatcher only once at the end of the edit. Important point is, the block operation is + * applied only for EmojiSpans. Therefore any other span change operation works the same way as in + * the framework. + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +public final class SpannableBuilder extends SpannableStringBuilder { + /** + * DynamicLayout$ChangeWatcher class. + */ + private final @NonNull Class mWatcherClass; + + /** + * All WatcherWrappers. + */ + private final @NonNull List mWatchers = new ArrayList<>(); + + /** + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + SpannableBuilder(@NonNull Class watcherClass) { + Preconditions.checkNotNull(watcherClass, "watcherClass cannot be null"); + mWatcherClass = watcherClass; + } + + /** + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + SpannableBuilder(@NonNull Class watcherClass, @NonNull CharSequence text) { + super(text); + Preconditions.checkNotNull(watcherClass, "watcherClass cannot be null"); + mWatcherClass = watcherClass; + } + + /** + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + SpannableBuilder(@NonNull Class watcherClass, @NonNull CharSequence text, int start, + int end) { + super(text, start, end); + Preconditions.checkNotNull(watcherClass, "watcherClass cannot be null"); + mWatcherClass = watcherClass; + } + + /** + * @hide + */ + @NonNull + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + public static SpannableBuilder create(@NonNull Class clazz, @NonNull CharSequence text) { + return new SpannableBuilder(clazz, text); + } + + /** + * Checks whether the mObject is instance of the DynamicLayout$ChangeWatcher. + * + * @param object mObject to be checked + * + * @return true if mObject is instance of the DynamicLayout$ChangeWatcher. + */ + private boolean isWatcher(@Nullable Object object) { + return object != null && isWatcher(object.getClass()); + } + + /** + * Checks whether the class is DynamicLayout$ChangeWatcher. + * + * @param clazz class to be checked + * + * @return true if class is DynamicLayout$ChangeWatcher. + */ + private boolean isWatcher(@NonNull Class clazz) { + return mWatcherClass == clazz; + } + + @SuppressLint("UnknownNullness") + @Override + public CharSequence subSequence(int start, int end) { + return new SpannableBuilder(mWatcherClass, this, start, end); + } + + /** + * If the span being added is instance of DynamicLayout$ChangeWatcher, wrap the watcher in + * another internal watcher that will prevent EmojiSpan events to be fired to DynamicLayout. Set + * this new mObject as the span. + */ + @Override + public void setSpan(@Nullable Object what, int start, int end, int flags) { + if (isWatcher(what)) { + final WatcherWrapper span = new WatcherWrapper(what); + mWatchers.add(span); + what = span; + } + + super.setSpan(what, start, end, flags); + } + + /** + * If previously a DynamicLayout$ChangeWatcher was wrapped in a WatcherWrapper, return the + * correct Object that the client has set. + */ + @SuppressLint("UnknownNullness") + @SuppressWarnings("unchecked") + @Override + public T[] getSpans(int queryStart, int queryEnd, @NonNull Class kind) { + if (isWatcher(kind)) { + final WatcherWrapper[] spans = super.getSpans(queryStart, queryEnd, + WatcherWrapper.class); + final T[] result = (T[]) Array.newInstance(kind, spans.length); + for (int i = 0; i < spans.length; i++) { + result[i] = (T) spans[i].mObject; + } + return result; + } + return super.getSpans(queryStart, queryEnd, kind); + } + + /** + * If the client wants to remove the DynamicLayout$ChangeWatcher span, remove the WatcherWrapper + * instead. + */ + @Override + public void removeSpan(@Nullable Object what) { + final WatcherWrapper watcher; + if (isWatcher(what)) { + watcher = getWatcherFor(what); + if (watcher != null) { + what = watcher; + } + } else { + watcher = null; + } + + super.removeSpan(what); + + if (watcher != null) { + mWatchers.remove(watcher); + } + } + + /** + * Return the correct start for the DynamicLayout$ChangeWatcher span. + */ + @Override + public int getSpanStart(@Nullable Object tag) { + if (isWatcher(tag)) { + final WatcherWrapper watcher = getWatcherFor(tag); + if (watcher != null) { + tag = watcher; + } + } + return super.getSpanStart(tag); + } + + /** + * Return the correct end for the DynamicLayout$ChangeWatcher span. + */ + @Override + public int getSpanEnd(@Nullable Object tag) { + if (isWatcher(tag)) { + final WatcherWrapper watcher = getWatcherFor(tag); + if (watcher != null) { + tag = watcher; + } + } + return super.getSpanEnd(tag); + } + + /** + * Return the correct flags for the DynamicLayout$ChangeWatcher span. + */ + @Override + public int getSpanFlags(@Nullable Object tag) { + if (isWatcher(tag)) { + final WatcherWrapper watcher = getWatcherFor(tag); + if (watcher != null) { + tag = watcher; + } + } + return super.getSpanFlags(tag); + } + + /** + * Return the correct transition for the DynamicLayout$ChangeWatcher span. + */ + @Override + public int nextSpanTransition(int start, int limit, @Nullable Class type) { + if (type == null || isWatcher(type)) { + type = WatcherWrapper.class; + } + return super.nextSpanTransition(start, limit, type); + } + + /** + * Find the WatcherWrapper for a given DynamicLayout$ChangeWatcher. + * + * @param object DynamicLayout$ChangeWatcher mObject + * + * @return WatcherWrapper that wraps the mObject. + */ + private WatcherWrapper getWatcherFor(Object object) { + for (int i = 0; i < mWatchers.size(); i++) { + WatcherWrapper watcher = mWatchers.get(i); + if (watcher.mObject == object) { + return watcher; + } + } + return null; + } + + /** + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + public void beginBatchEdit() { + blockWatchers(); + } + + /** + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + public void endBatchEdit() { + unblockwatchers(); + fireWatchers(); + } + + /** + * Block all watcher wrapper events. + */ + private void blockWatchers() { + for (int i = 0; i < mWatchers.size(); i++) { + mWatchers.get(i).blockCalls(); + } + } + + /** + * Unblock all watcher wrapper events. + */ + private void unblockwatchers() { + for (int i = 0; i < mWatchers.size(); i++) { + mWatchers.get(i).unblockCalls(); + } + } + + /** + * Unblock all watcher wrapper events. Called by editing operations, namely + * {@link SpannableStringBuilder#replace(int, int, CharSequence)}. + */ + private void fireWatchers() { + for (int i = 0; i < mWatchers.size(); i++) { + mWatchers.get(i).onTextChanged(this, 0, this.length(), this.length()); + } + } + + @SuppressLint("UnknownNullness") + @Override + public SpannableStringBuilder replace(int start, int end, CharSequence tb) { + blockWatchers(); + super.replace(start, end, tb); + unblockwatchers(); + return this; + } + + @SuppressLint("UnknownNullness") + @Override + public SpannableStringBuilder replace(int start, int end, CharSequence tb, int tbstart, + int tbend) { + blockWatchers(); + super.replace(start, end, tb, tbstart, tbend); + unblockwatchers(); + return this; + } + + @SuppressLint("UnknownNullness") + @Override + public SpannableStringBuilder insert(int where, CharSequence tb) { + super.insert(where, tb); + return this; + } + + @SuppressLint("UnknownNullness") + @Override + public SpannableStringBuilder insert(int where, CharSequence tb, int start, int end) { + super.insert(where, tb, start, end); + return this; + } + + @SuppressLint("UnknownNullness") + @Override + public SpannableStringBuilder delete(int start, int end) { + super.delete(start, end); + return this; + } + + @NonNull + @Override + public SpannableStringBuilder append(@SuppressLint("UnknownNullness") CharSequence text) { + super.append(text); + return this; + } + + @NonNull + @Override + public SpannableStringBuilder append(char text) { + super.append(text); + return this; + } + + @NonNull + @Override + public SpannableStringBuilder append(@SuppressLint("UnknownNullness") CharSequence text, + int start, + int end) { + super.append(text, start, end); + return this; + } + + @SuppressLint("UnknownNullness") + @Override + public SpannableStringBuilder append(CharSequence text, Object what, int flags) { + super.append(text, what, flags); + return this; + } + + /** + * Wraps a DynamicLayout$ChangeWatcher in order to prevent firing of events to DynamicLayout. + */ + private static class WatcherWrapper implements TextWatcher, SpanWatcher { + @SuppressWarnings("WeakerAccess") /* synthetic access */ + final Object mObject; + private final AtomicInteger mBlockCalls = new AtomicInteger(0); + + WatcherWrapper(Object object) { + this.mObject = object; + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + ((TextWatcher) mObject).beforeTextChanged(s, start, count, after); + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + ((TextWatcher) mObject).onTextChanged(s, start, before, count); + } + + @Override + public void afterTextChanged(Editable s) { + ((TextWatcher) mObject).afterTextChanged(s); + } + + /** + * Prevent the onSpanAdded calls to DynamicLayout$ChangeWatcher if in a replace operation + * (mBlockCalls is set) and the span that is added is an EmojiSpan. + */ + @Override + public void onSpanAdded(Spannable text, Object what, int start, int end) { + if (mBlockCalls.get() > 0 && isEmojiSpan(what)) { + return; + } + ((SpanWatcher) mObject).onSpanAdded(text, what, start, end); + } + + /** + * Prevent the onSpanRemoved calls to DynamicLayout$ChangeWatcher if in a replace operation + * (mBlockCalls is set) and the span that is added is an EmojiSpan. + */ + @Override + public void onSpanRemoved(Spannable text, Object what, int start, int end) { + if (mBlockCalls.get() > 0 && isEmojiSpan(what)) { + return; + } + ((SpanWatcher) mObject).onSpanRemoved(text, what, start, end); + } + + /** + * Prevent the onSpanChanged calls to DynamicLayout$ChangeWatcher if in a replace operation + * (mBlockCalls is set) and the span that is added is an EmojiSpan. + */ + @Override + public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart, + int nend) { + if (mBlockCalls.get() > 0 && isEmojiSpan(what)) { + return; + } + // workaround for platform bug fixed in Android P + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + // b/67926915 start cannot be determined, fallback to reflow from start instead + // of causing an exception. + + // emoji2 bug b/216891011 + if (ostart > oend) { + ostart = 0; + } + if (nstart > nend) { + nstart = 0; + } + } + ((SpanWatcher) mObject).onSpanChanged(text, what, ostart, oend, nstart, nend); + } + + final void blockCalls() { + mBlockCalls.incrementAndGet(); + } + + final void unblockCalls() { + mBlockCalls.decrementAndGet(); + } + + private boolean isEmojiSpan(final Object span) { + return span instanceof EmojiSpan; + } + } + +} diff --git a/app/src/main/java/androidx/emoji2/text/TypefaceEmojiRasterizer.java b/app/src/main/java/androidx/emoji2/text/TypefaceEmojiRasterizer.java new file mode 100644 index 0000000000..f120b3cb23 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/TypefaceEmojiRasterizer.java @@ -0,0 +1,331 @@ +/* + * Copyright 2021 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.emoji2.text; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY; +import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; +import static androidx.annotation.RestrictTo.Scope.TESTS; + +import android.annotation.SuppressLint; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Typeface; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.emoji2.text.flatbuffer.MetadataItem; +import androidx.emoji2.text.flatbuffer.MetadataList; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Information about a single emoji. + * + * To draw this emoji on a canvas using use draw. + * + * To draw this emoji using custom code, use getCodepointAt and getTypeface to access the + * underlying emoji font and look up the glyph. + * + * @see TypefaceEmojiRasterizer#draw + * @see TypefaceEmojiRasterizer#getCodepointAt + * @see TypefaceEmojiRasterizer#getTypeface + * + */ +@AnyThread +@RequiresApi(19) +public class TypefaceEmojiRasterizer { + /** + * Defines whether the system can render the emoji. + * @hide + */ + @IntDef({HAS_GLYPH_UNKNOWN, HAS_GLYPH_ABSENT, HAS_GLYPH_EXISTS}) + @Retention(RetentionPolicy.SOURCE) + @RestrictTo(LIBRARY_GROUP) + public @interface HasGlyph { + } + + /** + * Not calculated on device yet. + * @hide + */ + @RestrictTo(LIBRARY) + static final int HAS_GLYPH_UNKNOWN = 0; + + /** + * Device cannot render the emoji. + * @hide + */ + @RestrictTo(LIBRARY) + static final int HAS_GLYPH_ABSENT = 1; + + /** + * Device can render the emoji. + * @hide + */ + @RestrictTo(LIBRARY) + static final int HAS_GLYPH_EXISTS = 2; + + /** + * @see #getMetadataItem() + */ + private static final ThreadLocal sMetadataItem = new ThreadLocal<>(); + + /** + * Index of the TypefaceEmojiRasterizer in {@link MetadataList}. + */ + private final int mIndex; + + /** + * MetadataRepo that holds this instance. + */ + @NonNull + private final MetadataRepo mMetadataRepo; + + /** + * Stores hasGlyph as well as exclusion values + * + * mCache & 0b0011 is hasGlyph result + * mCache & 0b0100 is exclusion value + */ + private volatile int mCache = 0; + + /** + * @hide + */ + @RestrictTo(LIBRARY) + TypefaceEmojiRasterizer(@NonNull final MetadataRepo metadataRepo, + @IntRange(from = 0) final int index) { + mMetadataRepo = metadataRepo; + mIndex = index; + } + + /** + * Draws the emoji onto a canvas with origin at (x,y), using the specified paint. + * + * @param canvas Canvas to be drawn + * @param x x-coordinate of the origin of the emoji being drawn + * @param y y-coordinate of the baseline of the emoji being drawn + * @param paint Paint used for the text (e.g. color, size, style) + */ + public void draw(@NonNull final Canvas canvas, final float x, final float y, + @NonNull final Paint paint) { + final Typeface typeface = mMetadataRepo.getTypeface(); + final Typeface oldTypeface = paint.getTypeface(); + paint.setTypeface(typeface); + // MetadataRepo.getEmojiCharArray() is a continuous array of chars that is used to store the + // chars for emojis. since all emojis are mapped to a single codepoint, and since it is 2 + // chars wide, we assume that the start index of the current emoji is mIndex * 2, and it is + // 2 chars long. + final int charArrayStartIndex = mIndex * 2; + canvas.drawText(mMetadataRepo.getEmojiCharArray(), charArrayStartIndex, 2, x, y, paint); + paint.setTypeface(oldTypeface); + } + + /** + * @return return typeface to be used to render + */ + @NonNull + public Typeface getTypeface() { + return mMetadataRepo.getTypeface(); + } + + /** + * @return a ThreadLocal instance of MetadataItem for this TypefaceEmojiRasterizer + */ + private MetadataItem getMetadataItem() { + MetadataItem result = sMetadataItem.get(); + if (result == null) { + result = new MetadataItem(); + sMetadataItem.set(result); + } + // MetadataList is a wrapper around the metadata ByteBuffer. MetadataItem is a wrapper with + // an index (pointer) on this ByteBuffer that represents a single emoji. Both are FlatBuffer + // classes that wraps a ByteBuffer and gives access to the information in it. In order not + // to create a wrapper class for each TypefaceEmojiRasterizer, we use mIndex as the index + // of the MetadataItem in the ByteBuffer. We need to reiniitalize the current thread + // local instance by executing the statement below. All the statement does is to set an + // int index in MetadataItem. the same instance is used by all TypefaceEmojiRasterizer + // classes in the same thread. + mMetadataRepo.getMetadataList().list(result, mIndex); + return result; + } + + /** + * Unique id for the emoji, as loaded from the font file. + * + * @return unique id for the emoji + * @hide + */ + @RestrictTo(LIBRARY) + public int getId() { + return getMetadataItem().id(); + } + + /** + * @return width of the emoji image + */ + public int getWidth() { + return getMetadataItem().width(); + } + + /** + * @return height of the emoji image + */ + public int getHeight() { + return getMetadataItem().height(); + } + + /** + * @return in which metadata version the emoji was added + * @hide + */ + @RestrictTo(LIBRARY) + public short getCompatAdded() { + return getMetadataItem().compatAdded(); + } + + /** + * @return first SDK that the support for this emoji was added + * @hide + */ + @RestrictTo(LIBRARY) + public short getSdkAdded() { + return getMetadataItem().sdkAdded(); + } + + /** + * Returns the value set by setHasGlyph + * + * This is intended to be used as a cache on this emoji to avoid repeatedly calling + * PaintCompat#hasGlyph on the same codepoint sequence, which is expensive. + * + * @see TypefaceEmojiRasterizer#setHasGlyph + * @return the set value of hasGlyph for this metadata item + * @hide + */ + @HasGlyph + @SuppressLint("KotlinPropertyAccess") + @RestrictTo(LIBRARY) + public int getHasGlyph() { + return (int) (mCache & 0b0011); + } + + /** + * Reset any cached values of hasGlyph on this metadata. + * + * This is only useful for testing, and will make the next display of this emoji slower. + * + * @hide + */ + @RestrictTo(TESTS) + public void resetHasGlyphCache() { + boolean willExclude = isPreferredSystemRender(); + if (willExclude) { + mCache = 0b0100; + } else { + mCache = 0b0000; + } + } + + /** + * Set whether the system can render the emoji. + * + * @see PaintCompat#hasGlyph + * @param hasGlyph {@code true} if system can render the emoji + * @hide + */ + @SuppressLint("KotlinPropertyAccess") + @RestrictTo(LIBRARY) + public void setHasGlyph(boolean hasGlyph) { + int newValue = mCache & 0b0100; /* keep the exclusion bit */ + if (hasGlyph) { + newValue |= 0b0010; + } else { + newValue |= 0b0001; + } + mCache = newValue; + } + + /** + * If this emoji is excluded due to CodepointExclusions.getExcludedCodpoints() + * + * @param exclude if the emoji should never be rendered by emojicompat + * @hide + */ + @RestrictTo(LIBRARY) + public void setExclusion(boolean exclude) { + int hasGlyphBits = getHasGlyph(); + if (exclude) { + mCache = hasGlyphBits | 0b0100; + } else { + mCache = hasGlyphBits; + } + } + + /** + * If the platform requested that this emoji not be rendered using emojicompat. + * + * @return true if this emoji should be drawn by the system instead of this renderer + */ + public boolean isPreferredSystemRender() { + return (mCache & 0b0100) > 0; + } + + /** + * @return whether the emoji is in Emoji Presentation by default (without emoji + * style selector 0xFE0F) + */ + public boolean isDefaultEmoji() { + return getMetadataItem().emojiStyle(); + } + + /** + * @param index index of the codepoint + * + * @return the codepoint at index + */ + public int getCodepointAt(int index) { + return getMetadataItem().codepoints(index); + } + + /** + * @return the length of the codepoints for this emoji + */ + public int getCodepointsLength() { + return getMetadataItem().codepointsLength(); + } + + @NonNull + @Override + public String toString() { + final StringBuilder builder = new StringBuilder(); + builder.append(super.toString()); + builder.append(", id:"); + builder.append(Integer.toHexString(getId())); + builder.append(", codepoints:"); + final int codepointsLength = getCodepointsLength(); + for (int i = 0; i < codepointsLength; i++) { + builder.append(Integer.toHexString(getCodepointAt(i))); + builder.append(" "); + } + return builder.toString(); + } +} diff --git a/app/src/main/java/androidx/emoji2/text/TypefaceEmojiSpan.java b/app/src/main/java/androidx/emoji2/text/TypefaceEmojiSpan.java new file mode 100644 index 0000000000..3b23474cc9 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/TypefaceEmojiSpan.java @@ -0,0 +1,150 @@ +/* + * Copyright 2021 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.emoji2.text; + +import android.annotation.SuppressLint; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.style.CharacterStyle; + +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; + +/** + * EmojiSpan subclass used to render emojis using Typeface. + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +@RequiresApi(19) +public final class TypefaceEmojiSpan extends EmojiSpan { + + /** + * Paint object used to draw a background in debug mode. + */ + private static @Nullable Paint sDebugPaint; + @Nullable + private TextPaint mWorkingPaint; + + /** + * Default constructor. + * + * @param metadata metadata representing the emoji that this span will draw + */ + public TypefaceEmojiSpan(final @NonNull TypefaceEmojiRasterizer metadata) { + super(metadata); + } + + @Override + public void draw(@NonNull final Canvas canvas, + @SuppressLint("UnknownNullness") final CharSequence text, + @IntRange(from = 0) final int start, @IntRange(from = 0) final int end, final float x, + final int top, final int y, final int bottom, @NonNull final Paint paint) { + @Nullable TextPaint textPaint = applyCharacterSpanStyles(text, start, end, paint); + if (textPaint != null && textPaint.bgColor != 0) { + drawBackground(canvas, textPaint, x, x + getWidth(), top, bottom); + } + if (EmojiCompat.get().isEmojiSpanIndicatorEnabled()) { + canvas.drawRect(x, top , x + getWidth(), bottom, getDebugPaint()); + } + getTypefaceRasterizer().draw(canvas, x, y, textPaint != null ? textPaint : paint); + } + + // compat behavior with TextLine.java#handleText background drawing + void drawBackground(Canvas c, TextPaint textPaint, float leftX, float rightX, float top, + float bottom) { + int previousColor = textPaint.getColor(); + Paint.Style previousStyle = textPaint.getStyle(); + + textPaint.setColor(textPaint.bgColor); + textPaint.setStyle(Paint.Style.FILL); + c.drawRect(leftX, top, rightX, bottom, textPaint); + + textPaint.setStyle(previousStyle); + textPaint.setColor(previousColor); + } + + /** + * This applies the CharacterSpanStyles that _would_ have been applied to this character by + * StaticLayout. + * + * StaticLayout applies CharacterSpanStyles _after_ calling ReplacementSpan.draw, which means + * BackgroundSpan will not be applied before draw is called. + * + * If any CharacterSpanStyles would impact _this_ location, apply them to a TextPaint to + * determine if a background needs draw prior to the emoji. + * + * @param text text that this span is part of + * @param start start position to replace + * @param end end position to replace + * @param paint paint (from TextLine) + * @return TextPaint configured + */ + @Nullable + private TextPaint applyCharacterSpanStyles(@Nullable CharSequence text, int start, int end, + Paint paint) { + if (text instanceof Spanned) { + Spanned spanned = (Spanned) text; + CharacterStyle[] spans = spanned.getSpans(start, end, CharacterStyle.class); + if (spans.length == 0 || (spans.length == 1 && spans[0] == this)) { + if (paint instanceof TextPaint) { + // happy path goes here, retain color and bgColor from caller + return (TextPaint) paint; + } else { + return null; + } + } + // there are some non-TypefaceEmojiSpan character styles to apply, update a working + // paint to apply each span style, like TextLine would have. + TextPaint wp = mWorkingPaint; + if (wp == null) { + wp = new TextPaint(); + mWorkingPaint = wp; + } + wp.set(paint); + //noinspection ForLoopReplaceableByForEach + for (int pos = 0; pos < spans.length; pos++) { + spans[pos].updateDrawState(wp); + } + return wp; + } else { + if (paint instanceof TextPaint) { + // retain any color and bgColor from caller + return (TextPaint) paint; + } else { + return null; + } + } + + } + + @NonNull + private static Paint getDebugPaint() { + if (sDebugPaint == null) { + sDebugPaint = new TextPaint(); + sDebugPaint.setColor(EmojiCompat.get().getEmojiSpanIndicatorColor()); + sDebugPaint.setStyle(Paint.Style.FILL); + } + return sDebugPaint; + } + + +} diff --git a/app/src/main/java/androidx/emoji2/text/UnprecomputeTextOnModificationSpannable.java b/app/src/main/java/androidx/emoji2/text/UnprecomputeTextOnModificationSpannable.java new file mode 100644 index 0000000000..25f44329e2 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/UnprecomputeTextOnModificationSpannable.java @@ -0,0 +1,182 @@ +/* + * Copyright 2022 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.emoji2.text; + +import android.os.Build; +import android.text.PrecomputedText; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.text.PrecomputedTextCompat; + +import java.util.stream.IntStream; + +/** + * Spannable that will delegate to a passed spannable for all query operations without allocation. + * + * If delegating to a PrecomputedText, the delegate will be swapped for a SpannableString prior + * to any modifications. + */ +class UnprecomputeTextOnModificationSpannable implements Spannable { + + + /** + * True when mDelegate is safe to write, otherwise mDelegate will need wrapped before mutation + */ + private boolean mSafeToWrite = false; + + @NonNull + private Spannable mDelegate; + + UnprecomputeTextOnModificationSpannable(@NonNull Spannable delegate) { + mDelegate = delegate; + } + + UnprecomputeTextOnModificationSpannable(@NonNull Spanned delegate) { + mDelegate = new SpannableString(delegate); + } + + UnprecomputeTextOnModificationSpannable(@NonNull CharSequence delegate) { + mDelegate = new SpannableString(delegate); + } + + private void ensureSafeWrites() { + Spannable old = mDelegate; + if (!mSafeToWrite && precomputedTextDetector().isPrecomputedText(old)) { + mDelegate = new SpannableString(old); + } + mSafeToWrite = true; + } + + Spannable getUnwrappedSpannable() { + return mDelegate; + } + + @Override + public void setSpan(Object o, int i, int i1, int i2) { + ensureSafeWrites(); + mDelegate.setSpan(o, i, i1, i2); + } + + @Override + public void removeSpan(Object o) { + ensureSafeWrites(); + mDelegate.removeSpan(o); + } + + @Override + public T[] getSpans(int i, int i1, Class aClass) { + return mDelegate.getSpans(i, i1, aClass); + } + + @Override + public int getSpanStart(Object o) { + return mDelegate.getSpanStart(o); + } + + @Override + public int getSpanEnd(Object o) { + return mDelegate.getSpanEnd(o); + } + + @Override + public int getSpanFlags(Object o) { + return mDelegate.getSpanFlags(o); + } + + @Override + public int nextSpanTransition(int i, int i1, Class aClass) { + return mDelegate.nextSpanTransition(i, i1, aClass); + } + + @Override + public int length() { + return mDelegate.length(); + } + + @Override + public char charAt(int i) { + return mDelegate.charAt(i); + } + + @NonNull + @Override + public CharSequence subSequence(int i, int i1) { + return mDelegate.subSequence(i, i1); + } + + @NonNull + @Override + public String toString() { + return mDelegate.toString(); + } + + @RequiresApi(api = Build.VERSION_CODES.N) + @NonNull + @Override + public IntStream chars() { + return CharSequenceHelper_API24.chars(mDelegate); + } + + @RequiresApi(api = Build.VERSION_CODES.N) + @NonNull + @Override + public IntStream codePoints() { + return CharSequenceHelper_API24.codePoints(mDelegate); + } + + @RequiresApi(24) + private static class CharSequenceHelper_API24 { + private CharSequenceHelper_API24() { + // not constructable + } + + static IntStream codePoints(CharSequence charSequence) { + return charSequence.codePoints(); + } + + static IntStream chars(CharSequence charSequence) { + return charSequence.chars(); + } + } + + static PrecomputedTextDetector precomputedTextDetector() { + return (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) + ? new PrecomputedTextDetector() : new PrecomputedTextDetector_28(); + } + + static class PrecomputedTextDetector { + + boolean isPrecomputedText(CharSequence text) { + return text instanceof PrecomputedTextCompat; + } + } + + @RequiresApi(28) + static class PrecomputedTextDetector_28 extends PrecomputedTextDetector { + + @Override + boolean isPrecomputedText(CharSequence text) { + return text instanceof PrecomputedText || text instanceof PrecomputedTextCompat; + } + } +} + + diff --git a/app/src/main/java/androidx/emoji2/text/flatbuffer/MetadataItem.java b/app/src/main/java/androidx/emoji2/text/flatbuffer/MetadataItem.java new file mode 100644 index 0000000000..f6d87b97cf --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/flatbuffer/MetadataItem.java @@ -0,0 +1,79 @@ +// CHECKSTYLE:OFF Generated code +/* + * Copyright (C) 2017 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. + */ +// automatically generated by the FlatBuffers compiler, do not modify +package androidx.emoji2.text.flatbuffer; +import java.nio.*; +import java.lang.*; +import java.util.*; +import com.google.flatbuffers.*; +@SuppressWarnings("unused") +public final class MetadataItem extends Table { + public static void ValidateVersion() { Constants.FLATBUFFERS_2_0_0(); } + public static MetadataItem getRootAsMetadataItem(ByteBuffer _bb) { return getRootAsMetadataItem(_bb, new MetadataItem()); } + public static MetadataItem getRootAsMetadataItem(ByteBuffer _bb, MetadataItem obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public MetadataItem __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + public int id() { int o = __offset(4); return o != 0 ? bb.getInt(o + bb_pos) : 0; } + public boolean emojiStyle() { int o = __offset(6); return o != 0 ? 0!=bb.get(o + bb_pos) : false; } + public short sdkAdded() { int o = __offset(8); return o != 0 ? bb.getShort(o + bb_pos) : 0; } + public short compatAdded() { int o = __offset(10); return o != 0 ? bb.getShort(o + bb_pos) : 0; } + public short width() { int o = __offset(12); return o != 0 ? bb.getShort(o + bb_pos) : 0; } + public short height() { int o = __offset(14); return o != 0 ? bb.getShort(o + bb_pos) : 0; } + public int codepoints(int j) { int o = __offset(16); return o != 0 ? bb.getInt(__vector(o) + j * 4) : 0; } + public int codepointsLength() { int o = __offset(16); return o != 0 ? __vector_len(o) : 0; } + public IntVector codepointsVector() { return codepointsVector(new IntVector()); } + public IntVector codepointsVector(IntVector obj) { int o = __offset(16); return o != 0 ? obj.__assign(__vector(o), bb) : null; } + public ByteBuffer codepointsAsByteBuffer() { return __vector_as_bytebuffer(16, 4); } + public ByteBuffer codepointsInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 16, 4); } + public static int createMetadataItem(FlatBufferBuilder builder, + int id, + boolean emojiStyle, + short sdkAdded, + short compatAdded, + short width, + short height, + int codepointsOffset) { + builder.startTable(7); + MetadataItem.addCodepoints(builder, codepointsOffset); + MetadataItem.addId(builder, id); + MetadataItem.addHeight(builder, height); + MetadataItem.addWidth(builder, width); + MetadataItem.addCompatAdded(builder, compatAdded); + MetadataItem.addSdkAdded(builder, sdkAdded); + MetadataItem.addEmojiStyle(builder, emojiStyle); + return MetadataItem.endMetadataItem(builder); + } + public static void startMetadataItem(FlatBufferBuilder builder) { builder.startTable(7); } + public static void addId(FlatBufferBuilder builder, int id) { builder.addInt(0, id, 0); } + public static void addEmojiStyle(FlatBufferBuilder builder, boolean emojiStyle) { builder.addBoolean(1, emojiStyle, false); } + public static void addSdkAdded(FlatBufferBuilder builder, short sdkAdded) { builder.addShort(2, sdkAdded, 0); } + public static void addCompatAdded(FlatBufferBuilder builder, short compatAdded) { builder.addShort(3, compatAdded, 0); } + public static void addWidth(FlatBufferBuilder builder, short width) { builder.addShort(4, width, 0); } + public static void addHeight(FlatBufferBuilder builder, short height) { builder.addShort(5, height, 0); } + public static void addCodepoints(FlatBufferBuilder builder, int codepointsOffset) { builder.addOffset(6, codepointsOffset, 0); } + public static int createCodepointsVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addInt(data[i]); return builder.endVector(); } + public static void startCodepointsVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static int endMetadataItem(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + public MetadataItem get(int j) { return get(new MetadataItem(), j); } + public MetadataItem get(MetadataItem obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} diff --git a/app/src/main/java/androidx/emoji2/text/flatbuffer/MetadataList.java b/app/src/main/java/androidx/emoji2/text/flatbuffer/MetadataList.java new file mode 100644 index 0000000000..7c7a5aae30 --- /dev/null +++ b/app/src/main/java/androidx/emoji2/text/flatbuffer/MetadataList.java @@ -0,0 +1,66 @@ +// CHECKSTYLE:OFF Generated code +/* + * Copyright (C) 2017 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. + */ +// automatically generated by the FlatBuffers compiler, do not modify +package androidx.emoji2.text.flatbuffer; +import java.nio.*; +import java.lang.*; +import java.util.*; +import com.google.flatbuffers.*; +@SuppressWarnings("unused") +public final class MetadataList extends Table { + public static void ValidateVersion() { Constants.FLATBUFFERS_2_0_0(); } + public static MetadataList getRootAsMetadataList(ByteBuffer _bb) { return getRootAsMetadataList(_bb, new MetadataList()); } + public static MetadataList getRootAsMetadataList(ByteBuffer _bb, MetadataList obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public MetadataList __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + public int version() { int o = __offset(4); return o != 0 ? bb.getInt(o + bb_pos) : 0; } + public androidx.emoji2.text.flatbuffer.MetadataItem list(int j) { return list(new androidx.emoji2.text.flatbuffer.MetadataItem(), j); } + public androidx.emoji2.text.flatbuffer.MetadataItem list(androidx.emoji2.text.flatbuffer.MetadataItem obj, int j) { int o = __offset(6); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int listLength() { int o = __offset(6); return o != 0 ? __vector_len(o) : 0; } + public androidx.emoji2.text.flatbuffer.MetadataItem.Vector listVector() { return listVector(new androidx.emoji2.text.flatbuffer.MetadataItem.Vector()); } + public androidx.emoji2.text.flatbuffer.MetadataItem.Vector listVector(androidx.emoji2.text.flatbuffer.MetadataItem.Vector obj) { int o = __offset(6); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public String sourceSha() { int o = __offset(8); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer sourceShaAsByteBuffer() { return __vector_as_bytebuffer(8, 1); } + public ByteBuffer sourceShaInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 8, 1); } + public static int createMetadataList(FlatBufferBuilder builder, + int version, + int listOffset, + int sourceShaOffset) { + builder.startTable(3); + MetadataList.addSourceSha(builder, sourceShaOffset); + MetadataList.addList(builder, listOffset); + MetadataList.addVersion(builder, version); + return MetadataList.endMetadataList(builder); + } + public static void startMetadataList(FlatBufferBuilder builder) { builder.startTable(3); } + public static void addVersion(FlatBufferBuilder builder, int version) { builder.addInt(0, version, 0); } + public static void addList(FlatBufferBuilder builder, int listOffset) { builder.addOffset(1, listOffset, 0); } + public static int createListVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startListVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addSourceSha(FlatBufferBuilder builder, int sourceShaOffset) { builder.addOffset(2, sourceShaOffset, 0); } + public static int endMetadataList(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + public static void finishMetadataListBuffer(FlatBufferBuilder builder, int offset) { builder.finish(offset); } + public static void finishSizePrefixedMetadataListBuffer(FlatBufferBuilder builder, int offset) { builder.finishSizePrefixed(offset); } + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + public MetadataList get(int j) { return get(new MetadataList(), j); } + public MetadataList get(MetadataList obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +}