Create new emoji2 feature
Moved core and bundled features to folders that match their feature name
Bug: 178035684
Test: Refactoring, ensured build
Relnote: N/A
Change-Id: Ibfbc1e9fb09276a73cc273176c9b868f65a0ed2f
diff --git a/buildSrc/src/main/kotlin/androidx/build/LibraryGroups.kt b/buildSrc/src/main/kotlin/androidx/build/LibraryGroups.kt
index 5e24cda..d22e5be 100644
--- a/buildSrc/src/main/kotlin/androidx/build/LibraryGroups.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/LibraryGroups.kt
@@ -48,6 +48,7 @@
val DRAWERLAYOUT = LibraryGroup("androidx.drawerlayout", LibraryVersions.DRAWERLAYOUT)
val DYNAMICANIMATION = LibraryGroup("androidx.dynamicanimation", null)
val EMOJI = LibraryGroup("androidx.emoji", null)
+ val EMOJI2 = LibraryGroup("androidx.emoji2", LibraryVersions.EMOJI2)
val ENTERPRISE = LibraryGroup("androidx.enterprise", LibraryVersions.ENTERPRISE)
val EXIFINTERFACE = LibraryGroup("androidx.exifinterface", LibraryVersions.EXIFINTERFACE)
val FRAGMENT = LibraryGroup("androidx.fragment", LibraryVersions.FRAGMENT)
diff --git a/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt b/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt
index 360643f..2647951 100644
--- a/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt
@@ -62,6 +62,7 @@
val DYNAMICANIMATION = Version("1.1.0-alpha04")
val DYNAMICANIMATION_KTX = Version("1.0.0-alpha04")
val EMOJI = Version("1.2.0-alpha03")
+ val EMOJI2 = Version("1.0.0-alpha01")
val ENTERPRISE = Version("1.1.0-rc01")
val EXIFINTERFACE = Version("1.4.0-alpha01")
val FRAGMENT = Version("1.4.0-alpha01")
diff --git a/emoji2/OWNERS b/emoji2/OWNERS
new file mode 100644
index 0000000..fe41080
--- /dev/null
+++ b/emoji2/OWNERS
@@ -0,0 +1,3 @@
+clarabayarri@google.com
+siyamed@google.com
+seanmcq@google.com
diff --git a/emoji2/emoji2-bundled/build.gradle b/emoji2/emoji2-bundled/build.gradle
new file mode 100644
index 0000000..5b6e687
--- /dev/null
+++ b/emoji2/emoji2-bundled/build.gradle
@@ -0,0 +1,42 @@
+import androidx.build.LibraryGroups
+import androidx.build.LibraryVersions
+import androidx.build.Publish
+import androidx.build.RunApiTasks
+
+plugins {
+ id("AndroidXPlugin")
+ id("com.android.library")
+}
+
+ext {
+ fontDir = project(':noto-emoji-compat').projectDir
+}
+
+android {
+ sourceSets {
+ main.assets.srcDirs new File(fontDir, "font").getAbsolutePath()
+ }
+}
+
+dependencies {
+ api(project(":emoji"))
+}
+
+androidx {
+ name = "Android Emoji2 Compat"
+ publish = Publish.NONE
+ mavenVersion = LibraryVersions.EMOJI2
+ mavenGroup = LibraryGroups.EMOJI2
+ inceptionYear = "2017"
+ description = "Library bundled with assets to enable emoji compatibility in Kitkat and newer devices to avoid the empty emoji characters."
+
+ license {
+ name = "SIL Open Font License, Version 1.1"
+ url = "http://scripts.sil.org/cms/scripts/page.php?item_id=OFL_web"
+ }
+
+ license {
+ name = "Unicode, Inc. License"
+ url = "http://www.unicode.org/copyright.html#License"
+ }
+}
\ No newline at end of file
diff --git a/emoji2/emoji2-bundled/lint-baseline.xml b/emoji2/emoji2-bundled/lint-baseline.xml
new file mode 100644
index 0000000..27e26a8
--- /dev/null
+++ b/emoji2/emoji2-bundled/lint-baseline.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="5" by="lint 4.2.0-beta04" client="gradle" variant="debug" version="4.2.0-beta04">
+
+</issues>
diff --git a/emoji2/emoji2-bundled/src/main/AndroidManifest.xml b/emoji2/emoji2-bundled/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..56ac589
--- /dev/null
+++ b/emoji2/emoji2-bundled/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<manifest package="androidx.emoji.bundled"/>
\ No newline at end of file
diff --git a/emoji2/emoji2-bundled/src/main/java/androidx/emoji/bundled/BundledEmojiCompatConfig.java b/emoji2/emoji2-bundled/src/main/java/androidx/emoji/bundled/BundledEmojiCompatConfig.java
new file mode 100644
index 0000000..03f5567
--- /dev/null
+++ b/emoji2/emoji2-bundled/src/main/java/androidx/emoji/bundled/BundledEmojiCompatConfig.java
@@ -0,0 +1,87 @@
+/*
+ * 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.
+ */
+
+package androidx.emoji.bundled;
+
+import android.content.Context;
+import android.content.res.AssetManager;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.core.util.Preconditions;
+import androidx.emoji.text.EmojiCompat;
+import androidx.emoji.text.MetadataRepo;
+
+/**
+ * {@link EmojiCompat.Config} implementation that loads the metadata using AssetManager and
+ * bundled resources.
+ * <p/>
+ * <pre><code>EmojiCompat.init(new BundledEmojiCompatConfig(context));</code></pre>
+ *
+ * @see EmojiCompat
+ */
+public class BundledEmojiCompatConfig extends EmojiCompat.Config {
+
+ /**
+ * Default constructor.
+ *
+ * @param context Context instance
+ */
+ public BundledEmojiCompatConfig(@NonNull Context context) {
+ super(new BundledMetadataLoader(context));
+ }
+
+ private static class BundledMetadataLoader implements EmojiCompat.MetadataRepoLoader {
+ private final Context mContext;
+
+ BundledMetadataLoader(@NonNull Context context) {
+ mContext = context.getApplicationContext();
+ }
+
+ @Override
+ @RequiresApi(19)
+ public void load(@NonNull EmojiCompat.MetadataRepoLoaderCallback loaderCallback) {
+ Preconditions.checkNotNull(loaderCallback, "loaderCallback cannot be null");
+ final InitRunnable runnable = new InitRunnable(mContext, loaderCallback);
+ final Thread thread = new Thread(runnable);
+ thread.setDaemon(false);
+ thread.start();
+ }
+ }
+
+ @RequiresApi(19)
+ private static class InitRunnable implements Runnable {
+ private static final String FONT_NAME = "NotoColorEmojiCompat.ttf";
+ private final EmojiCompat.MetadataRepoLoaderCallback mLoaderCallback;
+ private final Context mContext;
+
+ InitRunnable(Context context, EmojiCompat.MetadataRepoLoaderCallback loaderCallback) {
+ mContext = context;
+ mLoaderCallback = loaderCallback;
+ }
+
+ @Override
+ public void run() {
+ try {
+ final AssetManager assetManager = mContext.getAssets();
+ final MetadataRepo resourceIndex = MetadataRepo.create(assetManager, FONT_NAME);
+ mLoaderCallback.onLoaded(resourceIndex);
+ } catch (Throwable t) {
+ mLoaderCallback.onFailed(t);
+ }
+ }
+ }
+}
diff --git a/emoji2/emoji2/AndroidManifest.xml b/emoji2/emoji2/AndroidManifest.xml
new file mode 100644
index 0000000..524cb3c
--- /dev/null
+++ b/emoji2/emoji2/AndroidManifest.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<manifest package="androidx.emoji2"/>
diff --git a/emoji2/emoji2/build.gradle b/emoji2/emoji2/build.gradle
new file mode 100644
index 0000000..ec4a8ea
--- /dev/null
+++ b/emoji2/emoji2/build.gradle
@@ -0,0 +1,79 @@
+import androidx.build.BundleInsideHelper
+import androidx.build.LibraryGroups
+import androidx.build.LibraryVersions
+import androidx.build.Publish
+import androidx.build.RunApiTasks
+
+import static androidx.build.dependencies.DependenciesKt.*
+
+plugins {
+ id("AndroidXPlugin")
+ id("com.android.library")
+ id("com.github.johnrengelman.shadow")
+}
+
+ext {
+ fontDir = project(':noto-emoji-compat').projectDir
+}
+
+BundleInsideHelper.forInsideAar(
+ project,
+ /* from = */ "com.google.flatbuffers",
+ /* to = */ "androidx.text.emoji.flatbuffer"
+)
+
+dependencies {
+ bundleInside(project(":noto-emoji-compat"))
+
+ api("androidx.core:core:1.3.0-rc01")
+ implementation("androidx.collection:collection:1.1.0")
+
+ androidTestImplementation(ANDROIDX_TEST_EXT_JUNIT)
+ androidTestImplementation(ANDROIDX_TEST_CORE)
+ androidTestImplementation(ANDROIDX_TEST_RUNNER)
+ androidTestImplementation(ANDROIDX_TEST_RULES)
+ androidTestImplementation(ESPRESSO_CORE, libs.exclude_for_espresso)
+ androidTestImplementation(MOCKITO_CORE, libs.exclude_bytebuddy) // DexMaker has it"s own MockMaker
+ androidTestImplementation(DEXMAKER_MOCKITO, libs.exclude_bytebuddy) // DexMaker has it"s own MockMaker
+ androidTestImplementation project(':internal-testutils-runtime')
+}
+
+android {
+ sourceSets {
+ main {
+ // We use a non-standard manifest path.
+ manifest.srcFile 'AndroidManifest.xml'
+ res.srcDirs += 'src/main/res-public'
+ resources {
+ srcDirs += [fontDir.getAbsolutePath()]
+ includes += ["LICENSE_UNICODE", "LICENSE_OFL"]
+ }
+ }
+
+ androidTest {
+ assets {
+ srcDirs = [new File(fontDir, "font").getAbsolutePath(),
+ new File(fontDir, "supported-emojis").getAbsolutePath()]
+ }
+ }
+ }
+}
+
+androidx {
+ name = "Android Emoji2 Compat"
+ publish = Publish.NONE
+ mavenVersion = LibraryVersions.EMOJI2
+ mavenGroup = LibraryGroups.EMOJI2
+ inceptionYear = "2017"
+ description = "Core library to enable emoji compatibility in Kitkat and newer devices to avoid the empty emoji characters."
+
+ license {
+ name = "SIL Open Font License, Version 1.1"
+ url = "http://scripts.sil.org/cms/scripts/page.php?item_id=OFL_web"
+ }
+
+ license {
+ name = "Unicode, Inc. License"
+ url = "http://www.unicode.org/copyright.html#License"
+ }
+}
diff --git a/emoji2/emoji2/lint-baseline.xml b/emoji2/emoji2/lint-baseline.xml
new file mode 100644
index 0000000..d6d91ea
--- /dev/null
+++ b/emoji2/emoji2/lint-baseline.xml
@@ -0,0 +1,1416 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="5" by="lint 4.2.0-beta04" client="gradle" variant="debug" version="4.2.0-beta04">
+
+ <issue
+ id="PrivateConstructorForUtilityClass"
+ message="Utility class with non private constructor"
+ errorLine1=" private static final class CodepointIndexFinder {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiProcessor.java"
+ line="654"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="UnsafeNewApiCall"
+ message="This call is to a method from API 21, the call containing class androidx.emoji2.widget.EmojiButton is not annotated with @RequiresApi(x) where x is at least 21. Either annotate the containing class with at least @RequiresApi(21) or move the call to a static method in a wrapper class annotated with at least @RequiresApi(21)."
+ errorLine1=" super(context, attrs, defStyleAttr, defStyleRes);"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiButton.java"
+ line="58"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="UnsafeNewApiCall"
+ message="This call is to a method from API 21, the call containing class androidx.emoji2.widget.EmojiEditText is not annotated with @RequiresApi(x) where x is at least 21. Either annotate the containing class with at least @RequiresApi(21) or move the call to a static method in a wrapper class annotated with at least @RequiresApi(21)."
+ errorLine1=" super(context, attrs, defStyleAttr, defStyleRes);"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiEditText.java"
+ line="65"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="UnsafeNewApiCall"
+ message="This call is to a method from API 21, the call containing class androidx.emoji2.widget.EmojiExtractEditText is not annotated with @RequiresApi(x) where x is at least 21. Either annotate the containing class with at least @RequiresApi(21) or move the call to a static method in a wrapper class annotated with at least @RequiresApi(21)."
+ errorLine1=" super(context, attrs, defStyleAttr, defStyleRes);"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java"
+ line="74"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="UnsafeNewApiCall"
+ message="This call is to a method from API 21, the call containing class androidx.emoji2.widget.EmojiExtractTextLayout is not annotated with @RequiresApi(x) where x is at least 21. Either annotate the containing class with at least @RequiresApi(21) or move the call to a static method in a wrapper class annotated with at least @RequiresApi(21)."
+ errorLine1=" super(context, attrs, defStyleAttr, defStyleRes);"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractTextLayout.java"
+ line="99"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="UnsafeNewApiCall"
+ message="This call is to a method from API 19, the call containing class androidx.emoji2.widget.EmojiInputFilter.InitCallbackImpl is not annotated with @RequiresApi(x) where x is at least 19. Either annotate the containing class with at least @RequiresApi(19) or move the call to a static method in a wrapper class annotated with at least @RequiresApi(19)."
+ errorLine1=" if (textView != null && textView.isAttachedToWindow()) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiInputFilter.java"
+ line="110"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="UnsafeNewApiCall"
+ message="This call is to a method from API 19, the call containing class androidx.emoji2.text.EmojiProcessor.CodepointIndexFinder is not annotated with @RequiresApi(x) where x is at least 19. Either annotate the containing class with at least @RequiresApi(19) or move the call to a static method in a wrapper class annotated with at least @RequiresApi(19)."
+ errorLine1=" if (!Character.isSurrogate(c)) {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiProcessor.java"
+ line="702"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="UnsafeNewApiCall"
+ message="This call is to a method from API 19, the call containing class androidx.emoji2.text.EmojiProcessor.CodepointIndexFinder is not annotated with @RequiresApi(x) where x is at least 19. Either annotate the containing class with at least @RequiresApi(19) or move the call to a static method in a wrapper class annotated with at least @RequiresApi(19)."
+ errorLine1=" if (!Character.isSurrogate(c)) {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiProcessor.java"
+ line="759"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="UnsafeNewApiCall"
+ message="This call is to a method from API 21, the call containing class androidx.emoji2.widget.EmojiTextView is not annotated with @RequiresApi(x) where x is at least 21. Either annotate the containing class with at least @RequiresApi(21) or move the call to a static method in a wrapper class annotated with at least @RequiresApi(21)."
+ errorLine1=" super(context, attrs, defStyleAttr, defStyleRes);"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiTextView.java"
+ line="58"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="UnsafeNewApiCall"
+ message="This call is to a method from API 19, the call containing class androidx.emoji2.widget.EmojiTextWatcher.InitCallbackImpl is not annotated with @RequiresApi(x) where x is at least 19. Either annotate the containing class with at least @RequiresApi(19) or move the call to a static method in a wrapper class annotated with at least @RequiresApi(19)."
+ errorLine1=" if (editText != null && editText.isAttachedToWindow()) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiTextWatcher.java"
+ line="121"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="UnsafeNewApiCall"
+ message="This call is to a method from API 21, the call containing class androidx.emoji2.widget.ExtractButtonCompat is not annotated with @RequiresApi(x) where x is at least 21. Either annotate the containing class with at least @RequiresApi(21) or move the call to a static method in a wrapper class annotated with at least @RequiresApi(21)."
+ errorLine1=" super(context, attrs, defStyleAttr, defStyleRes);"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/ExtractButtonCompat.java"
+ line="53"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="KotlinPropertyAccess"
+ message="The getter return type (`int`) and setter parameter type (`boolean`) getter and setter methods for property `hasGlyph` should have exactly the same type to allow be accessed as a property from Kotlin; see https://android.github.io/kotlin-guides/interop.html#property-prefixes"
+ errorLine1=" public int getHasGlyph() {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiMetadata.java"
+ line="184"
+ column="16"/>
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiMetadata.java"
+ line="193"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EditTextAttributeHelper(@NonNull View view, AttributeSet attrs, int defStyleAttr,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EditTextAttributeHelper.java"
+ line="40"
+ column="56"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiButton(Context context) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiButton.java"
+ line="41"
+ column="24"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiButton(Context context, AttributeSet attrs) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiButton.java"
+ line="46"
+ column="24"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiButton(Context context, AttributeSet attrs) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiButton.java"
+ line="46"
+ column="41"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiButton(Context context, AttributeSet attrs, int defStyleAttr) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiButton.java"
+ line="51"
+ column="24"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiButton(Context context, AttributeSet attrs, int defStyleAttr) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiButton.java"
+ line="51"
+ column="41"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiButton.java"
+ line="57"
+ column="24"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiButton.java"
+ line="57"
+ column="41"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public void setFilters(InputFilter[] filters) {"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiButton.java"
+ line="70"
+ column="28"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public void setCustomSelectionActionModeCallback(ActionMode.Callback actionModeCallback) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiButton.java"
+ line="92"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public static EmojiCompat init(@NonNull final Config config) {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="302"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public static EmojiCompat reset(@NonNull final Config config) {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="322"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public static EmojiCompat reset(final EmojiCompat emojiCompat) {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="337"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public static EmojiCompat reset(final EmojiCompat emojiCompat) {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="337"
+ column="43"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public static EmojiCompat get() {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="352"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" final KeyEvent event) {"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="541"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public CharSequence process(@NonNull final CharSequence charSequence) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="625"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public CharSequence process(@NonNull final CharSequence charSequence,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="662"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public CharSequence process(@NonNull final CharSequence charSequence,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="698"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public CharSequence process(@NonNull final CharSequence charSequence,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="739"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public Config registerInitCallback(@NonNull InitCallback initCallback) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="982"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public Config unregisterInitCallback(@NonNull InitCallback initCallback) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="1000"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public Config setReplaceAll(final boolean replaceAll) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="1017"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public Config setUseEmojiAsDefaultStyle(final boolean useEmojiAsDefaultStyle) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="1037"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public Config setUseEmojiAsDefaultStyle(final boolean useEmojiAsDefaultStyle,"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="1057"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public Config setEmojiSpanIndicatorEnabled(boolean emojiSpanIndicatorEnabled) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="1081"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public Config setEmojiSpanIndicatorColor(@ColorInt int color) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="1092"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public Config setMetadataLoadStrategy(@LoadStrategy int strategy) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="1133"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" protected final MetadataRepoLoader getMetadataRepoLoader() {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiCompat.java"
+ line="1154"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiEditText(Context context) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiEditText.java"
+ line="48"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiEditText(Context context, AttributeSet attrs) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiEditText.java"
+ line="53"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiEditText(Context context, AttributeSet attrs) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiEditText.java"
+ line="53"
+ column="43"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiEditText.java"
+ line="58"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiEditText.java"
+ line="58"
+ column="43"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiEditText.java"
+ line="64"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiEditText.java"
+ line="64"
+ column="43"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public InputConnection onCreateInputConnection(EditorInfo outAttrs) {"
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiEditText.java"
+ line="88"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public InputConnection onCreateInputConnection(EditorInfo outAttrs) {"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiEditText.java"
+ line="88"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public void setCustomSelectionActionModeCallback(ActionMode.Callback actionModeCallback) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiEditText.java"
+ line="133"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiExtractEditText(Context context) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java"
+ line="56"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiExtractEditText(Context context, AttributeSet attrs) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java"
+ line="61"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiExtractEditText(Context context, AttributeSet attrs) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java"
+ line="61"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiExtractEditText(Context context, AttributeSet attrs, int defStyleAttr) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java"
+ line="66"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiExtractEditText(Context context, AttributeSet attrs, int defStyleAttr) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java"
+ line="66"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiExtractEditText(Context context, AttributeSet attrs, int defStyleAttr,"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java"
+ line="72"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiExtractEditText(Context context, AttributeSet attrs, int defStyleAttr,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java"
+ line="72"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public InputConnection onCreateInputConnection(EditorInfo outAttrs) {"
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java"
+ line="97"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public InputConnection onCreateInputConnection(EditorInfo outAttrs) {"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java"
+ line="97"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public void setCustomSelectionActionModeCallback(ActionMode.Callback actionModeCallback) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java"
+ line="161"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiExtractTextLayout(Context context) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractTextLayout.java"
+ line="79"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiExtractTextLayout(Context context,"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractTextLayout.java"
+ line="84"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiExtractTextLayout(Context context,"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractTextLayout.java"
+ line="90"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiExtractTextLayout(Context context, AttributeSet attrs,"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractTextLayout.java"
+ line="97"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiExtractTextLayout(Context context, AttributeSet attrs,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractTextLayout.java"
+ line="97"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public void onUpdateExtractingViews(InputMethodService inputMethodService, EditorInfo ei) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractTextLayout.java"
+ line="163"
+ column="41"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public void onUpdateExtractingViews(InputMethodService inputMethodService, EditorInfo ei) {"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiExtractTextLayout.java"
+ line="163"
+ column="80"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public Typeface getTypeface() {"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiMetadata.java"
+ line="120"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public int getSize(@NonNull final Paint paint, final CharSequence text, final int start,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiSpan.java"
+ line="77"
+ column="58"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" final int end, final Paint.FontMetricsInt fm) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/EmojiSpan.java"
+ line="78"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiTextView(Context context) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiTextView.java"
+ line="41"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiTextView(Context context, AttributeSet attrs) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiTextView.java"
+ line="46"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiTextView(Context context, AttributeSet attrs) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiTextView.java"
+ line="46"
+ column="43"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiTextView.java"
+ line="51"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiTextView.java"
+ line="51"
+ column="43"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiTextView.java"
+ line="57"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiTextView.java"
+ line="57"
+ column="43"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public void setFilters(InputFilter[] filters) {"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiTextView.java"
+ line="70"
+ column="28"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public void setCustomSelectionActionModeCallback(ActionMode.Callback actionModeCallback) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/EmojiTextView.java"
+ line="92"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public ExtractButtonCompat(Context context) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/ExtractButtonCompat.java"
+ line="38"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public ExtractButtonCompat(Context context, AttributeSet attrs) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/ExtractButtonCompat.java"
+ line="42"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public ExtractButtonCompat(Context context, AttributeSet attrs) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/ExtractButtonCompat.java"
+ line="42"
+ column="49"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public ExtractButtonCompat(Context context, AttributeSet attrs, int defStyleAttr) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/ExtractButtonCompat.java"
+ line="46"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public ExtractButtonCompat(Context context, AttributeSet attrs, int defStyleAttr) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/ExtractButtonCompat.java"
+ line="46"
+ column="49"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public ExtractButtonCompat(Context context, AttributeSet attrs, int defStyleAttr,"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/ExtractButtonCompat.java"
+ line="51"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public ExtractButtonCompat(Context context, AttributeSet attrs, int defStyleAttr,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/ExtractButtonCompat.java"
+ line="51"
+ column="49"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public void setCustomSelectionActionModeCallback(ActionMode.Callback actionModeCallback) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/ExtractButtonCompat.java"
+ line="70"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public FontRequestEmojiCompatConfig setHandler(Handler handler) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java"
+ line="143"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public FontRequestEmojiCompatConfig setHandler(Handler handler) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java"
+ line="143"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public FontRequestEmojiCompatConfig setRetryPolicy(RetryPolicy policy) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java"
+ line="156"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public FontRequestEmojiCompatConfig setRetryPolicy(RetryPolicy policy) {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java"
+ line="156"
+ column="56"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public FontFamilyResult fetchFonts(@NonNull Context context,"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java"
+ line="335"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public Typeface buildTypeface(@NonNull Context context,"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java"
+ line="341"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public static MetadataRepo create(@NonNull final Typeface typeface,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/MetadataRepo.java"
+ line="103"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public static MetadataRepo create(@NonNull final Typeface typeface,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/MetadataRepo.java"
+ line="115"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public static MetadataRepo create(@NonNull final AssetManager assetManager,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/MetadataRepo.java"
+ line="127"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" final String assetPath) throws IOException {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/MetadataRepo.java"
+ line="128"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public char[] getEmojiCharArray() {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/MetadataRepo.java"
+ line="176"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public MetadataList getMetadataList() {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/MetadataRepo.java"
+ line="184"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public CharSequence subSequence(int start, int end) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="124"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public void setSpan(Object what, int start, int end, int flags) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="134"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public <T> T[] getSpans(int queryStart, int queryEnd, Class<T> kind) {"
+ errorLine2=" ~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="149"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public <T> T[] getSpans(int queryStart, int queryEnd, Class<T> kind) {"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="149"
+ column="59"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public void removeSpan(Object what) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="167"
+ column="28"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public int getSpanStart(Object tag) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="189"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public int getSpanEnd(Object tag) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="203"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public int getSpanFlags(Object tag) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="217"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public int nextSpanTransition(int start, int limit, Class type) {"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="231"
+ column="57"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder replace(int start, int end, CharSequence tb) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="301"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder replace(int start, int end, CharSequence tb) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="301"
+ column="63"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder replace(int start, int end, CharSequence tb, int tbstart,"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="309"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder replace(int start, int end, CharSequence tb, int tbstart,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="309"
+ column="63"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder insert(int where, CharSequence tb) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="318"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder insert(int where, CharSequence tb) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="318"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder insert(int where, CharSequence tb, int start, int end) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="324"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder insert(int where, CharSequence tb, int start, int end) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="324"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder delete(int start, int end) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="330"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder append(CharSequence text) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="336"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder append(CharSequence text) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="336"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder append(char text) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="342"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder append(CharSequence text, int start, int end) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="348"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder append(CharSequence text, int start, int end) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="348"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder append(CharSequence text, Object what, int flags) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="354"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder append(CharSequence text, Object what, int flags) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="354"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public SpannableStringBuilder append(CharSequence text, Object what, int flags) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/widget/SpannableBuilder.java"
+ line="354"
+ column="61"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public TypefaceEmojiSpan(final EmojiMetadata metadata) {"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/TypefaceEmojiSpan.java"
+ line="48"
+ column="36"/>
+ </issue>
+
+ <issue
+ id="UnknownNullness"
+ message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://android.github.io/kotlin-guides/interop.html#nullability-annotations"
+ errorLine1=" public void draw(@NonNull final Canvas canvas, final CharSequence text,"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/emoji2/text/TypefaceEmojiSpan.java"
+ line="53"
+ column="58"/>
+ </issue>
+
+</issues>
diff --git a/emoji2/emoji2/src/androidTest/AndroidManifest.xml b/emoji2/emoji2/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000..0b2cc8b
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.emoji2.text">
+
+ <application>
+ <activity android:name=".TestActivity"/>
+ </application>
+
+</manifest>
\ No newline at end of file
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/AllEmojisTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/AllEmojisTest.java
new file mode 100644
index 0000000..6e5a680
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/AllEmojisTest.java
@@ -0,0 +1,158 @@
+/*
+ * 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 org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+import android.graphics.Paint;
+import android.text.Spanned;
+
+import androidx.core.graphics.PaintCompat;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.emoji2.util.TestString;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Collection;
+
+/**
+ * Reads raw/allemojis.txt which includes all the emojis known to human kind and tests that
+ * EmojiCompat creates EmojiSpans for each one of them.
+ */
+@LargeTest
+@RunWith(Parameterized.class)
+@SdkSuppress(minSdkVersion = 19)
+public class AllEmojisTest {
+
+ /**
+ * String representation for a single emoji
+ */
+ private String mString;
+
+ /**
+ * Codepoints of emoji for better assert error message.
+ */
+ private String mCodepoints;
+
+ /**
+ * Paint object used to check if Typeface can render the given emoji.
+ */
+ private Paint mPaint;
+
+ @BeforeClass
+ public static void setup() {
+ EmojiCompat.reset(TestConfigBuilder.config());
+ }
+
+ @Parameterized.Parameters
+ public static Collection<Object[]> data() throws IOException {
+ final Context context = ApplicationProvider.getApplicationContext();
+ final InputStream inputStream = context.getAssets().open("emojis.txt");
+ try {
+ final BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
+ final Collection<Object[]> data = new ArrayList<>();
+ final StringBuilder stringBuilder = new StringBuilder();
+ final StringBuilder codePointsBuilder = new StringBuilder();
+
+ String s;
+ while ((s = reader.readLine()) != null) {
+ s = s.trim();
+ // pass comments
+ if (s.isEmpty() || s.startsWith("#")) continue;
+
+ stringBuilder.setLength(0);
+ codePointsBuilder.setLength(0);
+
+ // emoji codepoints are space separated: i.e. 0x1f1e6 0x1f1e8
+ final String[] split = s.split(" ");
+
+ for (int index = 0; index < split.length; index++) {
+ final String part = split[index].trim();
+ codePointsBuilder.append(part);
+ codePointsBuilder.append(",");
+ stringBuilder.append(Character.toChars(Integer.parseInt(part, 16)));
+ }
+ data.add(new Object[]{stringBuilder.toString(), codePointsBuilder.toString()});
+ }
+
+ return data;
+ } finally {
+ inputStream.close();
+ }
+
+ }
+
+ public AllEmojisTest(String string, String codepoints) {
+ mString = string;
+ mCodepoints = codepoints;
+ mPaint = new Paint();
+ }
+
+ @Test
+ public void testEmoji() {
+ assertTrue("EmojiCompat should have emoji: " + mCodepoints,
+ EmojiCompat.get().hasEmojiGlyph(mString));
+ assertEmojiCompatAddsEmoji(mString);
+ assertSpanCanRenderEmoji(mString);
+ }
+
+ private void assertSpanCanRenderEmoji(final String str) {
+ final Spanned spanned = (Spanned) EmojiCompat.get().process(new TestString(str).toString());
+ final EmojiSpan[] spans = spanned.getSpans(0, spanned.length(), EmojiSpan.class);
+ final EmojiMetadata metadata = spans[0].getMetadata();
+ mPaint.setTypeface(metadata.getTypeface());
+
+ final String codepoint = String.valueOf(Character.toChars(metadata.getId()));
+ assertTrue(metadata.toString() + " should be rendered",
+ PaintCompat.hasGlyph(mPaint, codepoint));
+ }
+
+ private void assertEmojiCompatAddsEmoji(final String str) {
+ TestString string = new TestString(str);
+ CharSequence sequence = EmojiCompat.get().process(string.toString());
+ assertThat(sequence, EmojiMatcher.hasEmojiCount(1));
+ assertThat(sequence,
+ EmojiMatcher.hasEmojiAt(string.emojiStartIndex(), string.emojiEndIndex()));
+
+ // case where Emoji is in the middle of string
+ string = new TestString(str).withPrefix().withSuffix();
+ sequence = EmojiCompat.get().process(string.toString());
+ assertThat(sequence, EmojiMatcher.hasEmojiCount(1));
+ assertThat(sequence,
+ EmojiMatcher.hasEmojiAt(string.emojiStartIndex(), string.emojiEndIndex()));
+
+ // case where Emoji is at the end of string
+ string = new TestString(str).withSuffix();
+ sequence = EmojiCompat.get().process(string.toString());
+ assertThat(sequence, EmojiMatcher.hasEmojiCount(1));
+ assertThat(sequence,
+ EmojiMatcher.hasEmojiAt(string.emojiStartIndex(), string.emojiEndIndex()));
+ }
+
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/ConfigTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/ConfigTest.java
new file mode 100644
index 0000000..34525b1
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/ConfigTest.java
@@ -0,0 +1,217 @@
+/*
+ * 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 org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.graphics.Color;
+
+import androidx.emoji2.util.Emoji;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.emoji2.util.TestString;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.MediumTest;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.hamcrest.Matchers;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class ConfigTest {
+
+ Context mContext;
+
+ @Before
+ public void setup() {
+ mContext = ApplicationProvider.getApplicationContext();
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testConstructor_throwsExceptionIfMetadataLoaderNull() {
+ new TestConfigBuilder.TestConfig(null);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testInitCallback_throwsExceptionIfNull() {
+ new ValidTestConfig().registerInitCallback(null);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testUnregisterInitCallback_throwsExceptionIfNull() {
+ new ValidTestConfig().unregisterInitCallback(null);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testBuild_withDefaultValues() {
+ final EmojiCompat.Config config = new ValidTestConfig().setReplaceAll(true);
+
+ final EmojiCompat emojiCompat = EmojiCompat.reset(config);
+
+ final CharSequence processed = emojiCompat.process(new TestString(
+ Emoji.EMOJI_SINGLE_CODEPOINT)
+ .toString());
+ assertThat(processed, EmojiMatcher.hasEmojiCount(1));
+ assertThat(processed, EmojiMatcher.hasEmoji(Emoji.EMOJI_SINGLE_CODEPOINT));
+ }
+
+ @Test
+ public void testInitCallback_callsSuccessCallback() {
+ final EmojiCompat.InitCallback initCallback1 = mock(EmojiCompat.InitCallback.class);
+ final EmojiCompat.InitCallback initCallback2 = mock(EmojiCompat.InitCallback.class);
+
+ final EmojiCompat.Config config = new ValidTestConfig().registerInitCallback(initCallback1)
+ .registerInitCallback(initCallback2);
+ EmojiCompat.reset(config);
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ verify(initCallback1, times(1)).onInitialized();
+ verify(initCallback2, times(1)).onInitialized();
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19) //Fail callback never called for pre 19
+ public void testInitCallback_callsFailCallback() {
+ final EmojiCompat.InitCallback initCallback1 = mock(EmojiCompat.InitCallback.class);
+ final EmojiCompat.InitCallback initCallback2 = mock(EmojiCompat.InitCallback.class);
+ final EmojiCompat.MetadataRepoLoader loader = mock(EmojiCompat.MetadataRepoLoader.class);
+ doThrow(new RuntimeException("")).when(loader)
+ .load(any(EmojiCompat.MetadataRepoLoaderCallback.class));
+
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(loader)
+ .registerInitCallback(initCallback1)
+ .registerInitCallback(initCallback2);
+ EmojiCompat.reset(config);
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ verify(initCallback1, times(1)).onFailed(any(Throwable.class));
+ verify(initCallback2, times(1)).onFailed(any(Throwable.class));
+ }
+
+ @Test
+ public void testBuild_withEmojiSpanIndicator() {
+ EmojiCompat.Config config = new ValidTestConfig();
+ EmojiCompat emojiCompat = EmojiCompat.reset(config);
+
+ assertFalse(emojiCompat.isEmojiSpanIndicatorEnabled());
+
+ config = new ValidTestConfig().setEmojiSpanIndicatorEnabled(true);
+ emojiCompat = EmojiCompat.reset(config);
+
+ assertTrue(emojiCompat.isEmojiSpanIndicatorEnabled());
+ }
+
+ @Test
+ public void testBuild_withEmojiSpanIndicatorColor() {
+ EmojiCompat.Config config = new ValidTestConfig();
+ EmojiCompat emojiCompat = EmojiCompat.reset(config);
+
+ assertEquals(Color.GREEN, emojiCompat.getEmojiSpanIndicatorColor());
+
+ config = new ValidTestConfig().setEmojiSpanIndicatorColor(Color.RED);
+ emojiCompat = EmojiCompat.reset(config);
+
+ assertEquals(Color.RED, emojiCompat.getEmojiSpanIndicatorColor());
+ }
+
+ @Test
+ public void testBuild_defaultEmojiSpanIndicatorColor() {
+ final EmojiCompat.Config config = new ValidTestConfig().setEmojiSpanIndicatorEnabled(true);
+ final EmojiCompat emojiCompat = EmojiCompat.reset(config);
+
+ assertTrue(emojiCompat.isEmojiSpanIndicatorEnabled());
+ }
+
+ @Test
+ public void testBuild_manualLoadStrategy_doesNotCallMetadataLoaderLoad() {
+ final EmojiCompat.MetadataRepoLoader loader = mock(EmojiCompat.MetadataRepoLoader.class);
+ final EmojiCompat.Config config = new ValidTestConfig(loader)
+ .setMetadataLoadStrategy(EmojiCompat.LOAD_STRATEGY_MANUAL);
+
+ EmojiCompat.reset(config);
+
+ verify(loader, never()).load(any(EmojiCompat.MetadataRepoLoaderCallback.class));
+ assertEquals(EmojiCompat.LOAD_STATE_DEFAULT, EmojiCompat.get().getLoadState());
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testGlyphCheckerInstance_EmojiSpan_isNotAdded_whenHasGlyph_returnsTrue() {
+ final EmojiCompat.GlyphChecker glyphChecker = mock(EmojiCompat.GlyphChecker.class);
+ when(glyphChecker.hasGlyph(any(CharSequence.class), anyInt(), anyInt(), anyInt()))
+ .thenReturn(true);
+
+ final EmojiCompat.Config config = TestConfigBuilder.freshConfig()
+ .setReplaceAll(false)
+ .setGlyphChecker(glyphChecker);
+ EmojiCompat.reset(config);
+
+ final String original = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).toString();
+ CharSequence processed = EmojiCompat.get().process(original, 0, original.length());
+
+ verify(glyphChecker, times(1))
+ .hasGlyph(any(CharSequence.class), anyInt(), anyInt(), anyInt());
+ assertThat(processed, Matchers.not(EmojiMatcher.hasEmoji()));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testGlyphCheckerInstance_EmojiSpan_isAdded_whenHasGlyph_returnsFalse() {
+ final EmojiCompat.GlyphChecker glyphChecker = mock(EmojiCompat.GlyphChecker.class);
+ when(glyphChecker.hasGlyph(any(CharSequence.class), anyInt(), anyInt(), anyInt()))
+ .thenReturn(false);
+
+ final EmojiCompat.Config config = TestConfigBuilder.freshConfig()
+ .setReplaceAll(false)
+ .setGlyphChecker(glyphChecker);
+ EmojiCompat.reset(config);
+
+ final String original = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).toString();
+
+ CharSequence processed = EmojiCompat.get().process(original, 0, original.length());
+
+ verify(glyphChecker, times(1))
+ .hasGlyph(any(CharSequence.class), anyInt(), anyInt(), anyInt());
+ assertThat(processed, EmojiMatcher.hasEmoji());
+ }
+
+ private static class ValidTestConfig extends EmojiCompat.Config {
+ ValidTestConfig() {
+ super(new TestConfigBuilder.TestEmojiDataLoader());
+ }
+
+ ValidTestConfig(EmojiCompat.MetadataRepoLoader loader) {
+ super(loader);
+ }
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/EmojiCompatTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/EmojiCompatTest.java
new file mode 100644
index 0000000..46d51bc
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/EmojiCompatTest.java
@@ -0,0 +1,947 @@
+/*
+ * 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 org.hamcrest.Matchers.instanceOf;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.annotation.SuppressLint;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.SpannedString;
+import android.view.KeyEvent;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+
+import androidx.emoji2.util.Emoji;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.emoji2.util.KeyboardUtil;
+import androidx.emoji2.util.TestString;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.MediumTest;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.hamcrest.Matchers;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class EmojiCompatTest {
+
+ @Before
+ public void setup() {
+ EmojiCompat.reset(TestConfigBuilder.config());
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testGet_throwsException() {
+ EmojiCompat.reset((EmojiCompat) null);
+ EmojiCompat.get();
+ }
+
+ @Test
+ public void testProcess_doesNothing_withNullCharSequence() {
+ assertNull(EmojiCompat.get().process(null));
+ }
+
+ @Test
+ public void testProcess_returnsEmptySpanned_withEmptyString() {
+ final CharSequence charSequence = EmojiCompat.get().process("");
+ assertNotNull(charSequence);
+ assertEquals(0, charSequence.length());
+ assertThat(charSequence, Matchers.not(EmojiMatcher.hasEmoji()));
+ }
+
+ @SuppressLint("Range")
+ @Test(expected = IllegalArgumentException.class)
+ public void testProcess_withNegativeStartValue() {
+ EmojiCompat.get().process("a", -1, 1);
+ }
+
+ @SuppressLint("Range")
+ @Test(expected = IllegalArgumentException.class)
+ public void testProcess_withNegativeEndValue() {
+ EmojiCompat.get().process("a", 1, -1);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testProcess_withStartSmallerThanEndValue() {
+ EmojiCompat.get().process("aa", 1, 0);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testProcess_withStartGreaterThanLength() {
+ EmojiCompat.get().process("a", 2, 2);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testProcess_withEndGreaterThanLength() {
+ EmojiCompat.get().process("a", 0, 2);
+ }
+
+ @Test
+ public void testProcessWithStartEnd_withNoOpValues() {
+ final Spannable spannable = new SpannableString(new TestString('a')
+ .withPrefix().withSuffix().toString());
+ // early return check
+ assertSame(spannable, EmojiCompat.get().process(spannable, 0, 0));
+ assertSame(spannable, EmojiCompat.get().process(spannable, 1, 1));
+ assertSame(spannable, EmojiCompat.get().process(spannable, spannable.length(),
+ spannable.length()));
+ }
+
+ @Test
+ public void testProcess_doesNotAddEmojiSpan() {
+ final String string = "abc";
+ final CharSequence charSequence = EmojiCompat.get().process(string);
+ assertNotNull(charSequence);
+ assertEquals(string, charSequence.toString());
+ assertThat(charSequence, Matchers.not(EmojiMatcher.hasEmoji()));
+ }
+
+ @Test
+ @SdkSuppress(maxSdkVersion = 18)
+ public void testProcess_returnsSameCharSequence_pre19() {
+ assertNull(EmojiCompat.get().process(null));
+
+ CharSequence testString = "abc";
+ assertSame(testString, EmojiCompat.get().process(testString));
+
+ testString = new SpannableString("abc");
+ assertSame(testString, EmojiCompat.get().process(testString));
+
+ testString = new TestString(new int[]{Emoji.CHAR_DEFAULT_EMOJI_STYLE}).toString();
+ assertSame(testString, EmojiCompat.get().process(testString));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsSingleCodePointEmoji() {
+ assertCodePointMatch(Emoji.EMOJI_SINGLE_CODEPOINT);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsFlagEmoji() {
+ assertCodePointMatch(Emoji.EMOJI_FLAG);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsUnknownFlagEmoji() {
+ assertCodePointMatch(Emoji.EMOJI_UNKNOWN_FLAG);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsRegionalIndicatorSymbol() {
+ assertCodePointMatch(Emoji.EMOJI_REGIONAL_SYMBOL);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsKeyCapEmoji() {
+ assertCodePointMatch(Emoji.EMOJI_DIGIT_KEYCAP);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_doesNotAddEmojiForNumbers() {
+ assertCodePointDoesNotMatch(new int[] {Emoji.CHAR_DIGIT});
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_doesNotAddEmojiForNumbers_1() {
+ final TestString string = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).append('1', 'f');
+ CharSequence charSequence = EmojiCompat.get().process(string.toString());
+ assertThat(charSequence, EmojiMatcher.hasEmojiCount(1));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsVariantSelectorEmoji() {
+ assertCodePointMatch(Emoji.EMOJI_DIGIT_ES);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_doesNotAddVariantSelectorTextStyle() {
+ assertCodePointDoesNotMatch(new int[]{Emoji.CHAR_DIGIT, Emoji.CHAR_VS_TEXT});
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsVariantSelectorAndKeyCapEmoji() {
+ assertCodePointMatch(Emoji.EMOJI_DIGIT_ES_KEYCAP);
+ }
+
+ @Test
+ public void testProcess_doesNotAddEmoji_forVariantBaseWithoutSelector() {
+ assertCodePointDoesNotMatch(new int[]{Emoji.CHAR_DIGIT});
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsAsteriskKeyCapEmoji() {
+ assertCodePointMatch(Emoji.EMOJI_ASTERISK_KEYCAP);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsSkinModifierEmoji() {
+ assertCodePointMatch(Emoji.EMOJI_SKIN_MODIFIER);
+ assertCodePointMatch(Emoji.EMOJI_SKIN_MODIFIER_TYPE_ONE);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsSkinModifierEmoji_withVariantSelector() {
+ assertCodePointMatch(Emoji.EMOJI_SKIN_MODIFIER_WITH_VS);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsSkinModifierEmoji_270c_withVariantSelector() {
+ // 0x270c is a Standardized Variant Base, Emoji Modifier Base and also Emoji
+ // therefore it is different than i.e. 0x1f3c3. The code actually failed for this test
+ // at first.
+ assertCodePointMatch(0xF0734,
+ new int[]{0x270C, Emoji.CHAR_VS_EMOJI, Emoji.CHAR_FITZPATRICK});
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_defaultStyleDoesNotAddSpan() {
+ assertCodePointDoesNotMatch(new int[]{Emoji.CHAR_DEFAULT_TEXT_STYLE});
+ assertCodePointMatch(Emoji.DEFAULT_TEXT_STYLE);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_defaultEmojiStyle_withTextStyleVs() {
+ assertCodePointMatch(Emoji.EMOJI_SINGLE_CODEPOINT.id(),
+ new int[]{Emoji.CHAR_DEFAULT_EMOJI_STYLE, Emoji.CHAR_VS_EMOJI});
+ assertCodePointDoesNotMatch(new int[]{Emoji.CHAR_DEFAULT_EMOJI_STYLE, Emoji.CHAR_VS_TEXT});
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_genderEmoji() {
+ assertCodePointMatch(Emoji.EMOJI_GENDER);
+ assertCodePointMatch(Emoji.EMOJI_GENDER_WITHOUT_VS);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_standardizedVariantEmojiExceptions() {
+ final int[][] exceptions = new int[][]{
+ {0x2600, 0xF034D},
+ {0x2601, 0xF0167},
+ {0x260E, 0xF034E},
+ {0x261D, 0xF0227},
+ {0x263A, 0xF02A6},
+ {0x2660, 0xF0350},
+ {0x2663, 0xF033F},
+ {0x2665, 0xF033B},
+ {0x2666, 0xF033E},
+ {0x270C, 0xF0079},
+ {0x2744, 0xF0342},
+ {0x2764, 0xF0362}
+ };
+
+ for (int i = 0; i < exceptions.length; i++) {
+ final int[] codepoints = new int[]{exceptions[i][0]};
+ assertCodePointMatch(exceptions[i][1], codepoints);
+ }
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsZwjEmoji() {
+ assertCodePointMatch(Emoji.EMOJI_WITH_ZWJ);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_doesNotAddEmojiForNumbersAfterZwjEmo() {
+ TestString string = new TestString(Emoji.EMOJI_WITH_ZWJ).append(0x20, 0x2B, 0x31)
+ .withSuffix().withPrefix();
+ CharSequence charSequence = EmojiCompat.get().process(string.toString());
+ assertThat(charSequence,
+ EmojiMatcher.hasEmojiAt(Emoji.EMOJI_WITH_ZWJ, string.emojiStartIndex(),
+ string.emojiEndIndex() - 3));
+ assertThat(charSequence, EmojiMatcher.hasEmojiCount(1));
+
+ string = new TestString(Emoji.EMOJI_WITH_ZWJ).withSuffix().withPrefix();
+ charSequence = EmojiCompat.get().process(string.toString());
+ assertThat(charSequence, EmojiMatcher.hasEmojiCount(1));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_addsEmojiThatFollowsDigit() {
+ TestString string = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).prepend('N', '5');
+ CharSequence charSequence = EmojiCompat.get().process(string.toString());
+ assertThat(charSequence, EmojiMatcher.hasEmojiAt(
+ Emoji.EMOJI_SINGLE_CODEPOINT, string.emojiStartIndex() + 2,
+ string.emojiEndIndex()));
+ assertThat(charSequence, EmojiMatcher.hasEmojiCount(1));
+
+ string = new TestString(Emoji.EMOJI_WITH_ZWJ).prepend('N', '5');
+ charSequence = EmojiCompat.get().process(string.toString());
+ assertThat(charSequence, EmojiMatcher.hasEmojiAt(
+ Emoji.EMOJI_WITH_ZWJ, string.emojiStartIndex() + 2,
+ string.emojiEndIndex()));
+ assertThat(charSequence, EmojiMatcher.hasEmojiCount(1));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_withAppend() {
+ final Editable editable = new SpannableStringBuilder(new TestString('a').withPrefix()
+ .withSuffix().toString());
+ final int start = 1;
+ final int end = start + Emoji.EMOJI_SINGLE_CODEPOINT.charCount();
+ editable.insert(start, new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).toString());
+ EmojiCompat.get().process(editable, start, end);
+ assertThat(editable, EmojiMatcher.hasEmojiCount(1));
+ assertThat(editable, EmojiMatcher.hasEmojiAt(Emoji.EMOJI_SINGLE_CODEPOINT, start, end));
+ }
+
+ @Test
+ public void testProcess_doesNotCreateSpannable_ifNoEmoji() {
+ CharSequence processed = EmojiCompat.get().process("abc");
+ assertNotNull(processed);
+ assertThat(processed, instanceOf(String.class));
+
+ processed = EmojiCompat.get().process(new SpannedString("abc"));
+ assertNotNull(processed);
+ assertThat(processed, instanceOf(SpannedString.class));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_reprocess() {
+ final String string = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT)
+ .append(Emoji.EMOJI_SINGLE_CODEPOINT)
+ .append(Emoji.EMOJI_SINGLE_CODEPOINT)
+ .withPrefix().withSuffix().toString();
+
+ Spannable processed = (Spannable) EmojiCompat.get().process(string);
+ assertThat(processed, EmojiMatcher.hasEmojiCount(3));
+
+ final EmojiSpan[] spans = processed.getSpans(0, processed.length(), EmojiSpan.class);
+ final Set<EmojiSpan> spanSet = new HashSet<>();
+ Collections.addAll(spanSet, spans);
+
+ processed = (Spannable) EmojiCompat.get().process(processed);
+ assertThat(processed, EmojiMatcher.hasEmojiCount(3));
+ // new spans should be new instances
+ final EmojiSpan[] newSpans = processed.getSpans(0, processed.length(), EmojiSpan.class);
+ for (int i = 0; i < newSpans.length; i++) {
+ assertFalse(spanSet.contains(newSpans[i]));
+ }
+ }
+
+ @SuppressLint("Range")
+ @Test(expected = IllegalArgumentException.class)
+ public void testProcess_throwsException_withMaxEmojiSetToNegative() {
+ final String original = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).toString();
+
+ final CharSequence processed = EmojiCompat.get().process(original, 0, original.length(),
+ -1 /*maxEmojiCount*/);
+
+ assertThat(processed, Matchers.not(EmojiMatcher.hasEmoji()));
+ }
+
+ @Test
+ public void testProcess_withMaxEmojiSetToZero() {
+ final String original = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).toString();
+
+ final CharSequence processed = EmojiCompat.get().process(original, 0, original.length(),
+ 0 /*maxEmojiCount*/);
+
+ assertThat(processed, Matchers.not(EmojiMatcher.hasEmoji()));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_withMaxEmojiSetToOne() {
+ final String original = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).toString();
+
+ final CharSequence processed = EmojiCompat.get().process(original, 0, original.length(),
+ 1 /*maxEmojiCount*/);
+
+ assertThat(processed, EmojiMatcher.hasEmojiCount(1));
+ assertThat(processed, EmojiMatcher.hasEmoji(Emoji.EMOJI_SINGLE_CODEPOINT));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_withMaxEmojiSetToLessThenExistingSpanCount() {
+ final String original = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT)
+ .append(Emoji.EMOJI_SINGLE_CODEPOINT)
+ .append(Emoji.EMOJI_SINGLE_CODEPOINT)
+ .toString();
+
+ // add 2 spans
+ final CharSequence processed = EmojiCompat.get().process(original, 0, original.length(), 2);
+
+ assertThat(processed, EmojiMatcher.hasEmojiCount(2));
+
+ // use the Spannable with 2 spans, but use maxEmojiCount=1, start from the beginning of
+ // last (3rd) emoji
+ EmojiCompat.get().process(processed,
+ original.length() - Emoji.EMOJI_SINGLE_CODEPOINT.charCount(),
+ original.length(),
+ 1 /*maxEmojiCount*/);
+
+ // expectation: there are still 2 emojis
+ assertThat(processed, EmojiMatcher.hasEmojiCount(2));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_withMaxEmojiSet_withExistingEmojis() {
+ // test string with two emoji characters
+ final String original = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT)
+ .append(Emoji.EMOJI_FLAG).toString();
+
+ // process and add 1 EmojiSpan, maxEmojiCount=1
+ CharSequence processed = EmojiCompat.get().process(original, 0, original.length(),
+ 1 /*maxEmojiCount*/);
+
+ // assert that there is a single emoji
+ assertThat(processed, EmojiMatcher.hasEmojiCount(1));
+ assertThat(processed,
+ EmojiMatcher.hasEmojiAt(
+ Emoji.EMOJI_SINGLE_CODEPOINT, 0, Emoji.EMOJI_SINGLE_CODEPOINT.charCount()));
+
+ // call process again with the charSequence that already has 1 span
+ processed = EmojiCompat.get().process(processed, Emoji.EMOJI_SINGLE_CODEPOINT.charCount(),
+ processed.length(), 1 /*maxEmojiCount*/);
+
+ // assert that there is still a single emoji
+ assertThat(processed, EmojiMatcher.hasEmojiCount(1));
+ assertThat(processed,
+ EmojiMatcher.hasEmojiAt(
+ Emoji.EMOJI_SINGLE_CODEPOINT, 0, Emoji.EMOJI_SINGLE_CODEPOINT.charCount()));
+
+ // make the same call, this time with maxEmojiCount=2
+ processed = EmojiCompat.get().process(processed, Emoji.EMOJI_SINGLE_CODEPOINT.charCount(),
+ processed.length(), 2 /*maxEmojiCount*/);
+
+ // assert that it contains 2 emojis
+ assertThat(processed, EmojiMatcher.hasEmojiCount(2));
+ assertThat(processed,
+ EmojiMatcher.hasEmojiAt(
+ Emoji.EMOJI_SINGLE_CODEPOINT, 0, Emoji.EMOJI_SINGLE_CODEPOINT.charCount()));
+ assertThat(processed,
+ EmojiMatcher.hasEmojiAt(Emoji.EMOJI_FLAG, Emoji.EMOJI_SINGLE_CODEPOINT.charCount(),
+ original.length()));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_withReplaceNonExistent_callsGlyphChecker() {
+ final EmojiCompat.GlyphChecker glyphChecker = mock(EmojiCompat.GlyphChecker.class);
+ final EmojiCompat.Config config = TestConfigBuilder.freshConfig()
+ .setReplaceAll(true)
+ .setGlyphChecker(glyphChecker);
+ EmojiCompat.reset(config);
+
+ when(glyphChecker.hasGlyph(any(CharSequence.class), anyInt(), anyInt(), anyInt()))
+ .thenReturn(true);
+
+ final String original = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).toString();
+
+ CharSequence processed = EmojiCompat.get().process(original, 0, original.length(),
+ Integer.MAX_VALUE /*maxEmojiCount*/, EmojiCompat.REPLACE_STRATEGY_NON_EXISTENT);
+
+ // when function overrides config level replaceAll, a call to GlyphChecker is expected.
+ verify(glyphChecker, times(1))
+ .hasGlyph(any(CharSequence.class), anyInt(), anyInt(), anyInt());
+
+ // since replaceAll is false, there should be no EmojiSpans
+ assertThat(processed, Matchers.not(EmojiMatcher.hasEmoji()));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_withReplaceDefault_doesNotCallGlyphChecker() {
+ final EmojiCompat.GlyphChecker glyphChecker = mock(EmojiCompat.GlyphChecker.class);
+ final EmojiCompat.Config config = TestConfigBuilder.freshConfig()
+ .setReplaceAll(true)
+ .setGlyphChecker(glyphChecker);
+ EmojiCompat.reset(config);
+
+ when(glyphChecker.hasGlyph(any(CharSequence.class), anyInt(), anyInt(), anyInt()))
+ .thenReturn(true);
+
+ final String original = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).toString();
+ // call without replaceAll, config value (true) should be used
+ final CharSequence processed = EmojiCompat.get().process(original, 0, original.length(),
+ Integer.MAX_VALUE /*maxEmojiCount*/, EmojiCompat.REPLACE_STRATEGY_DEFAULT);
+
+ // replaceAll=true should not call hasGlyph
+ verify(glyphChecker, times(0))
+ .hasGlyph(any(CharSequence.class), anyInt(), anyInt(), anyInt());
+
+ assertThat(processed, EmojiMatcher.hasEmojiCount(1));
+ assertThat(processed, EmojiMatcher.hasEmoji(Emoji.EMOJI_SINGLE_CODEPOINT));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testProcess_withSpanned_replaceNonExistent() {
+ final EmojiCompat.GlyphChecker glyphChecker = mock(EmojiCompat.GlyphChecker.class);
+ final EmojiCompat.Config config = TestConfigBuilder.freshConfig()
+ .setReplaceAll(false)
+ .setGlyphChecker(glyphChecker);
+ EmojiCompat.reset(config);
+
+ when(glyphChecker.hasGlyph(any(CharSequence.class), anyInt(), anyInt(), anyInt()))
+ .thenReturn(false);
+
+ final String string = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).append(
+ Emoji.EMOJI_SINGLE_CODEPOINT).toString();
+ CharSequence processed = EmojiCompat.get().process(string, 0, string.length(),
+ Integer.MAX_VALUE, EmojiCompat.REPLACE_STRATEGY_ALL);
+
+ final SpannedString spanned = new SpannedString(processed);
+ assertThat(spanned, EmojiMatcher.hasEmojiCount(2));
+
+ // change glyphChecker to return true so that no emoji will be added
+ when(glyphChecker.hasGlyph(any(CharSequence.class), anyInt(), anyInt(), anyInt()))
+ .thenReturn(true);
+
+ processed = EmojiCompat.get().process(spanned, 0, spanned.length(),
+ Integer.MAX_VALUE, EmojiCompat.REPLACE_STRATEGY_NON_EXISTENT);
+
+ assertThat(processed, Matchers.not(EmojiMatcher.hasEmoji()));
+
+ // start: 1 char after the first emoji (in the second emoji)
+ processed = EmojiCompat.get().process(spanned, Emoji.EMOJI_SINGLE_CODEPOINT.charCount() + 1,
+ spanned.length(), Integer.MAX_VALUE, EmojiCompat.REPLACE_STRATEGY_NON_EXISTENT);
+
+ assertThat(processed, EmojiMatcher.hasEmojiCount(1));
+ assertThat(processed, EmojiMatcher.hasEmoji(Emoji.EMOJI_SINGLE_CODEPOINT));
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testHasEmojiGlyph_withNullCharSequence() {
+ EmojiCompat.get().hasEmojiGlyph(null);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testHasEmojiGlyph_withMetadataVersion_withNullCharSequence() {
+ EmojiCompat.get().hasEmojiGlyph(null, Integer.MAX_VALUE);
+ }
+
+ @Test
+ @SdkSuppress(maxSdkVersion = 18)
+ public void testHasEmojiGlyph_pre19() {
+ String sequence = new TestString(new int[]{Emoji.CHAR_DEFAULT_EMOJI_STYLE}).toString();
+ assertFalse(EmojiCompat.get().hasEmojiGlyph(sequence));
+ }
+
+ @Test
+ @SdkSuppress(maxSdkVersion = 18)
+ public void testHasEmojiGlyph_withMetaVersion_pre19() {
+ String sequence = new TestString(new int[]{Emoji.CHAR_DEFAULT_EMOJI_STYLE}).toString();
+ assertFalse(EmojiCompat.get().hasEmojiGlyph(sequence, Integer.MAX_VALUE));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testHasEmojiGlyph_returnsTrueForExistingEmoji() {
+ final String sequence = new TestString(Emoji.EMOJI_FLAG).toString();
+ assertTrue(EmojiCompat.get().hasEmojiGlyph(sequence));
+ }
+
+ @Test
+ public void testHasGlyph_returnsFalseForNonExistentEmoji() {
+ final String sequence = new TestString(Emoji.EMOJI_FLAG).append(0x1111).toString();
+ assertFalse(EmojiCompat.get().hasEmojiGlyph(sequence));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testHashEmojiGlyph_withDefaultEmojiStyles() {
+ String sequence = new TestString(new int[]{Emoji.CHAR_DEFAULT_EMOJI_STYLE}).toString();
+ assertTrue(EmojiCompat.get().hasEmojiGlyph(sequence));
+
+ sequence = new TestString(
+ new int[]{Emoji.CHAR_DEFAULT_EMOJI_STYLE, Emoji.CHAR_VS_EMOJI}).toString();
+ assertTrue(EmojiCompat.get().hasEmojiGlyph(sequence));
+
+ sequence = new TestString(
+ new int[]{Emoji.CHAR_DEFAULT_EMOJI_STYLE, Emoji.CHAR_VS_TEXT}).toString();
+ assertFalse(EmojiCompat.get().hasEmojiGlyph(sequence));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testHashEmojiGlyph_withMetadataVersion() {
+ final String sequence = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).toString();
+ assertFalse(EmojiCompat.get().hasEmojiGlyph(sequence, 0));
+ assertTrue(EmojiCompat.get().hasEmojiGlyph(sequence, Integer.MAX_VALUE));
+ }
+
+ @Test
+ @SdkSuppress(maxSdkVersion = 18)
+ public void testGetLoadState_returnsSuccess_pre19() {
+ assertEquals(EmojiCompat.get().getLoadState(), EmojiCompat.LOAD_STATE_SUCCEEDED);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testGetLoadState_returnsSuccessIfLoadSuccess() throws InterruptedException {
+ final TestConfigBuilder.WaitingDataLoader
+ metadataLoader = new TestConfigBuilder.WaitingDataLoader(true /*success*/);
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(metadataLoader);
+ EmojiCompat.reset(config);
+
+ assertEquals(EmojiCompat.get().getLoadState(), EmojiCompat.LOAD_STATE_LOADING);
+
+ metadataLoader.getLoaderLatch().countDown();
+ metadataLoader.getTestLatch().await();
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ assertEquals(EmojiCompat.get().getLoadState(), EmojiCompat.LOAD_STATE_SUCCEEDED);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testGetLoadState_returnsFailIfLoadFail() throws InterruptedException {
+ final TestConfigBuilder.WaitingDataLoader
+ metadataLoader = new TestConfigBuilder.WaitingDataLoader(false/*fail*/);
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(metadataLoader);
+ EmojiCompat.reset(config);
+
+ assertEquals(EmojiCompat.get().getLoadState(), EmojiCompat.LOAD_STATE_LOADING);
+
+ metadataLoader.getLoaderLatch().countDown();
+ metadataLoader.getTestLatch().await();
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ assertEquals(EmojiCompat.get().getLoadState(), EmojiCompat.LOAD_STATE_FAILED);
+ }
+
+ @Test
+ public void testUpdateEditorInfoAttrs_doesNotSetKeyIfNotInitialized() {
+ final EditorInfo editorInfo = new EditorInfo();
+ editorInfo.extras = new Bundle();
+
+ final TestConfigBuilder.WaitingDataLoader
+ metadataLoader = new TestConfigBuilder.WaitingDataLoader();
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(metadataLoader);
+ EmojiCompat.reset(config);
+
+ EmojiCompat.get().updateEditorInfoAttrs(editorInfo);
+
+ final Bundle extras = editorInfo.extras;
+ assertFalse(extras.containsKey(EmojiCompat.EDITOR_INFO_METAVERSION_KEY));
+ assertFalse(extras.containsKey(EmojiCompat.EDITOR_INFO_REPLACE_ALL_KEY));
+
+ metadataLoader.getLoaderLatch().countDown();
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testLoad_throwsException_whenLoadStrategyDefault() {
+ final EmojiCompat.MetadataRepoLoader loader = mock(EmojiCompat.MetadataRepoLoader.class);
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(loader);
+ EmojiCompat.reset(config);
+
+ EmojiCompat.get().load();
+ }
+
+ @Test
+ @SdkSuppress(maxSdkVersion = 18)
+ public void testLoad_pre19() {
+ final EmojiCompat.MetadataRepoLoader loader = Mockito.spy(new TestConfigBuilder
+ .TestEmojiDataLoader());
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(loader)
+ .setMetadataLoadStrategy(EmojiCompat.LOAD_STRATEGY_MANUAL);
+
+ EmojiCompat.reset(config);
+
+ verify(loader, never()).load(any(EmojiCompat.MetadataRepoLoaderCallback.class));
+ assertEquals(EmojiCompat.LOAD_STATE_DEFAULT, EmojiCompat.get().getLoadState());
+
+ EmojiCompat.get().load();
+ assertEquals(EmojiCompat.LOAD_STATE_SUCCEEDED, EmojiCompat.get().getLoadState());
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_startsLoading() {
+ final EmojiCompat.MetadataRepoLoader loader = Mockito.spy(new TestConfigBuilder
+ .TestEmojiDataLoader());
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(loader)
+ .setMetadataLoadStrategy(EmojiCompat.LOAD_STRATEGY_MANUAL);
+
+ EmojiCompat.reset(config);
+
+ verify(loader, never()).load(any(EmojiCompat.MetadataRepoLoaderCallback.class));
+ assertEquals(EmojiCompat.LOAD_STATE_DEFAULT, EmojiCompat.get().getLoadState());
+
+ EmojiCompat.get().load();
+ verify(loader, times(1)).load(any(EmojiCompat.MetadataRepoLoaderCallback.class));
+ assertEquals(EmojiCompat.LOAD_STATE_SUCCEEDED, EmojiCompat.get().getLoadState());
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_onceSuccessDoesNotStartLoading() {
+ final EmojiCompat.MetadataRepoLoader loader = Mockito.spy(new TestConfigBuilder
+ .TestEmojiDataLoader());
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(loader)
+ .setMetadataLoadStrategy(EmojiCompat.LOAD_STRATEGY_MANUAL);
+
+ EmojiCompat.reset(config);
+
+ EmojiCompat.get().load();
+ verify(loader, times(1)).load(any(EmojiCompat.MetadataRepoLoaderCallback.class));
+ assertEquals(EmojiCompat.LOAD_STATE_SUCCEEDED, EmojiCompat.get().getLoadState());
+
+ reset(loader);
+ EmojiCompat.get().load();
+ verify(loader, never()).load(any(EmojiCompat.MetadataRepoLoaderCallback.class));
+ assertEquals(EmojiCompat.LOAD_STATE_SUCCEEDED, EmojiCompat.get().getLoadState());
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_onceLoadingDoesNotStartLoading() throws InterruptedException {
+ final TestConfigBuilder.WaitingDataLoader loader = Mockito.spy(
+ new TestConfigBuilder.WaitingDataLoader(true /*success*/));
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(loader)
+ .setMetadataLoadStrategy(EmojiCompat.LOAD_STRATEGY_MANUAL);
+
+ EmojiCompat.reset(config);
+
+ verify(loader, never()).load(any(EmojiCompat.MetadataRepoLoaderCallback.class));
+
+ EmojiCompat.get().load();
+ verify(loader, times(1)).load(any(EmojiCompat.MetadataRepoLoaderCallback.class));
+ assertEquals(EmojiCompat.get().getLoadState(), EmojiCompat.LOAD_STATE_LOADING);
+
+ reset(loader);
+
+ EmojiCompat.get().load();
+ verify(loader, never()).load(any(EmojiCompat.MetadataRepoLoaderCallback.class));
+
+ loader.getLoaderLatch().countDown();
+ loader.getTestLatch().await();
+
+ assertEquals(EmojiCompat.get().getLoadState(), EmojiCompat.LOAD_STATE_SUCCEEDED);
+ }
+
+ @Test
+ @SdkSuppress(maxSdkVersion = 18)
+ public void testGetAssetSignature() {
+ final String signature = EmojiCompat.get().getAssetSignature();
+ assertTrue(signature.isEmpty());
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testGetAssetSignature_api19() {
+ final String signature = EmojiCompat.get().getAssetSignature();
+ assertNotNull(signature);
+ assertFalse(signature.isEmpty());
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testUpdateEditorInfoAttrs_setsKeysIfInitialized() {
+ final EditorInfo editorInfo = new EditorInfo();
+ editorInfo.extras = new Bundle();
+ EmojiCompat.Config config = TestConfigBuilder.config().setReplaceAll(false);
+ EmojiCompat.reset(config);
+ EmojiCompat.get().updateEditorInfoAttrs(editorInfo);
+
+ final Bundle extras = editorInfo.extras;
+ assertTrue(extras.containsKey(EmojiCompat.EDITOR_INFO_METAVERSION_KEY));
+ assertTrue(extras.getInt(EmojiCompat.EDITOR_INFO_METAVERSION_KEY) > 0);
+ assertTrue(extras.containsKey(EmojiCompat.EDITOR_INFO_REPLACE_ALL_KEY));
+ assertFalse(extras.getBoolean(EmojiCompat.EDITOR_INFO_REPLACE_ALL_KEY));
+
+ config = new TestConfigBuilder.TestConfig().setReplaceAll(true);
+ EmojiCompat.reset(config);
+ EmojiCompat.get().updateEditorInfoAttrs(editorInfo);
+
+ assertTrue(extras.containsKey(EmojiCompat.EDITOR_INFO_REPLACE_ALL_KEY));
+ assertTrue(extras.getBoolean(EmojiCompat.EDITOR_INFO_REPLACE_ALL_KEY));
+ }
+
+ @Test
+ @SdkSuppress(maxSdkVersion = 18)
+ public void testHandleDeleteSurroundingText_pre19() {
+ final TestString testString = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT);
+ final InputConnection inputConnection = mock(InputConnection.class);
+ final Editable editable = spy(new SpannableStringBuilder(testString.toString()));
+
+ Selection.setSelection(editable, testString.emojiEndIndex());
+
+ reset(editable);
+ reset(inputConnection);
+ verifyNoMoreInteractions(editable);
+ verifyNoMoreInteractions(inputConnection);
+
+ // try backwards delete 1 character
+ assertFalse(EmojiCompat.handleDeleteSurroundingText(inputConnection, editable,
+ 1 /*beforeLength*/, 0 /*afterLength*/, false /*inCodePoints*/));
+ }
+
+ @Test
+ @SdkSuppress(maxSdkVersion = 18)
+ public void testOnKeyDown_pre19() {
+ final TestString testString = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT);
+ final Editable editable = spy(new SpannableStringBuilder(testString.toString()));
+ Selection.setSelection(editable, testString.emojiEndIndex());
+ final KeyEvent event = KeyboardUtil.del();
+
+ reset(editable);
+ verifyNoMoreInteractions(editable);
+
+ assertFalse(EmojiCompat.handleOnKeyDown(editable, event.getKeyCode(), event));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testUseEmojiAsDefaultStyle_whenEmojiInTheMiddle() {
+ final EmojiCompat.Config config = TestConfigBuilder.config().setReplaceAll(true);
+ EmojiCompat.reset(config);
+ String s = new TestString(0x0061, Emoji.CHAR_DEFAULT_TEXT_STYLE, 0x0062).toString();
+ // no span should be added as the emoji is text style presented by default
+ assertThat(EmojiCompat.get().process(s), Matchers.not(EmojiMatcher.hasEmoji()));
+ // a span should be added when we use the emoji style presentation as default
+ EmojiCompat.reset(config.setUseEmojiAsDefaultStyle(true));
+ assertThat(EmojiCompat.get().process(s),
+ EmojiMatcher.hasEmojiAt(Emoji.DEFAULT_TEXT_STYLE, 1, 2));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testUseEmojiAsDefaultStyle_whenEmojiAtTheEnd() {
+ final EmojiCompat.Config config = TestConfigBuilder.config().setReplaceAll(true);
+ EmojiCompat.reset(config);
+ String s = new TestString(0x0061, Emoji.CHAR_DEFAULT_TEXT_STYLE).toString();
+ // no span should be added as the emoji is text style presented by default
+ assertThat(EmojiCompat.get().process(s), Matchers.not(EmojiMatcher.hasEmoji()));
+ // a span should be added when we use the emoji style presentation as default
+ EmojiCompat.reset(config.setUseEmojiAsDefaultStyle(true));
+ assertThat(EmojiCompat.get().process(s),
+ EmojiMatcher.hasEmojiAt(Emoji.DEFAULT_TEXT_STYLE, 1, 2));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testUseEmojiAsDefaultStyle_noEmojisAdded_whenMarkedAsException() {
+ final String s = new TestString(Emoji.CHAR_DEFAULT_TEXT_STYLE).toString();
+ final List<Integer> exceptions =
+ Arrays.asList(Emoji.CHAR_DEFAULT_TEXT_STYLE + 1, Emoji.CHAR_DEFAULT_TEXT_STYLE);
+ final EmojiCompat.Config config = TestConfigBuilder.config().setReplaceAll(true)
+ .setUseEmojiAsDefaultStyle(true, exceptions);
+ EmojiCompat.reset(config);
+ // no span should be added as the text style codepoint is marked as exception
+ assertThat(EmojiCompat.get().process(s), Matchers.not(EmojiMatcher.hasEmoji()));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testUseEmojiAsDefaultStyle_emojisAdded_whenNotMarkedAsException() {
+ final String s = new TestString(Emoji.CHAR_DEFAULT_TEXT_STYLE).toString();
+ final List<Integer> exceptions =
+ Arrays.asList(Emoji.CHAR_DEFAULT_TEXT_STYLE - 1, Emoji.CHAR_DEFAULT_TEXT_STYLE + 1);
+ final EmojiCompat.Config config = TestConfigBuilder.config().setReplaceAll(true)
+ .setUseEmojiAsDefaultStyle(true, exceptions);
+ EmojiCompat.reset(config);
+ // a span should be added as the codepoint is not included in the set of exceptions
+ assertThat(EmojiCompat.get().process(s),
+ EmojiMatcher.hasEmojiAt(Emoji.DEFAULT_TEXT_STYLE, 0, 1));
+ }
+
+ private void assertCodePointMatch(Emoji.EmojiMapping emoji) {
+ assertCodePointMatch(emoji.id(), emoji.codepoints());
+ }
+
+ private void assertCodePointMatch(int id, int[] codepoints) {
+ TestString string = new TestString(codepoints);
+ CharSequence charSequence = EmojiCompat.get().process(string.toString());
+ assertThat(charSequence,
+ EmojiMatcher.hasEmojiAt(id, string.emojiStartIndex(), string.emojiEndIndex()));
+
+ // case where Emoji is in the middle of string
+ string = new TestString(codepoints).withPrefix().withSuffix();
+ charSequence = EmojiCompat.get().process(string.toString());
+ assertThat(charSequence,
+ EmojiMatcher.hasEmojiAt(id, string.emojiStartIndex(), string.emojiEndIndex()));
+
+ // case where Emoji is at the end of string
+ string = new TestString(codepoints).withSuffix();
+ charSequence = EmojiCompat.get().process(string.toString());
+ assertThat(charSequence,
+ EmojiMatcher.hasEmojiAt(id, string.emojiStartIndex(), string.emojiEndIndex()));
+ }
+
+ private void assertCodePointDoesNotMatch(int[] codepoints) {
+ TestString string = new TestString(codepoints);
+ CharSequence charSequence = EmojiCompat.get().process(string.toString());
+ assertThat(charSequence, Matchers.not(EmojiMatcher.hasEmoji()));
+
+ string = new TestString(codepoints).withSuffix().withPrefix();
+ charSequence = EmojiCompat.get().process(string.toString());
+ assertThat(charSequence, Matchers.not(EmojiMatcher.hasEmoji()));
+
+ string = new TestString(codepoints).withPrefix();
+ charSequence = EmojiCompat.get().process(string.toString());
+ assertThat(charSequence, Matchers.not(EmojiMatcher.hasEmoji()));
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/EmojiKeyboardTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/EmojiKeyboardTest.java
new file mode 100644
index 0000000..9b813d4
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/EmojiKeyboardTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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 org.junit.Assert.assertThat;
+
+import android.app.Instrumentation;
+import android.text.Editable;
+import android.view.inputmethod.InputConnection;
+import android.widget.EditText;
+
+import androidx.emoji2.test.R;
+import androidx.emoji2.util.Emoji;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.emoji2.util.KeyboardUtil;
+import androidx.emoji2.util.TestString;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.filters.Suppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.testutils.PollingCheck;
+
+import org.hamcrest.core.IsNot;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@LargeTest
+@RunWith(AndroidJUnit4.class)
+@Suppress
+public class EmojiKeyboardTest {
+
+ @SuppressWarnings("deprecation")
+ @Rule
+ public androidx.test.rule.ActivityTestRule<TestActivity> mActivityRule =
+ new androidx.test.rule.ActivityTestRule<>(TestActivity.class);
+ private Instrumentation mInstrumentation;
+
+ @BeforeClass
+ public static void setupEmojiCompat() {
+ EmojiCompat.reset(TestConfigBuilder.config());
+ }
+
+ @Before
+ public void setup() {
+ mInstrumentation = InstrumentationRegistry.getInstrumentation();
+ }
+
+ @Test
+ public void testAppendWithSoftKeyboard() throws Exception {
+ TestActivity activity = mActivityRule.getActivity();
+ final EditText editText = (EditText) activity.findViewById(R.id.editText);
+ final TestString string = new TestString(Emoji.EMOJI_WITH_ZWJ).withPrefix()
+ .withSuffix();
+
+ final InputConnection inputConnection = KeyboardUtil.initTextViewForSimulatedIme(
+ mInstrumentation, editText);
+ KeyboardUtil.setComposingTextInBatch(mInstrumentation, inputConnection,
+ string.toString());
+ Editable editable = editText.getEditableText();
+
+ assertThat(editable, EmojiMatcher.hasEmojiAt(Emoji.EMOJI_WITH_ZWJ, string.emojiStartIndex(),
+ string.emojiEndIndex()));
+ }
+
+ @Test
+ public void testBackDeleteWithSoftKeyboard() throws Exception {
+ TestActivity activity = mActivityRule.getActivity();
+ final EditText editText = (EditText) activity.findViewById(R.id.editText);
+ final TestString string = new TestString(Emoji.EMOJI_WITH_ZWJ).withPrefix()
+ .withSuffix();
+ final InputConnection inputConnection = KeyboardUtil.initTextViewForSimulatedIme(
+ mInstrumentation, editText);
+ KeyboardUtil.setComposingTextInBatch(mInstrumentation, inputConnection, string.toString());
+
+ // assert that emoji is there
+ final Editable editable = editText.getEditableText();
+ assertThat(editable, EmojiMatcher.hasEmoji());
+
+ // put selection at the end of emoji and back delete
+ KeyboardUtil.setSelection(mInstrumentation, editText.getEditableText(),
+ string.emojiEndIndex());
+ KeyboardUtil.deleteSurroundingText(mInstrumentation, inputConnection, 1, 0);
+
+ assertThat(editable, IsNot.not(EmojiMatcher.hasEmoji()));
+ }
+
+ @Test
+ public void testForwardDeleteWithSoftKeyboard() throws Exception {
+ TestActivity activity = mActivityRule.getActivity();
+ final EditText editText = (EditText) activity.findViewById(R.id.editText);
+ final TestString string = new TestString(Emoji.EMOJI_WITH_ZWJ).withPrefix()
+ .withSuffix();
+ final InputConnection inputConnection = KeyboardUtil.initTextViewForSimulatedIme(
+ mInstrumentation, editText);
+ KeyboardUtil.setComposingTextInBatch(mInstrumentation, inputConnection, string.toString());
+
+ // assert that emoji is there
+ final Editable editable = editText.getEditableText();
+ assertThat(editable, EmojiMatcher.hasEmoji());
+
+ // put selection at the beginning of emoji and forward delete
+ KeyboardUtil.setSelection(mInstrumentation, editText.getEditableText(),
+ string.emojiStartIndex());
+ KeyboardUtil.deleteSurroundingText(mInstrumentation, inputConnection, 0, 1);
+
+
+ assertThat(editable, IsNot.not(EmojiMatcher.hasEmoji()));
+ }
+
+ @Test
+ public void testBackDeleteWithHardwareKeyboard() throws Exception {
+ TestActivity activity = mActivityRule.getActivity();
+ final EditText editText = (EditText) activity.findViewById(R.id.editText);
+ final TestString string = new TestString(Emoji.EMOJI_WITH_ZWJ).withPrefix()
+ .withSuffix();
+ final InputConnection inputConnection = KeyboardUtil.initTextViewForSimulatedIme(
+ mInstrumentation, editText);
+ KeyboardUtil.setComposingTextInBatch(mInstrumentation, inputConnection, string.toString());
+
+ // assert that emoji is there
+ final Editable editable = editText.getEditableText();
+ assertThat(editable, EmojiMatcher.hasEmoji());
+
+ // put selection at the end of emoji and back delete
+ KeyboardUtil.setSelection(mInstrumentation, editText.getEditableText(),
+ string.emojiEndIndex());
+ mInstrumentation.sendKeySync(KeyboardUtil.del());
+ mInstrumentation.waitForIdleSync();
+
+ PollingCheck.waitFor(new PollingCheck.PollingCheckCondition() {
+ @Override
+ public boolean canProceed() {
+ return IsNot.not(EmojiMatcher.hasEmoji()).matches(true);
+ }
+ });
+ assertThat(editable, IsNot.not(EmojiMatcher.hasEmoji()));
+ }
+
+ @Test
+ public void testForwardDeleteWithHardwareKeyboard() throws Exception {
+ TestActivity activity = mActivityRule.getActivity();
+ final EditText editText = (EditText) activity.findViewById(R.id.editText);
+ final TestString string = new TestString(Emoji.EMOJI_WITH_ZWJ).withPrefix()
+ .withSuffix();
+ final InputConnection inputConnection = KeyboardUtil.initTextViewForSimulatedIme(
+ mInstrumentation, editText);
+ KeyboardUtil.setComposingTextInBatch(mInstrumentation, inputConnection, string.toString());
+
+ // assert that emoji is there
+ final Editable editable = editText.getEditableText();
+ assertThat(editable, EmojiMatcher.hasEmoji());
+
+ // put selection at the beginning of emoji and forward delete
+ KeyboardUtil.setSelection(mInstrumentation, editText.getEditableText(),
+ string.emojiStartIndex());
+ mInstrumentation.sendKeySync(KeyboardUtil.forwardDel());
+ mInstrumentation.waitForIdleSync();
+
+ PollingCheck.waitFor(new PollingCheck.PollingCheckCondition() {
+ @Override
+ public boolean canProceed() {
+ return IsNot.not(EmojiMatcher.hasEmoji()).matches(true);
+ }
+ });
+ assertThat(editable, IsNot.not(EmojiMatcher.hasEmoji()));
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/EmojiSpanInstrumentationTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/EmojiSpanInstrumentationTest.java
new file mode 100644
index 0000000..573a8f4
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/EmojiSpanInstrumentationTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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 org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThat;
+
+import android.app.Instrumentation;
+import android.text.Spannable;
+import android.text.Spanned;
+import android.text.style.RelativeSizeSpan;
+import android.util.TypedValue;
+import android.widget.TextView;
+
+import androidx.emoji2.test.R;
+import androidx.emoji2.util.Emoji;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.emoji2.util.TestString;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@LargeTest
+@RunWith(AndroidJUnit4.class)
+@SdkSuppress(minSdkVersion = 19)
+public class EmojiSpanInstrumentationTest {
+
+ @SuppressWarnings("deprecation")
+ @Rule
+ public androidx.test.rule.ActivityTestRule<TestActivity> mActivityRule =
+ new androidx.test.rule.ActivityTestRule<>(TestActivity.class);
+ private Instrumentation mInstrumentation;
+
+ @BeforeClass
+ public static void setupEmojiCompat() {
+ EmojiCompat.reset(TestConfigBuilder.config());
+ }
+
+ @Before
+ public void setup() {
+ mInstrumentation = InstrumentationRegistry.getInstrumentation();
+ }
+
+ @Test
+ public void testGetSize_withRelativeSizeSpan() {
+ final TestActivity activity = mActivityRule.getActivity();
+ final TextView textView = (TextView) activity.findViewById(R.id.text);
+
+ // create a string with single codepoint emoji
+ final TestString string = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT)
+ .withPrefix().withSuffix();
+ final CharSequence charSequence = EmojiCompat.get().process(string.toString());
+ assertNotNull(charSequence);
+ assertThat(charSequence, EmojiMatcher.hasEmojiCount(1));
+
+ final Spannable spanned = (Spannable) charSequence;
+ final EmojiSpan[] spans = spanned.getSpans(0, charSequence.length(), EmojiSpan.class);
+ final EmojiSpan span = spans[0];
+
+ // set text to the charSequence with the EmojiSpan
+ mInstrumentation.runOnMainSync(new Runnable() {
+ @Override
+ public void run() {
+ textView.setText(charSequence);
+ }
+ });
+ mInstrumentation.waitForIdleSync();
+
+ // record height of the default span
+ final int defaultHeight = span.getHeight();
+
+ // cover the charsequence with RelativeSizeSpan which will triple the size of the
+ // characters.
+ final float multiplier = 3.0f;
+ final RelativeSizeSpan sizeSpan = new RelativeSizeSpan(multiplier);
+ spanned.setSpan(sizeSpan, 0, charSequence.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ // set the new text
+ mInstrumentation.runOnMainSync(new Runnable() {
+ @Override
+ public void run() {
+ textView.setText(charSequence);
+ }
+ });
+ mInstrumentation.waitForIdleSync();
+
+ // record the height measured after RelativeSizeSpan
+ final int heightWithRelativeSpan = span.getHeight();
+
+ // accept 1sp error rate.
+ final float delta = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 1,
+ mInstrumentation.getTargetContext().getResources().getDisplayMetrics());
+ assertEquals(defaultHeight * 3, heightWithRelativeSpan, delta);
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/EmojiSpanTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/EmojiSpanTest.java
new file mode 100644
index 0000000..77694aa
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/EmojiSpanTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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 org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Paint.FontMetricsInt;
+import android.text.TextPaint;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+@LargeTest
+@RunWith(AndroidJUnit4.class)
+@SdkSuppress(minSdkVersion = 19)
+public class EmojiSpanTest {
+
+ @Before
+ public void setup() {
+ EmojiCompat.reset(TestConfigBuilder.config());
+ }
+
+ @Test
+ public void testGetSize() {
+ final short dimensionX = 18;
+ final short dimensionY = 20;
+ final int fontHeight = 10;
+ final float expectedRatio = fontHeight * 1.0f / dimensionY;
+ final TextPaint paint = mock(TextPaint.class);
+
+ // mock TextPaint to return test font metrics
+ when(paint.getFontMetricsInt(any(FontMetricsInt.class))).thenAnswer(new Answer<Object>() {
+ @Override
+ public Object answer(InvocationOnMock invocation) throws Throwable {
+ final FontMetricsInt fontMetrics = (FontMetricsInt) invocation.getArguments()[0];
+ fontMetrics.ascent = 0;
+ fontMetrics.descent = -fontHeight;
+ return null;
+ }
+ });
+
+ final EmojiMetadata metadata = mock(EmojiMetadata.class);
+ when(metadata.getWidth()).thenReturn(dimensionX);
+ when(metadata.getHeight()).thenReturn(dimensionY);
+ final EmojiSpan span = new TypefaceEmojiSpan(metadata);
+
+ final int resultSize = span.getSize(paint, "", 0, 0, null);
+ assertEquals((int) (dimensionX * expectedRatio), resultSize);
+ assertEquals(expectedRatio, span.getRatio(), 0.01f);
+ assertEquals((int) (dimensionX * expectedRatio), span.getWidth());
+ assertEquals((int) (dimensionY * expectedRatio), span.getHeight());
+ }
+
+ @Test
+ public void testBackgroundIndicator() {
+ // control the size of the emoji span
+ final EmojiMetadata metadata = mock(EmojiMetadata.class);
+ when(metadata.getWidth()).thenReturn((short) 10);
+ when(metadata.getHeight()).thenReturn((short) 10);
+
+ final EmojiSpan span = new TypefaceEmojiSpan(metadata);
+ final int spanWidth = span.getSize(mock(Paint.class), "", 0, 0, null);
+ // prepare parameters for draw() call
+ final Canvas canvas = mock(Canvas.class);
+ final float x = 10;
+ final int top = 15;
+ final int y = 20;
+ final int bottom = 30;
+
+ // verify the case where indicators are disabled
+ EmojiCompat.reset(TestConfigBuilder.config().setEmojiSpanIndicatorEnabled(false));
+ span.draw(canvas, "a", 0 /*start*/, 1 /*end*/, x, top, y, bottom, mock(Paint.class));
+
+ verify(canvas, times(0)).drawRect(eq(x), eq((float) top), eq(x + spanWidth),
+ eq((float) bottom), any(Paint.class));
+
+ // verify the case where indicators are enabled
+ EmojiCompat.reset(TestConfigBuilder.config().setEmojiSpanIndicatorEnabled(true));
+ reset(canvas);
+ span.draw(canvas, "a", 0 /*start*/, 1 /*end*/, x, top, y, bottom, mock(Paint.class));
+
+ verify(canvas, times(1)).drawRect(eq(x), eq((float) top), eq(x + spanWidth),
+ eq((float) bottom), any(Paint.class));
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/FontRequestEmojiCompatConfigTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/FontRequestEmojiCompatConfigTest.java
new file mode 100644
index 0000000..92afb67
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/FontRequestEmojiCompatConfigTest.java
@@ -0,0 +1,585 @@
+/*
+ * 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 android.content.res.AssetManager.ACCESS_BUFFER;
+
+import static androidx.core.provider.FontsContractCompat.Columns.RESULT_CODE_FONT_NOT_FOUND;
+import static androidx.core.provider.FontsContractCompat.Columns.RESULT_CODE_FONT_UNAVAILABLE;
+import static androidx.core.provider.FontsContractCompat.Columns.RESULT_CODE_MALFORMED_QUERY;
+import static androidx.core.provider.FontsContractCompat.Columns.RESULT_CODE_OK;
+import static androidx.core.provider.FontsContractCompat.FontFamilyResult.STATUS_OK;
+import static androidx.core.provider.FontsContractCompat.FontFamilyResult.STATUS_WRONG_CERTIFICATES;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.content.Context;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.HandlerThread;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.provider.FontRequest;
+import androidx.core.provider.FontsContractCompat.FontFamilyResult;
+import androidx.core.provider.FontsContractCompat.FontInfo;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+@LargeTest
+@RunWith(AndroidJUnit4.class)
+public class FontRequestEmojiCompatConfigTest {
+ private static final int DEFAULT_TIMEOUT_MILLIS = 3000;
+ private Context mContext;
+ private FontRequest mFontRequest;
+ private FontRequestEmojiCompatConfig.FontProviderHelper mFontProviderHelper;
+
+ @Before
+ public void setup() {
+ mContext = ApplicationProvider.getApplicationContext();
+ mFontRequest = new FontRequest("authority", "package", "query",
+ new ArrayList<List<byte[]>>());
+ mFontProviderHelper = mock(FontRequestEmojiCompatConfig.FontProviderHelper.class);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testConstructor_withNullContext() {
+ new FontRequestEmojiCompatConfig(null, mFontRequest);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testConstructor_withNullFontRequest() {
+ new FontRequestEmojiCompatConfig(mContext, null);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_whenGetFontThrowsException() throws NameNotFoundException {
+ final Exception exception = new RuntimeException();
+ doThrow(exception).when(mFontProviderHelper).fetchFonts(
+ any(Context.class), any(FontRequest.class));
+ final WaitingLoaderCallback callback = spy(new WaitingLoaderCallback());
+ final EmojiCompat.Config config = new FontRequestEmojiCompatConfig(mContext, mFontRequest,
+ mFontProviderHelper);
+
+ config.getMetadataRepoLoader().load(callback);
+ callback.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, times(1)).onFailed(same(exception));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_providerNotFound() throws NameNotFoundException {
+ doThrow(new NameNotFoundException()).when(mFontProviderHelper).fetchFonts(
+ any(Context.class), any(FontRequest.class));
+ final WaitingLoaderCallback callback = spy(new WaitingLoaderCallback());
+ final EmojiCompat.Config config = new FontRequestEmojiCompatConfig(mContext,
+ mFontRequest, mFontProviderHelper);
+
+ config.getMetadataRepoLoader().load(callback);
+ callback.await(DEFAULT_TIMEOUT_MILLIS);
+
+ final ArgumentCaptor<Throwable> argumentCaptor = ArgumentCaptor.forClass(Throwable.class);
+ verify(callback, times(1)).onFailed(argumentCaptor.capture());
+ assertThat(argumentCaptor.getValue().getMessage(), containsString("provider not found"));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_wrongCertificate() throws NameNotFoundException {
+ verifyLoaderOnFailedCalled(STATUS_WRONG_CERTIFICATES, null /* fonts */,
+ "fetchFonts failed (" + STATUS_WRONG_CERTIFICATES + ")");
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_fontNotFound() throws NameNotFoundException {
+ verifyLoaderOnFailedCalled(STATUS_OK,
+ getTestFontInfoWithInvalidPath(RESULT_CODE_FONT_NOT_FOUND),
+ "fetchFonts result is not OK. (" + RESULT_CODE_FONT_NOT_FOUND + ")");
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_fontUnavailable() throws NameNotFoundException {
+ verifyLoaderOnFailedCalled(STATUS_OK,
+ getTestFontInfoWithInvalidPath(RESULT_CODE_FONT_UNAVAILABLE),
+ "fetchFonts result is not OK. (" + RESULT_CODE_FONT_UNAVAILABLE + ")");
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_malformedQuery() throws NameNotFoundException {
+ verifyLoaderOnFailedCalled(STATUS_OK,
+ getTestFontInfoWithInvalidPath(RESULT_CODE_MALFORMED_QUERY),
+ "fetchFonts result is not OK. (" + RESULT_CODE_MALFORMED_QUERY + ")");
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_resultNotFound() throws NameNotFoundException {
+ verifyLoaderOnFailedCalled(STATUS_OK, new FontInfo[] {},
+ "fetchFonts failed (empty result)");
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_nullFontInfo() throws NameNotFoundException {
+ verifyLoaderOnFailedCalled(STATUS_OK, null /* fonts */,
+ "fetchFonts failed (empty result)");
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_cannotLoadTypeface() throws NameNotFoundException {
+ // getTestFontInfoWithInvalidPath returns FontInfo with invalid path to file.
+ verifyLoaderOnFailedCalled(STATUS_OK,
+ getTestFontInfoWithInvalidPath(RESULT_CODE_OK),
+ "Unable to open file.");
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_success() throws IOException, NameNotFoundException {
+ final File file = loadFont(mContext, "NotoColorEmojiCompat.ttf");
+ final FontInfo[] fonts = new FontInfo[] {
+ new FontInfo(Uri.fromFile(file), 0 /* ttc index */, 400 /* weight */,
+ false /* italic */, RESULT_CODE_OK)
+ };
+ doReturn(new FontFamilyResult(STATUS_OK, fonts)).when(mFontProviderHelper).fetchFonts(
+ any(Context.class), any(FontRequest.class));
+ final WaitingLoaderCallback callback = spy(new WaitingLoaderCallback());
+ final EmojiCompat.Config config = new FontRequestEmojiCompatConfig(mContext,
+ mFontRequest, mFontProviderHelper);
+
+ config.getMetadataRepoLoader().load(callback);
+ callback.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, times(1)).onLoaded(any(MetadataRepo.class));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_retryPolicy() throws IOException, NameNotFoundException {
+ final File file = loadFont(mContext, "NotoColorEmojiCompat.ttf");
+ final FontInfo[] fonts = new FontInfo[] {
+ new FontInfo(Uri.fromFile(file), 0 /* ttc index */, 400 /* weight */,
+ false /* italic */, RESULT_CODE_FONT_UNAVAILABLE)
+ };
+ doReturn(new FontFamilyResult(STATUS_OK, fonts)).when(mFontProviderHelper).fetchFonts(
+ any(Context.class), any(FontRequest.class));
+ final WaitingLoaderCallback callback = spy(new WaitingLoaderCallback());
+ final WaitingRetryPolicy retryPolicy = spy(new WaitingRetryPolicy(-1, 1));
+ final EmojiCompat.Config config = new FontRequestEmojiCompatConfig(mContext,
+ mFontRequest, mFontProviderHelper).setRetryPolicy(retryPolicy);
+
+ config.getMetadataRepoLoader().load(callback);
+ callback.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, never()).onLoaded(any(MetadataRepo.class));
+ verify(callback, times(1)).onFailed(any(Throwable.class));
+ verify(retryPolicy, times(1)).getRetryDelay();
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_keepRetryingAndGiveUp() throws IOException, NameNotFoundException {
+ final File file = loadFont(mContext, "NotoColorEmojiCompat.ttf");
+ final FontInfo[] fonts = new FontInfo[] {
+ new FontInfo(Uri.fromFile(file), 0 /* ttc index */, 400 /* weight */,
+ false /* italic */, RESULT_CODE_FONT_UNAVAILABLE)
+ };
+ doReturn(new FontFamilyResult(STATUS_OK, fonts)).when(mFontProviderHelper).fetchFonts(
+ any(Context.class), any(FontRequest.class));
+ final WaitingLoaderCallback callback = spy(new WaitingLoaderCallback());
+ final WaitingRetryPolicy retryPolicy = spy(new WaitingRetryPolicy(500, 1));
+ final EmojiCompat.Config config = new FontRequestEmojiCompatConfig(mContext,
+ mFontRequest, mFontProviderHelper).setRetryPolicy(retryPolicy);
+
+ config.getMetadataRepoLoader().load(callback);
+ retryPolicy.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, never()).onLoaded(any(MetadataRepo.class));
+ verify(callback, never()).onFailed(any(Throwable.class));
+ verify(retryPolicy, atLeastOnce()).getRetryDelay();
+ retryPolicy.changeReturnValue(-1);
+ callback.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, never()).onLoaded(any(MetadataRepo.class));
+ verify(callback, times(1)).onFailed(any(Throwable.class));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_keepRetryingAndFail() throws IOException, NameNotFoundException {
+ final File file = loadFont(mContext, "NotoColorEmojiCompat.ttf");
+ final Uri uri = Uri.fromFile(file);
+
+ final FontInfo[] fonts = new FontInfo[] {
+ new FontInfo(uri, 0 /* ttc index */, 400 /* weight */,
+ false /* italic */, RESULT_CODE_FONT_UNAVAILABLE)
+ };
+ doReturn(new FontFamilyResult(STATUS_OK, fonts)).when(mFontProviderHelper).fetchFonts(
+ any(Context.class), any(FontRequest.class));
+ final WaitingLoaderCallback callback = spy(new WaitingLoaderCallback());
+ final WaitingRetryPolicy retryPolicy = spy(new WaitingRetryPolicy(500, 1));
+
+ HandlerThread thread = new HandlerThread("testThread");
+ thread.start();
+ try {
+ Handler handler = new Handler(thread.getLooper());
+
+ final EmojiCompat.Config config = new FontRequestEmojiCompatConfig(mContext,
+ mFontRequest, mFontProviderHelper).setHandler(handler)
+ .setRetryPolicy(retryPolicy);
+
+ config.getMetadataRepoLoader().load(callback);
+ retryPolicy.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, never()).onLoaded(any(MetadataRepo.class));
+ verify(callback, never()).onFailed(any(Throwable.class));
+ verify(retryPolicy, atLeastOnce()).getRetryDelay();
+
+ // To avoid race condition, change the fetchFonts result on the handler thread.
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ final FontInfo[] fontsSuccess = new FontInfo[] {
+ new FontInfo(uri, 0 /* ttc index */, 400 /* weight */,
+ false /* italic */, RESULT_CODE_FONT_NOT_FOUND)
+ };
+
+ doReturn(new FontFamilyResult(STATUS_OK, fontsSuccess)).when(
+ mFontProviderHelper).fetchFonts(any(Context.class),
+ any(FontRequest.class));
+ } catch (NameNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ });
+
+ callback.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, never()).onLoaded(any(MetadataRepo.class));
+ verify(callback, times(1)).onFailed(any(Throwable.class));
+ } finally {
+ thread.quit();
+ }
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_keepRetryingAndSuccess() throws IOException, NameNotFoundException {
+ final File file = loadFont(mContext, "NotoColorEmojiCompat.ttf");
+ final Uri uri = Uri.fromFile(file);
+
+ final FontInfo[] fonts = new FontInfo[]{
+ new FontInfo(uri, 0 /* ttc index */, 400 /* weight */,
+ false /* italic */, RESULT_CODE_FONT_UNAVAILABLE)
+ };
+ doReturn(new FontFamilyResult(STATUS_OK, fonts)).when(mFontProviderHelper).fetchFonts(
+ any(Context.class), any(FontRequest.class));
+ final WaitingLoaderCallback callback = spy(new WaitingLoaderCallback());
+ final WaitingRetryPolicy retryPolicy = spy(new WaitingRetryPolicy(500, 1));
+
+ HandlerThread thread = new HandlerThread("testThread");
+ thread.start();
+ try {
+ Handler handler = new Handler(thread.getLooper());
+
+ final EmojiCompat.Config config = new FontRequestEmojiCompatConfig(mContext,
+ mFontRequest, mFontProviderHelper).setHandler(handler)
+ .setRetryPolicy(retryPolicy);
+
+ config.getMetadataRepoLoader().load(callback);
+ retryPolicy.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, never()).onLoaded(any(MetadataRepo.class));
+ verify(callback, never()).onFailed(any(Throwable.class));
+ verify(retryPolicy, atLeastOnce()).getRetryDelay();
+
+ final FontInfo[] fontsSuccess = new FontInfo[]{
+ new FontInfo(uri, 0 /* ttc index */, 400 /* weight */,
+ false /* italic */, RESULT_CODE_OK)
+ };
+
+ // To avoid race condition, change the fetchFonts result on the handler thread.
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ doReturn(new FontFamilyResult(STATUS_OK, fontsSuccess)).when(
+ mFontProviderHelper).fetchFonts(any(Context.class),
+ any(FontRequest.class));
+ } catch (NameNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ });
+
+ callback.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, times(1)).onLoaded(any(MetadataRepo.class));
+ verify(callback, never()).onFailed(any(Throwable.class));
+ } finally {
+ thread.quit();
+ }
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_ObserverNotifyAndSuccess() throws IOException, NameNotFoundException {
+ final File file = loadFont(mContext, "NotoColorEmojiCompat.ttf");
+ final Uri uri = Uri.fromFile(file);
+ final FontInfo[] fonts = new FontInfo[]{
+ new FontInfo(uri, 0 /* ttc index */, 400 /* weight */,
+ false /* italic */, RESULT_CODE_FONT_UNAVAILABLE)
+ };
+ doReturn(new FontFamilyResult(STATUS_OK, fonts)).when(mFontProviderHelper).fetchFonts(
+ any(Context.class), any(FontRequest.class));
+ final WaitingLoaderCallback callback = spy(new WaitingLoaderCallback());
+ final WaitingRetryPolicy retryPolicy = spy(new WaitingRetryPolicy(500, 2));
+
+ HandlerThread thread = new HandlerThread("testThread");
+ thread.start();
+ try {
+ Handler handler = new Handler(thread.getLooper());
+ final EmojiCompat.Config config = new FontRequestEmojiCompatConfig(mContext,
+ mFontRequest, mFontProviderHelper).setHandler(handler)
+ .setRetryPolicy(retryPolicy);
+
+ ArgumentCaptor<ContentObserver> observerCaptor =
+ ArgumentCaptor.forClass(ContentObserver.class);
+
+ config.getMetadataRepoLoader().load(callback);
+ retryPolicy.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, never()).onLoaded(any(MetadataRepo.class));
+ verify(callback, never()).onFailed(any(Throwable.class));
+ verify(retryPolicy, atLeastOnce()).getRetryDelay();
+ verify(mFontProviderHelper, times(1)).registerObserver(
+ any(Context.class), eq(uri), observerCaptor.capture());
+
+ final FontInfo[] fontsSuccess = new FontInfo[]{
+ new FontInfo(uri, 0 /* ttc index */, 400 /* weight */,
+ false /* italic */, RESULT_CODE_OK)
+ };
+ doReturn(new FontFamilyResult(STATUS_OK, fontsSuccess)).when(
+ mFontProviderHelper).fetchFonts(any(Context.class), any(FontRequest.class));
+
+ final ContentObserver observer = observerCaptor.getValue();
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ observer.onChange(false /* self change */, uri);
+ }
+ });
+
+ callback.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, times(1)).onLoaded(any(MetadataRepo.class));
+ verify(callback, never()).onFailed(any(Throwable.class));
+ } finally {
+ thread.quit();
+ }
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testLoad_ObserverNotifyAndFail() throws IOException, NameNotFoundException {
+ final File file = loadFont(mContext, "NotoColorEmojiCompat.ttf");
+ final Uri uri = Uri.fromFile(file);
+ final FontInfo[] fonts = new FontInfo[]{
+ new FontInfo(uri, 0 /* ttc index */, 400 /* weight */,
+ false /* italic */, RESULT_CODE_FONT_UNAVAILABLE)
+ };
+ doReturn(new FontFamilyResult(STATUS_OK, fonts)).when(mFontProviderHelper).fetchFonts(
+ any(Context.class), any(FontRequest.class));
+ final WaitingLoaderCallback callback = spy(new WaitingLoaderCallback());
+ final WaitingRetryPolicy retryPolicy = spy(new WaitingRetryPolicy(500, 2));
+
+ HandlerThread thread = new HandlerThread("testThread");
+ thread.start();
+ try {
+ Handler handler = new Handler(thread.getLooper());
+ final EmojiCompat.Config config = new FontRequestEmojiCompatConfig(mContext,
+ mFontRequest, mFontProviderHelper).setHandler(handler)
+ .setRetryPolicy(retryPolicy);
+
+ ArgumentCaptor<ContentObserver> observerCaptor =
+ ArgumentCaptor.forClass(ContentObserver.class);
+
+ config.getMetadataRepoLoader().load(callback);
+ retryPolicy.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, never()).onLoaded(any(MetadataRepo.class));
+ verify(callback, never()).onFailed(any(Throwable.class));
+ verify(retryPolicy, atLeastOnce()).getRetryDelay();
+ verify(mFontProviderHelper, times(1)).registerObserver(
+ any(Context.class), eq(uri), observerCaptor.capture());
+
+ final FontInfo[] fontsSuccess = new FontInfo[]{
+ new FontInfo(uri, 0 /* ttc index */, 400 /* weight */,
+ false /* italic */, RESULT_CODE_FONT_NOT_FOUND)
+ };
+ doReturn(new FontFamilyResult(STATUS_OK, fontsSuccess)).when(
+ mFontProviderHelper).fetchFonts(any(Context.class), any(FontRequest.class));
+
+ final ContentObserver observer = observerCaptor.getValue();
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ observer.onChange(false /* self change */, uri);
+ }
+ });
+
+ callback.await(DEFAULT_TIMEOUT_MILLIS);
+ verify(callback, never()).onLoaded(any(MetadataRepo.class));
+ verify(callback, times(1)).onFailed(any(Throwable.class));
+ } finally {
+ thread.quit();
+ }
+ }
+
+ private void verifyLoaderOnFailedCalled(final int statusCode,
+ final FontInfo[] fonts, String exceptionMessage) throws NameNotFoundException {
+ doReturn(new FontFamilyResult(statusCode, fonts)).when(mFontProviderHelper).fetchFonts(
+ any(Context.class), any(FontRequest.class));
+ final WaitingLoaderCallback callback = spy(new WaitingLoaderCallback());
+ final EmojiCompat.Config config = new FontRequestEmojiCompatConfig(mContext, mFontRequest,
+ mFontProviderHelper);
+
+ config.getMetadataRepoLoader().load(callback);
+ callback.await(DEFAULT_TIMEOUT_MILLIS);
+
+ final ArgumentCaptor<Throwable> argumentCaptor = ArgumentCaptor.forClass(Throwable.class);
+ verify(callback, times(1)).onFailed(argumentCaptor.capture());
+ assertThat(argumentCaptor.getValue().getMessage(), containsString(exceptionMessage));
+ }
+
+ public static class WaitingRetryPolicy extends FontRequestEmojiCompatConfig.RetryPolicy {
+ private final CountDownLatch mLatch;
+ private final Object mLock = new Object();
+ @GuardedBy("mLock")
+ private long mReturnValue;
+
+ public WaitingRetryPolicy(long returnValue, int callCount) {
+ mLatch = new CountDownLatch(callCount);
+ synchronized (mLock) {
+ mReturnValue = returnValue;
+ }
+ }
+
+ @Override
+ public long getRetryDelay() {
+ mLatch.countDown();
+ synchronized (mLock) {
+ return mReturnValue;
+ }
+ }
+
+ public void changeReturnValue(long value) {
+ synchronized (mLock) {
+ mReturnValue = value;
+ }
+ }
+
+ public void await(long timeoutMillis) {
+ try {
+ mLatch.await(timeoutMillis, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ public static class WaitingLoaderCallback extends EmojiCompat.MetadataRepoLoaderCallback {
+ final CountDownLatch mLatch;
+
+ public WaitingLoaderCallback() {
+ mLatch = new CountDownLatch(1);
+ }
+
+ @Override
+ public void onLoaded(@NonNull MetadataRepo metadataRepo) {
+ mLatch.countDown();
+ }
+
+ @Override
+ public void onFailed(@Nullable Throwable throwable) {
+ mLatch.countDown();
+ }
+
+ public void await(long timeoutMillis) {
+ try {
+ mLatch.await(timeoutMillis, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ public static File loadFont(Context context, String fileName) {
+ File cacheFile = new File(context.getCacheDir(), fileName);
+ try {
+ copyToCacheFile(context, fileName, cacheFile);
+ return cacheFile;
+ } catch (IOException e) {
+ fail();
+ }
+ return null;
+ }
+
+ private static void copyToCacheFile(final Context context, final String assetPath,
+ final File cacheFile) throws IOException {
+ try (InputStream is = context.getAssets().open(assetPath, ACCESS_BUFFER);
+ FileOutputStream fos = new FileOutputStream(cacheFile, false)) {
+ byte[] buffer = new byte[1024];
+ int readLen;
+ while ((readLen = is.read(buffer)) != -1) {
+ fos.write(buffer, 0, readLen);
+ }
+ }
+ }
+
+ private FontInfo[] getTestFontInfoWithInvalidPath(int resultCode) {
+ return new FontInfo[] { new FontInfo(Uri.parse("file:///some/placeholder/file"),
+ 0 /* ttc index */, 400 /* weight */, false /* italic */, resultCode) };
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/HardDeleteTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/HardDeleteTest.java
new file mode 100644
index 0000000..5ea5680
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/HardDeleteTest.java
@@ -0,0 +1,212 @@
+/*
+ * 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 org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.text.Editable;
+import android.text.Selection;
+import android.text.SpannableStringBuilder;
+import android.view.KeyEvent;
+
+import androidx.emoji2.util.Emoji;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.emoji2.util.KeyboardUtil;
+import androidx.emoji2.util.TestString;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.hamcrest.MatcherAssert;
+import org.hamcrest.core.IsNot;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+@SdkSuppress(minSdkVersion = 19)
+public class HardDeleteTest {
+
+ private TestString mTestString;
+ private Editable mEditable;
+
+ @BeforeClass
+ public static void setupEmojiCompat() {
+ EmojiCompat.reset(TestConfigBuilder.config());
+ }
+
+ @Before
+ public void setup() {
+ mTestString = new TestString(Emoji.EMOJI_WITH_ZWJ).withPrefix().withSuffix();
+ mEditable = new SpannableStringBuilder(mTestString.toString());
+ EmojiCompat.get().process(mEditable);
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmojiCount(1));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji(Emoji.EMOJI_WITH_ZWJ));
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_whenKeyCodeIsNotDelOrForwardDel() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+ final KeyEvent event = KeyboardUtil.zero();
+ assertFalse(EmojiCompat.handleOnKeyDown(mEditable, event.getKeyCode(), event));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_withOtherModifiers() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+ final KeyEvent event = KeyboardUtil.fnDel();
+ assertFalse(EmojiCompat.handleOnKeyDown(mEditable, event.getKeyCode(), event));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_withAltModifier() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+ final KeyEvent event = KeyboardUtil.altDel();
+ assertFalse(EmojiCompat.handleOnKeyDown(mEditable, event.getKeyCode(), event));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_withCtrlModifier() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+ final KeyEvent event = KeyboardUtil.ctrlDel();
+ assertFalse(EmojiCompat.handleOnKeyDown(mEditable, event.getKeyCode(), event));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_withShiftModifier() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+ final KeyEvent event = KeyboardUtil.shiftDel();
+ assertFalse(EmojiCompat.handleOnKeyDown(mEditable, event.getKeyCode(), event));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_withSelectionLongerThanZeroLength() {
+ // when there is a selection which is longer than 0, it should not delete.
+ Selection.setSelection(mEditable, 0, mEditable.length());
+ final KeyEvent event = KeyboardUtil.del();
+ assertFalse(EmojiCompat.handleOnKeyDown(mEditable, event.getKeyCode(), event));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_withoutEmojiSpans() {
+ final Editable editable = new SpannableStringBuilder("abc");
+ Selection.setSelection(editable, 1);
+ final KeyEvent event = KeyboardUtil.del();
+ assertFalse(EmojiCompat.handleOnKeyDown(mEditable, event.getKeyCode(), event));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_whenNoSpansBefore() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex());
+ final KeyEvent event = KeyboardUtil.del();
+ assertFalse(EmojiCompat.handleOnKeyDown(mEditable, event.getKeyCode(), event));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_deletesEmoji() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+ final KeyEvent event = KeyboardUtil.del();
+ assertTrue(EmojiCompat.handleOnKeyDown(mEditable, event.getKeyCode(), event));
+ MatcherAssert.assertThat(mEditable, IsNot.not(EmojiMatcher.hasEmoji()));
+ assertEquals(new TestString().withPrefix().withSuffix().toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotForwardDeleteEmoji_withNoSpansAfter() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+ final KeyEvent event = KeyboardUtil.forwardDel();
+ assertFalse(EmojiCompat.handleOnKeyDown(mEditable, event.getKeyCode(), event));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_forwardDeletesEmoji() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex());
+ final KeyEvent event = KeyboardUtil.forwardDel();
+ assertTrue(EmojiCompat.handleOnKeyDown(mEditable, event.getKeyCode(), event));
+ MatcherAssert.assertThat(mEditable, IsNot.not(EmojiMatcher.hasEmoji()));
+ assertEquals(new TestString().withPrefix().withSuffix().toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_deletesEmoji_ifSelectionIsInSpanBoundaries() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex() + 1);
+ final KeyEvent delEvent = KeyboardUtil.del();
+ assertTrue(EmojiCompat.handleOnKeyDown(mEditable, delEvent.getKeyCode(), delEvent));
+ MatcherAssert.assertThat(mEditable, IsNot.not(EmojiMatcher.hasEmoji()));
+ assertEquals(new TestString().withPrefix().withSuffix().toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_deletesEmoji_ifSelectionIsInSpanBoundaries_withForwardDel() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex() + 1);
+ final KeyEvent forwardDelEvent = KeyboardUtil.forwardDel();
+ assertTrue(EmojiCompat.handleOnKeyDown(mEditable, forwardDelEvent.getKeyCode(),
+ forwardDelEvent));
+ MatcherAssert.assertThat(mEditable, IsNot.not(EmojiMatcher.hasEmoji()));
+ assertEquals(new TestString().withPrefix().withSuffix().toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testOnKeyDown_deletesOnlyEmojiBeforeTheCursor() {
+ // contains three emojis
+ mTestString = new TestString(Emoji.EMOJI_FLAG)
+ .append(Emoji.EMOJI_WITH_ZWJ)
+ .append(Emoji.EMOJI_GENDER)
+ .withPrefix().withSuffix();
+ mEditable = new SpannableStringBuilder(mTestString.toString());
+ EmojiCompat.get().process(mEditable);
+
+ // put the cursor after the second emoji
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex()
+ + Emoji.EMOJI_FLAG.charCount()
+ + Emoji.EMOJI_WITH_ZWJ.charCount());
+
+ // delete
+ final KeyEvent forwardDelEvent = KeyboardUtil.del();
+ assertTrue(EmojiCompat.handleOnKeyDown(mEditable, forwardDelEvent.getKeyCode(),
+ forwardDelEvent));
+
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmojiCount(2));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji(Emoji.EMOJI_FLAG));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji(Emoji.EMOJI_GENDER));
+
+ assertEquals(new TestString(Emoji.EMOJI_FLAG).append(Emoji.EMOJI_GENDER)
+ .withPrefix().withSuffix().toString(), mEditable.toString());
+ }
+
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/InitCallbackTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/InitCallbackTest.java
new file mode 100644
index 0000000..118d823
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/InitCallbackTest.java
@@ -0,0 +1,168 @@
+/*
+ * 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 org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import androidx.annotation.NonNull;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.MediumTest;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class InitCallbackTest {
+
+ @Test
+ public void testRegisterInitCallback_callsSuccessCallback() {
+ final EmojiCompat.InitCallback initCallback1 = mock(EmojiCompat.InitCallback.class);
+ final EmojiCompat.InitCallback initCallback2 = mock(EmojiCompat.InitCallback.class);
+
+ final EmojiCompat.Config config = TestConfigBuilder.config();
+ final EmojiCompat emojiCompat = EmojiCompat.reset(config);
+ emojiCompat.registerInitCallback(initCallback1);
+ emojiCompat.registerInitCallback(initCallback2);
+
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ verify(initCallback1, times(1)).onInitialized();
+ verify(initCallback2, times(1)).onInitialized();
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testRegisterInitCallback_callsFailCallback() {
+ final EmojiCompat.InitCallback initCallback1 = mock(EmojiCompat.InitCallback.class);
+ final EmojiCompat.InitCallback initCallback2 = mock(EmojiCompat.InitCallback.class);
+ final EmojiCompat.MetadataRepoLoader loader = mock(EmojiCompat.MetadataRepoLoader.class);
+ doThrow(new RuntimeException("")).when(loader)
+ .load(any(EmojiCompat.MetadataRepoLoaderCallback.class));
+
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(loader);
+ final EmojiCompat emojiCompat = EmojiCompat.reset(config);
+ emojiCompat.registerInitCallback(initCallback1);
+ emojiCompat.registerInitCallback(initCallback2);
+
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ verify(initCallback1, times(1)).onFailed(nullable(Throwable.class));
+ verify(initCallback2, times(1)).onFailed(nullable(Throwable.class));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testRegisterInitCallback_callsFailCallback_whenOnFailCalledByLoader() {
+ final EmojiCompat.InitCallback initCallback = mock(EmojiCompat.InitCallback.class);
+ final EmojiCompat.MetadataRepoLoader loader = new EmojiCompat.MetadataRepoLoader() {
+ @Override
+ public void load(@NonNull EmojiCompat.MetadataRepoLoaderCallback loaderCallback) {
+ loaderCallback.onFailed(new RuntimeException(""));
+ }
+ };
+
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(loader);
+ final EmojiCompat emojiCompat = EmojiCompat.reset(config);
+ emojiCompat.registerInitCallback(initCallback);
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ verify(initCallback, times(1)).onFailed(nullable(Throwable.class));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testRegisterInitCallback_callsFailCallback_whenMetadataRepoIsNull() {
+ final EmojiCompat.InitCallback initCallback = mock(EmojiCompat.InitCallback.class);
+ final EmojiCompat.MetadataRepoLoader loader = new EmojiCompat.MetadataRepoLoader() {
+ @Override
+ public void load(@NonNull EmojiCompat.MetadataRepoLoaderCallback loaderCallback) {
+ loaderCallback.onLoaded(null);
+ }
+ };
+
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(loader);
+ final EmojiCompat emojiCompat = EmojiCompat.reset(config);
+ emojiCompat.registerInitCallback(initCallback);
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ verify(initCallback, times(1)).onFailed(nullable(Throwable.class));
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testUnregisterInitCallback_doesNotInteractWithCallback()
+ throws InterruptedException {
+ // will be registered
+ final EmojiCompat.InitCallback callback = mock(EmojiCompat.InitCallback.class);
+ // will be registered, and then unregistered before metadata load is complete
+ final EmojiCompat.InitCallback callbackUnregister = mock(EmojiCompat.InitCallback.class);
+ // will be registered to config
+ final EmojiCompat.InitCallback callbackConfigUnregister = mock(
+ EmojiCompat.InitCallback.class);
+ // will be registered to config and then unregistered
+ final EmojiCompat.InitCallback callbackConfig = mock(EmojiCompat.InitCallback.class);
+
+ //make sure that loader does not load before unregister
+ final TestConfigBuilder.WaitingDataLoader
+ metadataLoader = new TestConfigBuilder.WaitingDataLoader(false/*fail*/);
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(metadataLoader)
+ .registerInitCallback(callbackConfig)
+ .registerInitCallback(callbackConfigUnregister)
+ .unregisterInitCallback(callbackConfigUnregister);
+ final EmojiCompat emojiCompat = EmojiCompat.reset(config);
+ // register before metadata is loaded
+ emojiCompat.registerInitCallback(callbackUnregister);
+ emojiCompat.registerInitCallback(callback);
+
+ // unregister before metadata is loaded
+ emojiCompat.unregisterInitCallback(callbackUnregister);
+
+ // fire metadata loaded event
+ metadataLoader.getLoaderLatch().countDown();
+ metadataLoader.getTestLatch().await();
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ verify(callbackUnregister, times(0)).onFailed(any(Throwable.class));
+ verify(callbackConfigUnregister, times(0)).onFailed(nullable(Throwable.class));
+ verify(callback, times(1)).onFailed(nullable(Throwable.class));
+ verify(callbackConfig, times(1)).onFailed(nullable(Throwable.class));
+ }
+
+ @Test
+ public void testInitCallback_addedToConfigAndInstance_callsSuccess() {
+ final EmojiCompat.InitCallback initCallback1 = mock(EmojiCompat.InitCallback.class);
+ final EmojiCompat.InitCallback initCallback2 = mock(EmojiCompat.InitCallback.class);
+
+ final EmojiCompat.Config config = TestConfigBuilder.config()
+ .registerInitCallback(initCallback1);
+ final EmojiCompat emojiCompat = EmojiCompat.reset(config);
+ emojiCompat.registerInitCallback(initCallback2);
+
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ verify(initCallback1, times(1)).onInitialized();
+ verify(initCallback2, times(1)).onInitialized();
+ }
+
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/MetadataRepoTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/MetadataRepoTest.java
new file mode 100644
index 0000000..cead30d
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/MetadataRepoTest.java
@@ -0,0 +1,107 @@
+/*
+ * 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 org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+@SdkSuppress(minSdkVersion = 19)
+public class MetadataRepoTest {
+
+ MetadataRepo mMetadataRepo;
+
+ @Before
+ public void clearResourceIndex() {
+ mMetadataRepo = new MetadataRepo();
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testPut_withNullMetadata() {
+ mMetadataRepo.put(null);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testPut_withEmptyKeys() {
+ mMetadataRepo.put(new TestEmojiMetadata(new int[0]));
+ }
+
+ @Test
+ public void testPut_withSingleCodePointMapping() {
+ final int[] codePoint = new int[]{1};
+ final TestEmojiMetadata metadata = new TestEmojiMetadata(codePoint);
+ mMetadataRepo.put(metadata);
+ assertSame(metadata, getNode(codePoint));
+ }
+
+ @Test
+ public void testPut_withMultiCodePointsMapping() {
+ final int[] codePoint = new int[]{1, 2, 3, 4};
+ final TestEmojiMetadata metadata = new TestEmojiMetadata(codePoint);
+ mMetadataRepo.put(metadata);
+ assertSame(metadata, getNode(codePoint));
+
+ assertEquals(null, getNode(new int[]{1}));
+ assertEquals(null, getNode(new int[]{1, 2}));
+ assertEquals(null, getNode(new int[]{1, 2, 3}));
+ assertEquals(null, getNode(new int[]{1, 2, 3, 5}));
+ }
+
+ @Test
+ public void testPut_sequentialCodePoints() {
+ final int[] codePoint1 = new int[]{1, 2, 3, 4};
+ final EmojiMetadata metadata1 = new TestEmojiMetadata(codePoint1);
+
+ final int[] codePoint2 = new int[]{1, 2, 3};
+ final EmojiMetadata metadata2 = new TestEmojiMetadata(codePoint2);
+
+ final int[] codePoint3 = new int[]{1, 2};
+ final EmojiMetadata metadata3 = new TestEmojiMetadata(codePoint3);
+
+ mMetadataRepo.put(metadata1);
+ mMetadataRepo.put(metadata2);
+ mMetadataRepo.put(metadata3);
+
+ assertSame(metadata1, getNode(codePoint1));
+ assertSame(metadata2, getNode(codePoint2));
+ assertSame(metadata3, getNode(codePoint3));
+
+ assertEquals(null, getNode(new int[]{1}));
+ assertEquals(null, getNode(new int[]{1, 2, 3, 4, 5}));
+ }
+
+ final EmojiMetadata getNode(final int[] codepoints) {
+ return getNode(mMetadataRepo.getRootNode(), codepoints, 0);
+ }
+
+ final EmojiMetadata getNode(MetadataRepo.Node node, final int[] codepoints, int start) {
+ if (codepoints.length < start) return null;
+ if (codepoints.length == start) return node.getData();
+
+ final MetadataRepo.Node childNode = node.get(codepoints[start]);
+ if (childNode == null) return null;
+ return getNode(childNode, codepoints, start + 1);
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/SoftDeleteTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/SoftDeleteTest.java
new file mode 100644
index 0000000..9890903
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/SoftDeleteTest.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 static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import android.annotation.SuppressLint;
+import android.text.Editable;
+import android.text.Selection;
+import android.text.SpannableStringBuilder;
+import android.view.inputmethod.InputConnection;
+
+import androidx.emoji2.util.Emoji;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.emoji2.util.TestString;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.hamcrest.MatcherAssert;
+import org.hamcrest.core.IsNot;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+@SdkSuppress(minSdkVersion = 19)
+public class SoftDeleteTest {
+ private InputConnection mInputConnection;
+ private TestString mTestString;
+ private Editable mEditable;
+
+ @BeforeClass
+ public static void setupEmojiCompat() {
+ EmojiCompat.reset(TestConfigBuilder.config());
+ }
+
+ @Before
+ public void setup() {
+ mInputConnection = mock(InputConnection.class);
+ mTestString = new TestString(Emoji.EMOJI_WITH_ZWJ).withPrefix().withSuffix();
+ mEditable = new SpannableStringBuilder(mTestString.toString());
+ EmojiCompat.get().process(mEditable);
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmojiCount(1));
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji(Emoji.EMOJI_WITH_ZWJ));
+ }
+
+ @Test
+ public void testDelete_doesNotDelete_whenSelectionIsUndefined() {
+ // no selection is set on editable
+ assertFalse(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 1 /*beforeLength*/, 0 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji(Emoji.EMOJI_WITH_ZWJ));
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_doesNotDelete_whenThereIsSelectionLongerThanZero() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex(),
+ mTestString.emojiEndIndex() + 1);
+
+ assertFalse(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 1 /*beforeLength*/, 0 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji(Emoji.EMOJI_WITH_ZWJ));
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_withNullEditable() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+
+ assertFalse(EmojiCompat.handleDeleteSurroundingText(mInputConnection, null,
+ 1 /*beforeLength*/, 0 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji(Emoji.EMOJI_WITH_ZWJ));
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_withNullInputConnection() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+
+ assertFalse(EmojiCompat.handleDeleteSurroundingText(null, mEditable,
+ 1 /*beforeLength*/, 0 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji(Emoji.EMOJI_WITH_ZWJ));
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @SuppressLint("Range")
+ @Test
+ public void testDelete_withInvalidLength() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+
+ assertFalse(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ -1 /*beforeLength*/, 0 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji(Emoji.EMOJI_WITH_ZWJ));
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @SuppressLint("Range")
+ @Test
+ public void testDelete_withInvalidAfterLength() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+
+ assertFalse(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 0 /*beforeLength*/, -1 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji(Emoji.EMOJI_WITH_ZWJ));
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_backward() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+
+ // backwards delete 1 character, it will delete the emoji
+ assertTrue(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 1 /*beforeLength*/, 0 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, IsNot.not(EmojiMatcher.hasEmoji()));
+ assertEquals(new TestString().withPrefix().withSuffix().toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_backward_inCodepoints() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+
+ // backwards delete 1 character, it will delete the emoji
+ assertTrue(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 1 /*beforeLength*/, 0 /*afterLength*/, true /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, IsNot.not(EmojiMatcher.hasEmoji()));
+ assertEquals(new TestString().withPrefix().withSuffix().toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_forward() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex());
+
+ // forward delete 1 character, it will dele the emoji.
+ assertTrue(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 0 /*beforeLength*/, 1 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, IsNot.not(EmojiMatcher.hasEmoji()));
+ assertEquals(new TestString().withPrefix().withSuffix().toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_forward_inCodepoints() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex());
+
+ // forward delete 1 codepoint, it will delete the emoji.
+ assertTrue(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 0 /*beforeLength*/, 1 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, IsNot.not(EmojiMatcher.hasEmoji()));
+ assertEquals(new TestString().withPrefix().withSuffix().toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_backward_doesNotDeleteWhenSelectionAtCharSequenceStart() {
+ // make sure selection at 0 does not do something weird for backward delete
+ Selection.setSelection(mEditable, 0);
+
+ assertFalse(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 1 /*beforeLength*/, 0 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_forward_doesNotDeleteWhenSelectionAtCharSequenceEnd() {
+ // make sure selection at end does not do something weird for forward delete
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+
+ assertFalse(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 0 /*beforeLength*/, 1 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+ assertEquals(mTestString.toString(), mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_withMultipleCharacters() {
+ // prepare string as abc[emoji]def
+ mTestString = new TestString(Emoji.EMOJI_FLAG);
+ mEditable = new SpannableStringBuilder("abc" + mTestString.toString() + "def");
+ EmojiCompat.get().process(mEditable);
+
+ // set the selection in the middle of emoji
+ Selection.setSelection(mEditable, "abc".length() + Emoji.EMOJI_FLAG.charCount() / 2);
+
+ // delete 4 characters forward, 4 character backwards
+ assertTrue(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 4 /*beforeLength*/, 4 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, IsNot.not(EmojiMatcher.hasEmoji()));
+ assertEquals("af", mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_withMultipleCodepoints() {
+ // prepare string as abc[emoji]def
+ mTestString = new TestString(Emoji.EMOJI_FLAG);
+ mEditable = new SpannableStringBuilder("abc" + mTestString.toString() + "def");
+ EmojiCompat.get().process(mEditable);
+
+ // set the selection in the middle of emoji
+ Selection.setSelection(mEditable, "abc".length() + Emoji.EMOJI_FLAG.charCount() / 2);
+
+ // delete 3 codepoints forward, 3 codepoints backwards
+ assertTrue(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 3 /*beforeLength*/, 3 /*afterLength*/, true /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, IsNot.not(EmojiMatcher.hasEmoji()));
+ assertEquals("af", mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_withMultipleCharacters_withDeleteLengthLongerThanString() {
+ // prepare string as abc[emoji]def
+ mTestString = new TestString(Emoji.EMOJI_FLAG);
+ mEditable = new SpannableStringBuilder("abc" + mTestString.toString() + "def");
+ EmojiCompat.get().process(mEditable);
+
+ // set the selection in the middle of emoji
+ Selection.setSelection(mEditable, "abc".length() + Emoji.EMOJI_FLAG.charCount() / 2);
+
+ assertTrue(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 100 /*beforeLength*/, 100 /*afterLength*/, false /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, IsNot.not(EmojiMatcher.hasEmoji()));
+ assertEquals("", mEditable.toString());
+ }
+
+ @Test
+ public void testDelete_withMultipleCodepoints_withDeleteLengthLongerThanString() {
+ // prepare string as abc[emoji]def
+ mTestString = new TestString(Emoji.EMOJI_FLAG);
+ mEditable = new SpannableStringBuilder("abc" + mTestString.toString() + "def");
+ EmojiCompat.get().process(mEditable);
+
+ // set the selection in the middle of emoji
+ Selection.setSelection(mEditable, "abc".length() + Emoji.EMOJI_FLAG.charCount() / 2);
+
+ assertTrue(EmojiCompat.handleDeleteSurroundingText(mInputConnection, mEditable,
+ 100 /*beforeLength*/, 100 /*afterLength*/, true /*inCodePoints*/));
+
+ MatcherAssert.assertThat(mEditable, IsNot.not(EmojiMatcher.hasEmoji()));
+ assertEquals("", mEditable.toString());
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/TestActivity.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/TestActivity.java
new file mode 100644
index 0000000..bae3630
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/TestActivity.java
@@ -0,0 +1,30 @@
+/*
+ * 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.app.Activity;
+import android.os.Bundle;
+
+import androidx.emoji2.test.R;
+
+public class TestActivity extends Activity {
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_default);
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/TestConfigBuilder.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/TestConfigBuilder.java
new file mode 100644
index 0000000..6753840
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/TestConfigBuilder.java
@@ -0,0 +1,155 @@
+/*
+ * 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 org.junit.Assert.fail;
+
+import android.content.Context;
+import android.content.res.AssetManager;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.test.core.app.ApplicationProvider;
+
+import java.util.concurrent.CountDownLatch;
+
+public class TestConfigBuilder {
+ private static final String FONT_FILE = "NotoColorEmojiCompat.ttf";
+
+ private TestConfigBuilder() { }
+
+ public static EmojiCompat.Config config() {
+ return new TestConfig().setReplaceAll(true);
+ }
+
+ /**
+ * Forces the creation of Metadata instead of relying on cached metadata. If GlyphChecker is
+ * mocked, a new metadata has to be used instead of the statically cached metadata since the
+ * result of GlyphChecker on the same device might effect other tests.
+ */
+ public static EmojiCompat.Config freshConfig() {
+ return new TestConfig(new ResettingTestDataLoader()).setReplaceAll(true);
+ }
+
+ public static class TestConfig extends EmojiCompat.Config {
+ TestConfig() {
+ super(new TestEmojiDataLoader());
+ }
+
+ TestConfig(final EmojiCompat.MetadataRepoLoader metadataLoader) {
+ super(metadataLoader);
+ }
+ }
+
+ public static class WaitingDataLoader implements EmojiCompat.MetadataRepoLoader {
+ private final CountDownLatch mLoaderLatch;
+ private final CountDownLatch mTestLatch;
+ private final boolean mSuccess;
+
+ public WaitingDataLoader(boolean success) {
+ mLoaderLatch = new CountDownLatch(1);
+ mTestLatch = new CountDownLatch(1);
+ mSuccess = success;
+ }
+
+ public WaitingDataLoader() {
+ this(true);
+ }
+
+ public CountDownLatch getLoaderLatch() {
+ return mLoaderLatch;
+ }
+
+ public CountDownLatch getTestLatch() {
+ return mTestLatch;
+ }
+
+ @Override
+ public void load(@NonNull final EmojiCompat.MetadataRepoLoaderCallback loaderCallback) {
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ mLoaderLatch.await();
+ if (mSuccess) {
+ loaderCallback.onLoaded(new MetadataRepo());
+ } else {
+ loaderCallback.onFailed(null);
+ }
+
+ mTestLatch.countDown();
+ } catch (Throwable e) {
+ fail();
+ }
+ }
+ }).start();
+ }
+ }
+
+ public static class TestEmojiDataLoader implements EmojiCompat.MetadataRepoLoader {
+ static final Object S_METADATA_REPO_LOCK = new Object();
+ // keep a static instance to in order not to slow down the tests
+ @GuardedBy("sMetadataRepoLock")
+ static volatile MetadataRepo sMetadataRepo;
+
+ TestEmojiDataLoader() {
+ }
+
+ @Override
+ public void load(@NonNull EmojiCompat.MetadataRepoLoaderCallback loaderCallback) {
+ if (sMetadataRepo == null) {
+ synchronized (S_METADATA_REPO_LOCK) {
+ if (sMetadataRepo == null) {
+ try {
+ final Context context = ApplicationProvider.getApplicationContext();
+ final AssetManager assetManager = context.getAssets();
+ sMetadataRepo = MetadataRepo.create(assetManager, FONT_FILE);
+ } catch (Throwable e) {
+ loaderCallback.onFailed(e);
+ throw new RuntimeException(e);
+ }
+ }
+ }
+ }
+
+ loaderCallback.onLoaded(sMetadataRepo);
+ }
+ }
+
+ public static class ResettingTestDataLoader implements EmojiCompat.MetadataRepoLoader {
+ private MetadataRepo mMetadataRepo;
+
+ ResettingTestDataLoader() {
+ }
+
+ @Override
+ public void load(@NonNull EmojiCompat.MetadataRepoLoaderCallback loaderCallback) {
+ if (mMetadataRepo == null) {
+ try {
+ final Context context = ApplicationProvider.getApplicationContext();
+ final AssetManager assetManager = context.getAssets();
+ mMetadataRepo = MetadataRepo.create(assetManager, FONT_FILE);
+ } catch (Throwable e) {
+ loaderCallback.onFailed(e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ loaderCallback.onLoaded(mMetadataRepo);
+ }
+ }
+
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/TestEmojiMetadata.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/TestEmojiMetadata.java
new file mode 100644
index 0000000..ec94608
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/TestEmojiMetadata.java
@@ -0,0 +1,49 @@
+/*
+ * 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.RequiresApi;
+
+@RequiresApi(19)
+public class TestEmojiMetadata extends EmojiMetadata {
+ private final int[] mCodePoints;
+ private int mId;
+
+ TestEmojiMetadata(int[] codePoints, int id) {
+ super(null, 0);
+ mCodePoints = codePoints;
+ mId = id;
+ }
+
+ TestEmojiMetadata(int[] codePoints) {
+ this(codePoints, 0);
+ }
+
+ @Override
+ public int getId() {
+ return mId;
+ }
+
+ @Override
+ public int getCodepointAt(int index) {
+ return mCodePoints[index];
+ }
+
+ @Override
+ public int getCodepointsLength() {
+ return mCodePoints.length;
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/UninitializedStateTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/UninitializedStateTest.java
new file mode 100644
index 0000000..6cc85cd
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/text/UninitializedStateTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+@SdkSuppress(minSdkVersion = 19)
+public class UninitializedStateTest {
+
+ private TestConfigBuilder.WaitingDataLoader mWaitingDataLoader;
+
+ @Before
+ public void setup() {
+ mWaitingDataLoader = new TestConfigBuilder.WaitingDataLoader(true);
+ final EmojiCompat.Config config = new TestConfigBuilder.TestConfig(mWaitingDataLoader);
+ EmojiCompat.reset(config);
+ }
+
+ @After
+ public void after() {
+ mWaitingDataLoader.getLoaderLatch().countDown();
+ mWaitingDataLoader.getTestLatch().countDown();
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testHasEmojiGlyph() {
+ EmojiCompat.get().hasEmojiGlyph("anystring");
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testHasEmojiGlyph_withMetadataVersion() {
+ EmojiCompat.get().hasEmojiGlyph("anystring", 1);
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testProcess() {
+ EmojiCompat.get().process("anystring");
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testProcess_withStartEnd() {
+ EmojiCompat.get().process("anystring", 1, 2);
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/util/Emoji.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/util/Emoji.java
new file mode 100644
index 0000000..73117a2
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/util/Emoji.java
@@ -0,0 +1,110 @@
+/*
+ * 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.util;
+
+import androidx.annotation.NonNull;
+
+public class Emoji {
+
+ public static final int CHAR_KEYCAP = 0x20E3;
+ public static final int CHAR_DIGIT = 0x0039;
+ public static final int CHAR_ZWJ = 0x200D;
+ public static final int CHAR_VS_EMOJI = 0xFE0f;
+ public static final int CHAR_VS_TEXT = 0xFE0E;
+ public static final int CHAR_FITZPATRICK = 0x1F3FE;
+ public static final int CHAR_FITZPATRICK_TYPE_1 = 0x1F3fB;
+ public static final int CHAR_DEFAULT_TEXT_STYLE = 0x26F9;
+ public static final int CHAR_DEFAULT_EMOJI_STYLE = 0x1f3A2;
+ public static final int CHAR_FEMALE_SIGN = 0x2640;
+ public static final int CHAR_MAN = 0x1F468;
+ public static final int CHAR_HEART = 0x2764;
+ public static final int CHAR_KISS = 0x1F48B;
+ public static final int CHAR_REGIONAL_SYMBOL = 0x1F1E8;
+ public static final int CHAR_ASTERISK = 0x002A;
+
+ public static final EmojiMapping EMOJI_SINGLE_CODEPOINT = new EmojiMapping(
+ new int[]{CHAR_DEFAULT_EMOJI_STYLE}, 0xF01B4);
+
+ public static final EmojiMapping EMOJI_WITH_ZWJ = new EmojiMapping(
+ new int[]{CHAR_MAN, CHAR_ZWJ, CHAR_HEART, CHAR_VS_EMOJI, CHAR_ZWJ, CHAR_KISS, CHAR_ZWJ,
+ CHAR_MAN}, 0xF051F);
+
+ public static final EmojiMapping EMOJI_GENDER = new EmojiMapping(new int[]{
+ CHAR_DEFAULT_TEXT_STYLE, CHAR_VS_EMOJI, CHAR_ZWJ, CHAR_FEMALE_SIGN}, 0xF0950);
+
+ public static final EmojiMapping EMOJI_FLAG = new EmojiMapping(
+ new int[]{CHAR_REGIONAL_SYMBOL, CHAR_REGIONAL_SYMBOL}, 0xF03A0);
+
+ public static final EmojiMapping EMOJI_GENDER_WITHOUT_VS = new EmojiMapping(
+ new int[]{CHAR_DEFAULT_TEXT_STYLE, CHAR_ZWJ, CHAR_FEMALE_SIGN}, 0xF0950);
+
+ public static final EmojiMapping DEFAULT_TEXT_STYLE = new EmojiMapping(
+ new int[]{CHAR_DEFAULT_TEXT_STYLE, CHAR_VS_EMOJI}, 0xF04C6);
+
+ public static final EmojiMapping EMOJI_REGIONAL_SYMBOL = new EmojiMapping(
+ new int[]{CHAR_REGIONAL_SYMBOL}, 0xF0025);
+
+ public static final EmojiMapping EMOJI_UNKNOWN_FLAG = new EmojiMapping(
+ new int[]{0x1F1FA, 0x1F1F3}, 0xF0599);
+
+ public static final EmojiMapping EMOJI_DIGIT_ES = new EmojiMapping(
+ new int[]{CHAR_DIGIT, CHAR_VS_EMOJI}, 0xF0340);
+
+ public static final EmojiMapping EMOJI_DIGIT_KEYCAP = new EmojiMapping(
+ new int[]{CHAR_DIGIT, CHAR_KEYCAP}, 0xF0377);
+
+ public static final EmojiMapping EMOJI_DIGIT_ES_KEYCAP = new EmojiMapping(
+ new int[]{CHAR_DIGIT, CHAR_VS_EMOJI, CHAR_KEYCAP}, 0xF0377);
+
+ public static final EmojiMapping EMOJI_ASTERISK_KEYCAP = new EmojiMapping(
+ new int[]{CHAR_ASTERISK, CHAR_VS_EMOJI, CHAR_KEYCAP}, 0xF051D);
+
+ public static final EmojiMapping EMOJI_SKIN_MODIFIER = new EmojiMapping(
+ new int[]{CHAR_MAN, CHAR_FITZPATRICK}, 0xF0603);
+
+ public static final EmojiMapping EMOJI_SKIN_MODIFIER_TYPE_ONE = new EmojiMapping(
+ new int[]{CHAR_MAN, CHAR_FITZPATRICK_TYPE_1}, 0xF0606);
+
+ public static final EmojiMapping EMOJI_SKIN_MODIFIER_WITH_VS = new EmojiMapping(
+ new int[]{CHAR_MAN, CHAR_VS_EMOJI, CHAR_FITZPATRICK_TYPE_1}, 0xF0606);
+
+ public static class EmojiMapping {
+ private final int[] mCodepoints;
+ private final int mId;
+
+ private EmojiMapping(@NonNull final int[] codepoints, final int id) {
+ mCodepoints = codepoints;
+ mId = id;
+ }
+
+ public final int[] codepoints() {
+ return mCodepoints;
+ }
+
+ public final int id() {
+ return mId;
+ }
+
+ public final int charCount() {
+ int count = 0;
+ for (int i = 0; i < mCodepoints.length; i++) {
+ count += Character.charCount(mCodepoints[i]);
+ }
+ return count;
+ }
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/util/EmojiMatcher.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/util/EmojiMatcher.java
new file mode 100644
index 0000000..32d8e03
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/util/EmojiMatcher.java
@@ -0,0 +1,257 @@
+/*
+ * 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.util;
+
+import static org.mockito.ArgumentMatchers.argThat;
+
+import android.text.Spanned;
+import android.text.TextUtils;
+
+import androidx.emoji2.text.EmojiSpan;
+
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+import org.mockito.ArgumentMatcher;
+
+/**
+ * Utility class that includes matchers specific to emojis and EmojiSpans.
+ */
+public class EmojiMatcher {
+
+ public static Matcher<CharSequence> hasEmojiAt(final int id, final int start,
+ final int end) {
+ return new EmojiResourceMatcher(id, start, end);
+ }
+
+ public static Matcher<CharSequence> hasEmojiAt(final Emoji.EmojiMapping emojiMapping,
+ final int start, final int end) {
+ return new EmojiResourceMatcher(emojiMapping.id(), start, end);
+ }
+
+ public static Matcher<CharSequence> hasEmojiAt(final int start, final int end) {
+ return new EmojiResourceMatcher(-1, start, end);
+ }
+
+ public static Matcher<CharSequence> hasEmoji(final int id) {
+ return new EmojiResourceMatcher(id, -1, -1);
+ }
+
+ public static Matcher<CharSequence> hasEmoji(final Emoji.EmojiMapping emojiMapping) {
+ return new EmojiResourceMatcher(emojiMapping.id(), -1, -1);
+ }
+
+ public static Matcher<CharSequence> hasEmoji() {
+ return new EmojiSpanMatcher();
+ }
+
+ public static Matcher<CharSequence> hasEmojiCount(final int count) {
+ return new EmojiCountMatcher(count);
+ }
+
+ public static <T extends CharSequence> T sameCharSequence(final T expected) {
+ return argThat(new ArgumentMatcher<T>() {
+ @Override
+ public boolean matches(T o) {
+ if (o instanceof CharSequence) {
+ return TextUtils.equals(expected, o);
+ }
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return "doesn't match " + expected;
+ }
+ });
+ }
+
+ private static class EmojiSpanMatcher extends TypeSafeMatcher<CharSequence> {
+
+ private EmojiSpan[] mSpans;
+
+ EmojiSpanMatcher() {
+ }
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("should have EmojiSpans");
+ }
+
+ @Override
+ protected void describeMismatchSafely(final CharSequence charSequence,
+ Description mismatchDescription) {
+ mismatchDescription.appendText(" has no EmojiSpans");
+ }
+
+ @Override
+ protected boolean matchesSafely(final CharSequence charSequence) {
+ if (charSequence == null) return false;
+ if (!(charSequence instanceof Spanned)) return false;
+ mSpans = ((Spanned) charSequence).getSpans(0, charSequence.length(), EmojiSpan.class);
+ return mSpans.length != 0;
+ }
+ }
+
+ private static class EmojiCountMatcher extends TypeSafeMatcher<CharSequence> {
+
+ private final int mCount;
+ private EmojiSpan[] mSpans;
+
+ EmojiCountMatcher(final int count) {
+ mCount = count;
+ }
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("should have ").appendValue(mCount).appendText(" EmojiSpans");
+ }
+
+ @Override
+ protected void describeMismatchSafely(final CharSequence charSequence,
+ Description mismatchDescription) {
+ mismatchDescription.appendText(" has ");
+ if (mSpans == null) {
+ mismatchDescription.appendValue("no");
+ } else {
+ mismatchDescription.appendValue(mSpans.length);
+ }
+
+ mismatchDescription.appendText(" EmojiSpans");
+ }
+
+ @Override
+ protected boolean matchesSafely(final CharSequence charSequence) {
+ if (charSequence == null) return false;
+ if (!(charSequence instanceof Spanned)) return false;
+ mSpans = ((Spanned) charSequence).getSpans(0, charSequence.length(), EmojiSpan.class);
+ return mSpans.length == mCount;
+ }
+ }
+
+ private static class EmojiResourceMatcher extends TypeSafeMatcher<CharSequence> {
+ private static final int ERR_NONE = 0;
+ private static final int ERR_SPANNABLE_NULL = 1;
+ private static final int ERR_NO_SPANS = 2;
+ private static final int ERR_WRONG_INDEX = 3;
+ private final int mResId;
+ private final int mStart;
+ private final int mEnd;
+ private int mError = ERR_NONE;
+ private int mActualStart = -1;
+ private int mActualEnd = -1;
+
+ EmojiResourceMatcher(int resId, int start, int end) {
+ mResId = resId;
+ mStart = start;
+ mEnd = end;
+ }
+
+ @Override
+ public void describeTo(final Description description) {
+ if (mResId == -1) {
+ description.appendText("should have EmojiSpan at ")
+ .appendValue("[" + mStart + "," + mEnd + "]");
+ } else if (mStart == -1 && mEnd == -1) {
+ description.appendText("should have EmojiSpan with resource id ")
+ .appendValue(Integer.toHexString(mResId));
+ } else {
+ description.appendText("should have EmojiSpan with resource id ")
+ .appendValue(Integer.toHexString(mResId))
+ .appendText(" at ")
+ .appendValue("[" + mStart + "," + mEnd + "]");
+ }
+ }
+
+ @Override
+ protected void describeMismatchSafely(final CharSequence charSequence,
+ Description mismatchDescription) {
+ int offset = 0;
+ mismatchDescription.appendText("[");
+ while (offset < charSequence.length()) {
+ int codepoint = Character.codePointAt(charSequence, offset);
+ mismatchDescription.appendText(Integer.toHexString(codepoint));
+ offset += Character.charCount(codepoint);
+ if (offset < charSequence.length()) {
+ mismatchDescription.appendText(",");
+ }
+ }
+ mismatchDescription.appendText("]");
+
+ switch (mError) {
+ case ERR_NO_SPANS:
+ mismatchDescription.appendText(" had no spans");
+ break;
+ case ERR_SPANNABLE_NULL:
+ mismatchDescription.appendText(" was null");
+ break;
+ case ERR_WRONG_INDEX:
+ mismatchDescription.appendText(" had Emoji at ")
+ .appendValue("[" + mActualStart + "," + mActualEnd + "]");
+ break;
+ default:
+ mismatchDescription.appendText(" does not have an EmojiSpan with given "
+ + "resource id ");
+ }
+ }
+
+ @Override
+ protected boolean matchesSafely(final CharSequence charSequence) {
+ if (charSequence == null) {
+ mError = ERR_SPANNABLE_NULL;
+ return false;
+ }
+
+ if (!(charSequence instanceof Spanned)) {
+ mError = ERR_NO_SPANS;
+ return false;
+ }
+
+ Spanned spanned = (Spanned) charSequence;
+ final EmojiSpan[] spans = spanned.getSpans(0, charSequence.length(), EmojiSpan.class);
+
+ if (spans.length == 0) {
+ mError = ERR_NO_SPANS;
+ return false;
+ }
+
+ if (mStart == -1 && mEnd == -1) {
+ for (int index = 0; index < spans.length; index++) {
+ if (mResId == spans[index].getId()) {
+ return true;
+ }
+ }
+ return false;
+ } else {
+ for (int index = 0; index < spans.length; index++) {
+ if (mResId == -1 || mResId == spans[index].getId()) {
+ mActualStart = spanned.getSpanStart(spans[index]);
+ mActualEnd = spanned.getSpanEnd(spans[index]);
+ if (mActualStart == mStart && mActualEnd == mEnd) {
+ return true;
+ }
+ }
+ }
+
+ if (mActualStart != -1 && mActualEnd != -1) {
+ mError = ERR_WRONG_INDEX;
+ }
+
+ return false;
+ }
+ }
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/util/KeyboardUtil.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/util/KeyboardUtil.java
new file mode 100644
index 0000000..48a6d2e
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/util/KeyboardUtil.java
@@ -0,0 +1,147 @@
+/*
+ * 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.util;
+
+import android.app.Instrumentation;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.method.QwertyKeyListener;
+import android.text.method.TextKeyListener;
+import android.view.KeyEvent;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.TextView;
+
+import java.util.concurrent.CountDownLatch;
+
+/**
+ * Utility class for KeyEvents
+ */
+public class KeyboardUtil {
+ private static final int ALT = KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON;
+ private static final int CTRL = KeyEvent.META_CTRL_ON | KeyEvent.META_CTRL_LEFT_ON;
+ private static final int SHIFT = KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_LEFT_ON;
+ private static final int FN = KeyEvent.META_FUNCTION_ON;
+
+ public static KeyEvent zero() {
+ return keyEvent(KeyEvent.KEYCODE_0);
+ }
+
+ public static KeyEvent del() {
+ return keyEvent(KeyEvent.KEYCODE_DEL);
+ }
+
+ public static KeyEvent altDel() {
+ return keyEvent(KeyEvent.KEYCODE_DEL, ALT);
+ }
+
+ public static KeyEvent ctrlDel() {
+ return keyEvent(KeyEvent.KEYCODE_DEL, CTRL);
+ }
+
+ public static KeyEvent shiftDel() {
+ return keyEvent(KeyEvent.KEYCODE_DEL, SHIFT);
+ }
+
+ public static KeyEvent fnDel() {
+ return keyEvent(KeyEvent.KEYCODE_DEL, FN);
+ }
+
+ public static KeyEvent forwardDel() {
+ return keyEvent(KeyEvent.KEYCODE_FORWARD_DEL);
+ }
+
+ public static KeyEvent keyEvent(int keycode, int metaState) {
+ final long currentTime = System.currentTimeMillis();
+ return new KeyEvent(currentTime, currentTime, KeyEvent.ACTION_DOWN, keycode, 0, metaState);
+ }
+
+ public static KeyEvent keyEvent(int keycode) {
+ final long currentTime = System.currentTimeMillis();
+ return new KeyEvent(currentTime, currentTime, KeyEvent.ACTION_DOWN, keycode, 0);
+ }
+
+ public static void setComposingTextInBatch(final Instrumentation instrumentation,
+ final InputConnection inputConnection, final CharSequence text)
+ throws InterruptedException {
+ final CountDownLatch latch = new CountDownLatch(1);
+ instrumentation.runOnMainSync(new Runnable() {
+ @Override
+ public void run() {
+ inputConnection.beginBatchEdit();
+ inputConnection.setComposingText(text, 1);
+ inputConnection.endBatchEdit();
+ latch.countDown();
+ }
+ });
+
+ latch.await();
+ instrumentation.waitForIdleSync();
+ }
+
+ public static void deleteSurroundingText(final Instrumentation instrumentation,
+ final InputConnection inputConnection, final int before, final int after)
+ throws InterruptedException {
+ final CountDownLatch latch = new CountDownLatch(1);
+ instrumentation.runOnMainSync(new Runnable() {
+ @Override
+ public void run() {
+ inputConnection.beginBatchEdit();
+ inputConnection.deleteSurroundingText(before, after);
+ inputConnection.endBatchEdit();
+ latch.countDown();
+ }
+ });
+ latch.await();
+ instrumentation.waitForIdleSync();
+ }
+
+ public static void setSelection(Instrumentation instrumentation, final Spannable spannable,
+ final int start) throws InterruptedException {
+ setSelection(instrumentation, spannable, start, start);
+ }
+
+ public static void setSelection(Instrumentation instrumentation, final Spannable spannable,
+ final int start, final int end) throws InterruptedException {
+ final CountDownLatch latch = new CountDownLatch(1);
+ instrumentation.runOnMainSync(new Runnable() {
+ @Override
+ public void run() {
+ Selection.setSelection(spannable, start, end);
+ latch.countDown();
+ }
+ });
+ latch.await();
+ instrumentation.waitForIdleSync();
+ }
+
+ public static InputConnection initTextViewForSimulatedIme(Instrumentation instrumentation,
+ final TextView textView) throws InterruptedException {
+ final CountDownLatch latch = new CountDownLatch(1);
+ instrumentation.runOnMainSync(new Runnable() {
+ @Override
+ public void run() {
+ textView.setKeyListener(
+ QwertyKeyListener.getInstance(false, TextKeyListener.Capitalize.NONE));
+ textView.setText("", TextView.BufferType.EDITABLE);
+ latch.countDown();
+ }
+ });
+ latch.await();
+ instrumentation.waitForIdleSync();
+ return textView.onCreateInputConnection(new EditorInfo());
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/util/TestString.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/util/TestString.java
new file mode 100644
index 0000000..83728da
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/util/TestString.java
@@ -0,0 +1,114 @@
+/*
+ * 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.util;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Utility class used to create strings with emojis during tests.
+ */
+public class TestString {
+
+ private static final List<Integer> EMPTY_LIST = new ArrayList<>();
+
+ private static final String EXTRA = "ab";
+ private final List<Integer> mCodePoints;
+ private String mString;
+ private final String mValue;
+ private boolean mHasSuffix;
+ private boolean mHasPrefix;
+
+ public TestString(int... codePoints) {
+ if (codePoints.length == 0) {
+ mCodePoints = EMPTY_LIST;
+ } else {
+ mCodePoints = new ArrayList<>();
+ append(codePoints);
+ }
+ mValue = null;
+ }
+
+ public TestString(Emoji.EmojiMapping emojiMapping) {
+ this(emojiMapping.codepoints());
+ }
+
+ public TestString(String string) {
+ mCodePoints = EMPTY_LIST;
+ mValue = string;
+ }
+
+ public TestString append(int... codePoints) {
+ for (int i = 0; i < codePoints.length; i++) {
+ mCodePoints.add(codePoints[i]);
+ }
+ return this;
+ }
+
+ public TestString prepend(int... codePoints) {
+ for (int i = codePoints.length - 1; i >= 0; i--) {
+ mCodePoints.add(0, codePoints[i]);
+ }
+ return this;
+ }
+
+ public TestString append(Emoji.EmojiMapping emojiMapping) {
+ return append(emojiMapping.codepoints());
+ }
+
+ public TestString withSuffix() {
+ mHasSuffix = true;
+ return this;
+ }
+
+ public TestString withPrefix() {
+ mHasPrefix = true;
+ return this;
+ }
+
+ @SuppressWarnings("ForLoopReplaceableByForEach")
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ if (mHasPrefix) {
+ builder.append(EXTRA);
+ }
+
+ for (int index = 0; index < mCodePoints.size(); index++) {
+ builder.append(Character.toChars(mCodePoints.get(index)));
+ }
+
+ if (mValue != null) {
+ builder.append(mValue);
+ }
+
+ if (mHasSuffix) {
+ builder.append(EXTRA);
+ }
+ mString = builder.toString();
+ return mString;
+ }
+
+ public int emojiStartIndex() {
+ if (mHasPrefix) return EXTRA.length();
+ return 0;
+ }
+
+ public int emojiEndIndex() {
+ if (mHasSuffix) return mString.lastIndexOf(EXTRA);
+ return mString.length();
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiEditTextHelperPre19Test.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiEditTextHelperPre19Test.java
new file mode 100644
index 0000000..2883836
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiEditTextHelperPre19Test.java
@@ -0,0 +1,99 @@
+/*
+ * 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.widget;
+
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.text.TextWatcher;
+import android.text.method.KeyListener;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.EditText;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+@SdkSuppress(maxSdkVersion = 18)
+public class EmojiEditTextHelperPre19Test {
+ EmojiEditTextHelper mEmojiEditTextHelper;
+
+ @Before
+ public void setup() {
+ final EditText editText = mock(EditText.class);
+ mEmojiEditTextHelper = new EmojiEditTextHelper(editText);
+ verifyNoMoreInteractions(editText);
+ }
+
+ @Test
+ public void testGetKeyListener_returnsSameKeyListener() {
+ final KeyListener param = mock(KeyListener.class);
+ final KeyListener keyListener = mEmojiEditTextHelper.getKeyListener(
+ param);
+
+ assertSame(param, keyListener);
+ }
+
+ @LargeTest
+ @Test
+ public void testGetOnCreateInputConnection_returnsSameInputConnection() {
+ final InputConnection param = mock(InputConnection.class);
+ final InputConnection inputConnection = mEmojiEditTextHelper.onCreateInputConnection(param,
+ new EditorInfo());
+
+ assertSame(param, inputConnection);
+ }
+
+ @Test
+ public void testGetOnCreateInputConnection_withNullAttrs_returnsSameInputConnection() {
+ final InputConnection param = mock(InputConnection.class);
+ final InputConnection inputConnection = mEmojiEditTextHelper.onCreateInputConnection(param,
+ null);
+
+ assertSame(param, inputConnection);
+ }
+
+ @Test
+ public void testGetOnCreateInputConnection_withNullInputConnection_returnsNull() {
+ final InputConnection inputConnection = mEmojiEditTextHelper.onCreateInputConnection(null,
+ new EditorInfo());
+ assertNull(inputConnection);
+ }
+
+ @Test
+ public void testDoesNotAttachTextWatcher() {
+ final EditText editText = mock(EditText.class);
+
+ mEmojiEditTextHelper = new EmojiEditTextHelper(editText);
+
+ verify(editText, times(0)).addTextChangedListener(any(TextWatcher.class));
+ }
+
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiEditTextHelperTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiEditTextHelperTest.java
new file mode 100644
index 0000000..f995697
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiEditTextHelperTest.java
@@ -0,0 +1,166 @@
+/*
+ * 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.widget;
+
+import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.text.TextWatcher;
+import android.text.method.KeyListener;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.EditText;
+
+import androidx.emoji2.text.EmojiCompat;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+
+@LargeTest
+@RunWith(AndroidJUnit4.class)
+@SdkSuppress(minSdkVersion = 19)
+public class EmojiEditTextHelperTest {
+ EmojiEditTextHelper mEmojiEditTextHelper;
+ EditText mEditText;
+
+ @Before
+ public void setup() {
+ EmojiCompat.reset(mock(EmojiCompat.class));
+ mEditText = new EditText(ApplicationProvider.getApplicationContext());
+ mEmojiEditTextHelper = new EmojiEditTextHelper(mEditText);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testGetKeyListener_withNull_throwsException() {
+ mEmojiEditTextHelper.getKeyListener(null);
+ }
+
+ @Test
+ public void testGetKeyListener_returnsEmojiKeyListener() {
+ final KeyListener keyListener = mEmojiEditTextHelper.getKeyListener(
+ mock(KeyListener.class));
+
+ assertThat(keyListener, instanceOf(EmojiKeyListener.class));
+ }
+
+ @Test
+ public void testGetKeyListener_doesNotCreateNewInstance() {
+ KeyListener mockKeyListener = mock(KeyListener.class);
+ final KeyListener keyListener1 = mEmojiEditTextHelper.getKeyListener(mockKeyListener);
+ final KeyListener keyListener2 = mEmojiEditTextHelper.getKeyListener(keyListener1);
+ assertSame(keyListener1, keyListener2);
+ }
+
+ @Test
+ public void testGetOnCreateInputConnection_withNullAttrs_returnsInputConnection() {
+ final InputConnection inputConnection = mEmojiEditTextHelper.onCreateInputConnection(
+ mock(InputConnection.class), null);
+ assertNotNull(inputConnection);
+ assertThat(inputConnection, instanceOf(EmojiInputConnection.class));
+ }
+
+ @Test
+ public void testGetOnCreateInputConnection_withNullInputConnection_returnsNull() {
+ InputConnection inputConnection = mEmojiEditTextHelper.onCreateInputConnection(null,
+ new EditorInfo());
+ assertNull(inputConnection);
+ }
+
+ @Test
+ public void testGetOnCreateInputConnection_returnsEmojiInputConnection() {
+ final InputConnection inputConnection = mEmojiEditTextHelper.onCreateInputConnection(
+ mock(InputConnection.class), null);
+ assertNotNull(inputConnection);
+ assertThat(inputConnection, instanceOf(EmojiInputConnection.class));
+ }
+
+ @Test
+ public void testGetOnCreateInputConnection_doesNotCreateNewInstance() {
+ final InputConnection ic1 = mEmojiEditTextHelper.onCreateInputConnection(
+ mock(InputConnection.class), null);
+ final InputConnection ic2 = mEmojiEditTextHelper.onCreateInputConnection(ic1, null);
+
+ assertSame(ic1, ic2);
+ }
+
+ @Test
+ public void testAttachesTextWatcher() {
+ mEditText = mock(EditText.class);
+ mEmojiEditTextHelper = new EmojiEditTextHelper(mEditText);
+
+ final ArgumentCaptor<TextWatcher> argumentCaptor = ArgumentCaptor.forClass(
+ TextWatcher.class);
+
+ verify(mEditText, times(1)).addTextChangedListener(argumentCaptor.capture());
+ assertThat(argumentCaptor.getValue(), instanceOf(EmojiTextWatcher.class));
+ }
+
+ @Test
+ public void testSetMaxCount() {
+ mEditText = mock(EditText.class);
+ mEmojiEditTextHelper = new EmojiEditTextHelper(mEditText);
+ // capture TextWatcher
+ final ArgumentCaptor<TextWatcher> argumentCaptor = ArgumentCaptor.forClass(
+ TextWatcher.class);
+ verify(mEditText, times(1)).addTextChangedListener(argumentCaptor.capture());
+ assertThat(argumentCaptor.getValue(), instanceOf(EmojiTextWatcher.class));
+ final EmojiTextWatcher emojiTextWatcher = (EmojiTextWatcher) argumentCaptor.getValue();
+
+ mEmojiEditTextHelper.setMaxEmojiCount(1);
+
+ assertEquals(1, emojiTextWatcher.getMaxEmojiCount());
+ }
+
+ @Test
+ public void testSetEmojiReplaceStrategy() {
+ mEditText = mock(EditText.class);
+ mEmojiEditTextHelper = new EmojiEditTextHelper(mEditText);
+
+ //assert the default value
+ assertEquals(EmojiCompat.REPLACE_STRATEGY_DEFAULT,
+ mEmojiEditTextHelper.getEmojiReplaceStrategy());
+
+ // capture TextWatcher
+ final ArgumentCaptor<TextWatcher> argumentCaptor = ArgumentCaptor.forClass(
+ TextWatcher.class);
+ verify(mEditText, times(1)).addTextChangedListener(argumentCaptor.capture());
+ assertThat(argumentCaptor.getValue(), instanceOf(EmojiTextWatcher.class));
+ final EmojiTextWatcher emojiTextWatcher = (EmojiTextWatcher) argumentCaptor.getValue();
+
+ mEmojiEditTextHelper.setEmojiReplaceStrategy(EmojiCompat.REPLACE_STRATEGY_NON_EXISTENT);
+
+ assertEquals(EmojiCompat.REPLACE_STRATEGY_NON_EXISTENT,
+ mEmojiEditTextHelper.getEmojiReplaceStrategy());
+
+ assertEquals(EmojiCompat.REPLACE_STRATEGY_NON_EXISTENT,
+ emojiTextWatcher.getEmojiReplaceStrategy());
+ }
+
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiEditTextTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiEditTextTest.java
new file mode 100644
index 0000000..9db816f
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiEditTextTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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.widget;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThat;
+
+import android.app.Instrumentation;
+
+import androidx.emoji2.test.R;
+import androidx.emoji2.text.EmojiCompat;
+import androidx.emoji2.text.TestActivity;
+import androidx.emoji2.text.TestConfigBuilder;
+import androidx.emoji2.util.Emoji;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.emoji2.util.TestString;
+import androidx.test.annotation.UiThreadTest;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.MediumTest;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class EmojiEditTextTest {
+
+ @SuppressWarnings("deprecation")
+ @Rule
+ public androidx.test.rule.ActivityTestRule<TestActivity> mActivityRule =
+ new androidx.test.rule.ActivityTestRule<>(TestActivity.class);
+ private Instrumentation mInstrumentation;
+
+ @BeforeClass
+ public static void setupEmojiCompat() {
+ EmojiCompat.reset(TestConfigBuilder.config());
+ }
+
+ @Before
+ public void setup() {
+ mInstrumentation = InstrumentationRegistry.getInstrumentation();
+ }
+
+ @Test
+ public void testInflateWithMaxEmojiCount() {
+ final TestActivity activity = mActivityRule.getActivity();
+ final EmojiEditText editText = activity.findViewById(R.id.editTextWithMaxCount);
+
+ // value set in XML
+ assertEquals(5, editText.getMaxEmojiCount());
+
+ // set max emoji count
+ mInstrumentation.runOnMainSync(new Runnable() {
+ @Override
+ public void run() {
+ editText.setMaxEmojiCount(1);
+ }
+ });
+ mInstrumentation.waitForIdleSync();
+
+ assertEquals(1, editText.getMaxEmojiCount());
+ }
+
+ @Test
+ @UiThreadTest
+ public void testSetKeyListener_withNull() {
+ final TestActivity activity = mActivityRule.getActivity();
+ final EmojiEditText editText = activity.findViewById(R.id.editTextWithMaxCount);
+ editText.setKeyListener(null);
+ assertNull(editText.getKeyListener());
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 19)
+ public void testSetMaxCount() {
+ final TestActivity activity = mActivityRule.getActivity();
+ final EmojiEditText editText = activity.findViewById(R.id.editTextWithMaxCount);
+
+ // set max emoji count to 1 and set text with 2 emojis
+ mInstrumentation.runOnMainSync(new Runnable() {
+ @Override
+ public void run() {
+ editText.setMaxEmojiCount(1);
+ final String string = new TestString(Emoji.EMOJI_SINGLE_CODEPOINT).append(
+ Emoji.EMOJI_SINGLE_CODEPOINT).toString();
+ editText.setText(string);
+ }
+ });
+ mInstrumentation.waitForIdleSync();
+
+ assertThat(editText.getText(), EmojiMatcher.hasEmojiCount(1));
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiEditableFactoryTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiEditableFactoryTest.java
new file mode 100644
index 0000000..7addec0
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiEditableFactoryTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.widget;
+
+import static org.hamcrest.Matchers.arrayWithSize;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThat;
+import static org.mockito.Mockito.mock;
+
+import android.annotation.SuppressLint;
+import android.text.Editable;
+import android.text.SpannableString;
+import android.text.Spanned;
+
+import androidx.emoji2.text.EmojiMetadata;
+import androidx.emoji2.text.EmojiSpan;
+import androidx.emoji2.text.TypefaceEmojiSpan;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.MediumTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class EmojiEditableFactoryTest {
+
+ @Test
+ public void testGetInstance() {
+ final Editable.Factory instance = EmojiEditableFactory.getInstance();
+ assertNotNull(instance);
+
+ final Editable.Factory instance2 = EmojiEditableFactory.getInstance();
+ assertSame(instance, instance2);
+ }
+
+ @Test
+ public void testNewEditable_returnsEditable() {
+ final Editable editable = EmojiEditableFactory.getInstance().newEditable("abc");
+ assertNotNull(editable);
+ assertThat(editable, instanceOf(Editable.class));
+ }
+
+ @Test
+ public void testNewEditable_preservesCharSequenceData() {
+ final String string = "abc";
+ final SpannableString str = new SpannableString(string);
+ final EmojiMetadata metadata = mock(EmojiMetadata.class);
+ final EmojiSpan span = new TypefaceEmojiSpan(metadata);
+ str.setSpan(span, 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ final Editable editable = EmojiEditableFactory.getInstance().newEditable(str);
+ assertNotNull(editable);
+ assertEquals(string, editable.toString());
+ final EmojiSpan[] spans = editable.getSpans(0, 1, EmojiSpan.class);
+ assertThat(spans, arrayWithSize(1));
+ assertSame(spans[0], span);
+ }
+
+ @SuppressLint("PrivateApi")
+ @Test
+ public void testNewEditable_returnsEmojiSpannableIfWatcherClassExists() {
+ Class<?> clazz = null;
+ try {
+ String className = "android.text.DynamicLayout$ChangeWatcher";
+ clazz = Class.forName(className, false, getClass().getClassLoader());
+ } catch (Throwable t) {
+ // ignore
+ }
+
+ if (clazz == null) {
+ final Editable editable = EmojiEditableFactory.getInstance().newEditable("");
+ assertThat(editable, instanceOf(Editable.class));
+ } else {
+ final Editable editable = EmojiEditableFactory.getInstance().newEditable("");
+ assertThat(editable, instanceOf(SpannableBuilder.class));
+ }
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiExtractTextLayoutTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiExtractTextLayoutTest.java
new file mode 100644
index 0000000..2bb4ec04
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiExtractTextLayoutTest.java
@@ -0,0 +1,203 @@
+/*
+ * 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.widget;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.inputmethodservice.InputMethodService;
+import android.text.InputType;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+
+import androidx.emoji2.R;
+import androidx.emoji2.text.EmojiCompat;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.test.annotation.UiThreadTest;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class EmojiExtractTextLayoutTest {
+
+ private InputMethodService mInputMethodService;
+
+ @BeforeClass
+ public static void setupEmojiCompat() {
+ EmojiCompat.reset(mock(EmojiCompat.class));
+ }
+
+ @Before
+ public void setup() {
+ mInputMethodService = mock(InputMethodService.class);
+ }
+
+ @Test
+ @UiThreadTest
+ public void testInflate() {
+ final Context context = ApplicationProvider.getApplicationContext();
+ final EmojiExtractTextLayout layout = (EmojiExtractTextLayout) LayoutInflater.from(context)
+ .inflate(androidx.emoji2.test.R.layout.extract_view, null);
+
+ final EmojiExtractEditText extractEditText = layout.findViewById(
+ android.R.id.inputExtractEditText);
+ assertNotNull(extractEditText);
+
+ final ViewGroup inputExtractAccessories = layout.findViewById(
+ R.id.inputExtractAccessories);
+ assertNotNull(inputExtractAccessories);
+
+ final ExtractButtonCompat extractButton = inputExtractAccessories.findViewById(
+ R.id.inputExtractAction);
+ assertNotNull(extractButton);
+ }
+
+ @Test
+ @UiThreadTest
+ public void testSetKeyListener_withNull() {
+ final Context context = ApplicationProvider.getApplicationContext();
+ final EmojiExtractTextLayout layout = (EmojiExtractTextLayout) LayoutInflater.from(context)
+ .inflate(androidx.emoji2.test.R.layout.extract_view, null);
+
+ final EmojiExtractEditText extractEditText = layout.findViewById(
+ android.R.id.inputExtractEditText);
+ assertNotNull(extractEditText);
+
+ extractEditText.setKeyListener(null);
+ assertNull(extractEditText.getKeyListener());
+ }
+
+ @Test
+ @UiThreadTest
+ public void testSetEmojiReplaceStrategy() {
+ final Context context = ApplicationProvider.getApplicationContext();
+
+ final EmojiExtractTextLayout layout = (EmojiExtractTextLayout) LayoutInflater.from(context)
+ .inflate(androidx.emoji2.test.R.layout.extract_view_with_attrs, null);
+
+ assertEquals(EmojiCompat.REPLACE_STRATEGY_NON_EXISTENT, layout.getEmojiReplaceStrategy());
+
+ final EmojiExtractEditText extractEditText = layout.findViewById(
+ android.R.id.inputExtractEditText);
+ assertNotNull(extractEditText);
+ assertEquals(EmojiCompat.REPLACE_STRATEGY_NON_EXISTENT,
+ extractEditText.getEmojiReplaceStrategy());
+
+ layout.setEmojiReplaceStrategy(EmojiCompat.REPLACE_STRATEGY_ALL);
+ assertEquals(EmojiCompat.REPLACE_STRATEGY_ALL, layout.getEmojiReplaceStrategy());
+ assertEquals(EmojiCompat.REPLACE_STRATEGY_ALL, extractEditText.getEmojiReplaceStrategy());
+ }
+
+ @Test
+ @UiThreadTest
+ @SdkSuppress(minSdkVersion = 19)
+ public void testSetEmojiReplaceStrategyCallEmojiCompatWithCorrectStrategy() {
+ final Context context = ApplicationProvider.getApplicationContext();
+
+ final EmojiExtractTextLayout layout = (EmojiExtractTextLayout) LayoutInflater.from(context)
+ .inflate(androidx.emoji2.test.R.layout.extract_view_with_attrs, null);
+
+ final EmojiExtractEditText extractEditText = layout.findViewById(
+ android.R.id.inputExtractEditText);
+ assertNotNull(layout);
+ assertNotNull(extractEditText);
+ assertEquals(EmojiCompat.REPLACE_STRATEGY_NON_EXISTENT, layout.getEmojiReplaceStrategy());
+
+ final EmojiCompat emojiCompat = mock(EmojiCompat.class);
+ when(emojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_SUCCEEDED);
+ EmojiCompat.reset(emojiCompat);
+
+ final String testString = "anytext";
+ extractEditText.setText(testString);
+
+ verify(emojiCompat, times(1)).process(EmojiMatcher.sameCharSequence(testString),
+ anyInt(),
+ anyInt(),
+ anyInt(),
+ eq(EmojiCompat.REPLACE_STRATEGY_NON_EXISTENT));
+ }
+
+ @Test
+ @UiThreadTest
+ public void testOnUpdateExtractingViews() {
+ final Context context = ApplicationProvider.getApplicationContext();
+ final EmojiExtractTextLayout layout = (EmojiExtractTextLayout) LayoutInflater.from(context)
+ .inflate(androidx.emoji2.test.R.layout.extract_view, null);
+
+ final EditorInfo editorInfo = new EditorInfo();
+ editorInfo.actionLabel = "My Action Label";
+ editorInfo.imeOptions = EditorInfo.IME_ACTION_SEND;
+ editorInfo.inputType = InputType.TYPE_CLASS_TEXT;
+
+ when(mInputMethodService.isExtractViewShown()).thenReturn(true);
+
+ final ViewGroup inputExtractAccessories = layout.findViewById(
+ R.id.inputExtractAccessories);
+ inputExtractAccessories.setVisibility(View.GONE);
+
+ final ExtractButtonCompat extractButton = inputExtractAccessories.findViewById(
+ R.id.inputExtractAction);
+
+ layout.onUpdateExtractingViews(mInputMethodService, editorInfo);
+
+ assertEquals(View.VISIBLE, inputExtractAccessories.getVisibility());
+ assertEquals(editorInfo.actionLabel, extractButton.getText());
+ assertTrue(extractButton.hasOnClickListeners());
+ }
+
+ @Test
+ @UiThreadTest
+ public void testOnUpdateExtractingViews_hidesAccessoriesIfNoAction() {
+ final Context context = ApplicationProvider.getApplicationContext();
+ final EmojiExtractTextLayout layout = (EmojiExtractTextLayout) LayoutInflater.from(context)
+ .inflate(androidx.emoji2.test.R.layout.extract_view, null);
+
+ final EditorInfo editorInfo = new EditorInfo();
+ editorInfo.imeOptions = EditorInfo.IME_ACTION_NONE;
+ when(mInputMethodService.isExtractViewShown()).thenReturn(true);
+
+ final ViewGroup inputExtractAccessories = layout.findViewById(
+ R.id.inputExtractAccessories);
+ final ExtractButtonCompat extractButton = inputExtractAccessories.findViewById(
+ R.id.inputExtractAction);
+
+ layout.onUpdateExtractingViews(mInputMethodService, editorInfo);
+
+ assertEquals(View.GONE, inputExtractAccessories.getVisibility());
+ assertFalse(extractButton.hasOnClickListeners());
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiInputConnectionTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiInputConnectionTest.java
new file mode 100644
index 0000000..11d580a
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiInputConnectionTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.widget;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.os.Build;
+import android.text.Editable;
+import android.text.Selection;
+import android.text.SpannableStringBuilder;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.TextView;
+
+import androidx.emoji2.text.EmojiCompat;
+import androidx.emoji2.text.TestConfigBuilder;
+import androidx.emoji2.util.Emoji;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.emoji2.util.TestString;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.hamcrest.MatcherAssert;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@LargeTest
+@RunWith(AndroidJUnit4.class)
+@SdkSuppress(minSdkVersion = 19)
+public class EmojiInputConnectionTest {
+
+ private InputConnection mInputConnection;
+ private TestString mTestString;
+ private Editable mEditable;
+ private EmojiInputConnection mEmojiEmojiInputConnection;
+
+ @BeforeClass
+ public static void setupEmojiCompat() {
+ EmojiCompat.reset(TestConfigBuilder.config());
+ }
+
+ @Before
+ public void setup() {
+ mTestString = new TestString(Emoji.EMOJI_WITH_ZWJ).withPrefix().withSuffix();
+ mEditable = new SpannableStringBuilder(mTestString.toString());
+ mInputConnection = mock(InputConnection.class);
+ final Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ final TextView textView = spy(new TextView(context));
+ EmojiCompat.get().process(mEditable);
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+
+ doReturn(mEditable).when(textView).getEditableText();
+ when(mInputConnection.deleteSurroundingText(anyInt(), anyInt())).thenReturn(false);
+ setupDeleteSurroundingText();
+
+ mEmojiEmojiInputConnection = new EmojiInputConnection(textView, mInputConnection,
+ new EditorInfo());
+ }
+
+ private void setupDeleteSurroundingText() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ when(mInputConnection.deleteSurroundingTextInCodePoints(anyInt(), anyInt())).thenReturn(
+ false);
+ }
+ }
+
+ @Test
+ public void testDeleteSurroundingText_doesNotDelete() {
+ Selection.setSelection(mEditable, 0, mEditable.length());
+ assertFalse(mEmojiEmojiInputConnection.deleteSurroundingText(1, 0));
+ verify(mInputConnection, times(1)).deleteSurroundingText(1, 0);
+ }
+
+ @Test
+ public void testDeleteSurroundingText_deletesEmojiBackward() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+ assertTrue(mEmojiEmojiInputConnection.deleteSurroundingText(1, 0));
+ verify(mInputConnection, never()).deleteSurroundingText(anyInt(), anyInt());
+ }
+
+ @Test
+ public void testDeleteSurroundingText_doesNotDeleteEmojiIfSelectionAtStartIndex() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex());
+ assertFalse(mEmojiEmojiInputConnection.deleteSurroundingText(1, 0));
+ verify(mInputConnection, times(1)).deleteSurroundingText(1, 0);
+ }
+
+ @Test
+ public void testDeleteSurroundingText_deletesEmojiForward() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex());
+ assertTrue(mEmojiEmojiInputConnection.deleteSurroundingText(0, 1));
+ verify(mInputConnection, never()).deleteSurroundingText(anyInt(), anyInt());
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
+ @Test
+ public void testDeleteSurroundingTextInCodePoints_doesNotDelete() {
+ Selection.setSelection(mEditable, 0, mEditable.length());
+ assertFalse(mEmojiEmojiInputConnection.deleteSurroundingTextInCodePoints(1, 0));
+ verify(mInputConnection, times(1)).deleteSurroundingTextInCodePoints(1, 0);
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
+ @Test
+ public void testDeleteSurroundingTextInCodePoints_deletesEmojiBackward() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+ assertTrue(mEmojiEmojiInputConnection.deleteSurroundingTextInCodePoints(1, 0));
+ verify(mInputConnection, never()).deleteSurroundingTextInCodePoints(anyInt(), anyInt());
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
+ @Test
+ public void testDeleteSurroundingTextInCodePoints_deletesEmojiForward() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex());
+ assertTrue(mEmojiEmojiInputConnection.deleteSurroundingTextInCodePoints(0, 1));
+ verify(mInputConnection, never()).deleteSurroundingTextInCodePoints(anyInt(), anyInt());
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
+ @Test
+ public void testDeleteSurroundingTextInCodePoints_doesNotDeleteEmojiIfSelectionAtStartIndex() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex());
+ assertFalse(mEmojiEmojiInputConnection.deleteSurroundingTextInCodePoints(1, 0));
+ verify(mInputConnection, times(1)).deleteSurroundingTextInCodePoints(1, 0);
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiInputFilterTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiInputFilterTest.java
new file mode 100644
index 0000000..dc9b977
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiInputFilterTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.widget;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.widget.TextView;
+
+import androidx.emoji2.text.EmojiCompat;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@LargeTest
+@RunWith(AndroidJUnit4.class)
+public class EmojiInputFilterTest {
+
+ private EmojiInputFilter mInputFilter;
+ private EmojiCompat mEmojiCompat;
+
+ @Before
+ public void setup() {
+ final TextView textView = mock(TextView.class);
+ mEmojiCompat = mock(EmojiCompat.class);
+ EmojiCompat.reset(mEmojiCompat);
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_SUCCEEDED);
+ mInputFilter = new EmojiInputFilter(textView);
+ }
+
+ @Test
+ public void testFilter_withNullSource() {
+ assertNull(mInputFilter.filter(null, 0, 1, null, 0, 1));
+ verify(mEmojiCompat, never()).process(any(CharSequence.class));
+ verify(mEmojiCompat, never()).process(any(CharSequence.class), anyInt(), anyInt());
+ }
+
+ @Test
+ public void testFilter_withString() {
+ final String testString = "abc";
+ when(mEmojiCompat.process(any(CharSequence.class), anyInt(), anyInt()))
+ .thenReturn(new SpannableString(testString));
+ final CharSequence result = mInputFilter.filter(testString, 0, 1, null, 0, 1);
+
+ assertNotNull(result);
+ assertTrue(result instanceof Spannable);
+ verify(mEmojiCompat, times(1)).process(EmojiMatcher.sameCharSequence("a"), eq(0), eq(1));
+ }
+
+ @Test
+ public void testFilter_withSpannable() {
+ final Spannable testString = new SpannableString("abc");
+ when(mEmojiCompat.process(any(Spannable.class), anyInt(), anyInt())).thenReturn(testString);
+
+ final CharSequence result = mInputFilter.filter(testString, 0, 1, null, 0, 1);
+
+ assertNotNull(result);
+ assertSame(result, testString);
+ verify(mEmojiCompat, times(1)).process(
+ EmojiMatcher.sameCharSequence(testString.subSequence(0, 1)),
+ eq(0), eq(1));
+ }
+
+ @Test
+ public void testFilter_whenEmojiCompatLoading() {
+ final Spannable testString = new SpannableString("abc");
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_LOADING);
+
+ final CharSequence result = mInputFilter.filter(testString, 0, 1, null, 0, 1);
+
+ assertNotNull(result);
+ assertSame(result, testString);
+ verify(mEmojiCompat, times(0)).process(any(Spannable.class), anyInt(), anyInt());
+ verify(mEmojiCompat, times(1)).registerInitCallback(any(EmojiCompat.InitCallback.class));
+ }
+
+ @Test
+ public void testFilter_whenEmojiCompatLoadFailed() {
+ final Spannable testString = new SpannableString("abc");
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_FAILED);
+
+ final CharSequence result = mInputFilter.filter(testString, 0, 1, null, 0, 1);
+
+ assertNotNull(result);
+ verify(mEmojiCompat, times(0)).process(any(Spannable.class), anyInt(), anyInt());
+ verify(mEmojiCompat, times(0)).registerInitCallback(any(EmojiCompat.InitCallback.class));
+ }
+
+ @Test
+ public void testFilter_withManualLoadStrategy() {
+ final Spannable testString = new SpannableString("abc");
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_DEFAULT);
+
+ final CharSequence result = mInputFilter.filter(testString, 0, 1, null, 0, 1);
+
+ assertNotNull(result);
+ verify(mEmojiCompat, times(0)).process(any(Spannable.class), anyInt(), anyInt());
+ verify(mEmojiCompat, times(1)).registerInitCallback(any(EmojiCompat.InitCallback.class));
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiKeyListenerTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiKeyListenerTest.java
new file mode 100644
index 0000000..3bee8fe
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiKeyListenerTest.java
@@ -0,0 +1,178 @@
+/*
+ * 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.widget;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.text.Editable;
+import android.text.Selection;
+import android.text.SpannableStringBuilder;
+import android.text.method.KeyListener;
+import android.view.KeyEvent;
+import android.view.View;
+
+import androidx.emoji2.text.EmojiCompat;
+import androidx.emoji2.text.TestConfigBuilder;
+import androidx.emoji2.util.Emoji;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.emoji2.util.KeyboardUtil;
+import androidx.emoji2.util.TestString;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.hamcrest.MatcherAssert;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+@SdkSuppress(minSdkVersion = 19)
+public class EmojiKeyListenerTest {
+
+ private KeyListener mKeyListener;
+ private TestString mTestString;
+ private Editable mEditable;
+ private EmojiKeyListener mEmojiKeyListener;
+
+ @BeforeClass
+ public static void setupEmojiCompat() {
+ EmojiCompat.reset(TestConfigBuilder.config());
+ }
+
+ @Before
+ public void setup() {
+ mKeyListener = mock(KeyListener.class);
+ mTestString = new TestString(Emoji.EMOJI_WITH_ZWJ).withPrefix().withSuffix();
+ mEditable = new SpannableStringBuilder(mTestString.toString());
+ mEmojiKeyListener = new EmojiKeyListener(mKeyListener);
+ EmojiCompat.get().process(mEditable);
+ MatcherAssert.assertThat(mEditable, EmojiMatcher.hasEmoji());
+
+ when(mKeyListener.onKeyDown(any(View.class), any(Editable.class), anyInt(),
+ any(KeyEvent.class))).thenReturn(false);
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_whenKeyCodeIsNotDelOrForwardDel() {
+ Selection.setSelection(mEditable, 0);
+ final KeyEvent event = KeyboardUtil.zero();
+ assertFalse(mEmojiKeyListener.onKeyDown(null, mEditable, event.getKeyCode(), event));
+ verify(mKeyListener, times(1)).onKeyDown((View) eq(null), same(mEditable),
+ eq(event.getKeyCode()), same(event));
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_withOtherModifiers() {
+ Selection.setSelection(mEditable, 0);
+ final KeyEvent event = KeyboardUtil.fnDel();
+ assertFalse(mEmojiKeyListener.onKeyDown(null, mEditable, event.getKeyCode(), event));
+ verify(mKeyListener, times(1)).onKeyDown((View) eq(null), same(mEditable),
+ eq(event.getKeyCode()), same(event));
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_withAltModifier() {
+ Selection.setSelection(mEditable, 0);
+ final KeyEvent event = KeyboardUtil.altDel();
+ assertFalse(mEmojiKeyListener.onKeyDown(null, mEditable, event.getKeyCode(), event));
+ verify(mKeyListener, times(1)).onKeyDown((View) eq(null), same(mEditable),
+ eq(event.getKeyCode()), same(event));
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_withCtrlModifier() {
+ Selection.setSelection(mEditable, 0);
+ final KeyEvent event = KeyboardUtil.ctrlDel();
+ assertFalse(mEmojiKeyListener.onKeyDown(null, mEditable, event.getKeyCode(), event));
+ verify(mKeyListener, times(1)).onKeyDown((View) eq(null), same(mEditable),
+ eq(event.getKeyCode()), same(event));
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_withShiftModifier() {
+ Selection.setSelection(mEditable, 0);
+ final KeyEvent event = KeyboardUtil.shiftDel();
+ assertFalse(mEmojiKeyListener.onKeyDown(null, mEditable, event.getKeyCode(), event));
+ verify(mKeyListener, times(1)).onKeyDown((View) eq(null), same(mEditable),
+ eq(event.getKeyCode()), same(event));
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_withSelection() {
+ Selection.setSelection(mEditable, 0, mEditable.length());
+ final KeyEvent event = KeyboardUtil.del();
+ assertFalse(mEmojiKeyListener.onKeyDown(null, mEditable, event.getKeyCode(), event));
+ verify(mKeyListener, times(1)).onKeyDown((View) eq(null), same(mEditable),
+ eq(event.getKeyCode()), same(event));
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_withoutEmojiSpans() {
+ Editable editable = new SpannableStringBuilder("abc");
+ Selection.setSelection(editable, 1);
+ final KeyEvent event = KeyboardUtil.del();
+ assertFalse(mEmojiKeyListener.onKeyDown(null, editable, event.getKeyCode(), event));
+ verify(mKeyListener, times(1)).onKeyDown((View) eq(null), same(editable),
+ eq(event.getKeyCode()), same(event));
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotDelete_whenNoSpansBefore() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex());
+ final KeyEvent event = KeyboardUtil.del();
+ assertFalse(mEmojiKeyListener.onKeyDown(null, mEditable, event.getKeyCode(), event));
+ verify(mKeyListener, times(1)).onKeyDown((View) eq(null), same(mEditable),
+ eq(event.getKeyCode()), same(event));
+ }
+
+ @Test
+ public void testOnKeyDown_deletesEmoji() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+ final KeyEvent event = KeyboardUtil.del();
+ assertTrue(mEmojiKeyListener.onKeyDown(null, mEditable, event.getKeyCode(), event));
+ verifyNoMoreInteractions(mKeyListener);
+ }
+
+ @Test
+ public void testOnKeyDown_doesNotForwardDeleteEmoji_withNoSpansAfter() {
+ Selection.setSelection(mEditable, mTestString.emojiEndIndex());
+ final KeyEvent event = KeyboardUtil.forwardDel();
+ assertFalse(mEmojiKeyListener.onKeyDown(null, mEditable, event.getKeyCode(), event));
+ verify(mKeyListener, times(1)).onKeyDown((View) eq(null), same(mEditable),
+ eq(event.getKeyCode()), same(event));
+ }
+
+ @Test
+ public void testOnKeyDown_forwardDeletesEmoji() {
+ Selection.setSelection(mEditable, mTestString.emojiStartIndex());
+ final KeyEvent event = KeyboardUtil.forwardDel();
+ assertTrue(mEmojiKeyListener.onKeyDown(null, mEditable, event.getKeyCode(), event));
+ verifyNoMoreInteractions(mKeyListener);
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiTextViewHelperPre19Test.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiTextViewHelperPre19Test.java
new file mode 100644
index 0000000..eaa6a01
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiTextViewHelperPre19Test.java
@@ -0,0 +1,87 @@
+/*
+ * 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.widget;
+
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.mockito.Mockito.mock;
+
+import android.text.InputFilter;
+import android.text.method.TransformationMethod;
+import android.widget.TextView;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+@SdkSuppress(maxSdkVersion = 18)
+public class EmojiTextViewHelperPre19Test {
+ EmojiTextViewHelper mTextViewHelper;
+ TextView mTextView;
+
+ @Before
+ public void setup() {
+ mTextView = new TextView(ApplicationProvider.getApplicationContext());
+ mTextViewHelper = new EmojiTextViewHelper(mTextView);
+ }
+
+ @Test
+ public void testUpdateTransformationMethod_doesNotUpdateTransformationMethod() {
+ final TransformationMethod tm = mock(TransformationMethod.class);
+ mTextView.setTransformationMethod(tm);
+
+ mTextViewHelper.updateTransformationMethod();
+
+ assertSame(tm, mTextView.getTransformationMethod());
+ }
+
+ @Test
+ public void testGetFilters_returnsSameFilters() {
+ final InputFilter existingFilter = mock(InputFilter.class);
+ final InputFilter[] filters = new InputFilter[]{existingFilter};
+
+ final InputFilter[] newFilters = mTextViewHelper.getFilters(filters);
+
+ assertSame(filters, newFilters);
+ }
+
+ @Test
+ public void testGetTransformationMethod_returnSameTransformationMethod() {
+ assertNull(mTextViewHelper.wrapTransformationMethod(null));
+
+ final TransformationMethod tm = mock(TransformationMethod.class);
+ assertSame(tm, mTextViewHelper.wrapTransformationMethod(tm));
+ }
+
+ @Test
+ public void testSetAllCaps_doesNotUpdateTransformationMethod() {
+ final TransformationMethod tm = mock(TransformationMethod.class);
+ mTextView.setTransformationMethod(tm);
+ mTextViewHelper.setAllCaps(true);
+ assertSame(tm, mTextView.getTransformationMethod());
+
+ mTextViewHelper.setAllCaps(false);
+ assertSame(tm, mTextView.getTransformationMethod());
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiTextViewHelperTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiTextViewHelperTest.java
new file mode 100644
index 0000000..d4b00e9
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiTextViewHelperTest.java
@@ -0,0 +1,165 @@
+/*
+ * 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.widget;
+
+import static org.hamcrest.CoreMatchers.hasItem;
+import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThat;
+import static org.mockito.Mockito.mock;
+
+import android.text.InputFilter;
+import android.text.method.PasswordTransformationMethod;
+import android.text.method.TransformationMethod;
+import android.widget.TextView;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+@SdkSuppress(minSdkVersion = 19)
+public class EmojiTextViewHelperTest {
+ EmojiTextViewHelper mTextViewHelper;
+ TextView mTextView;
+
+ @Before
+ public void setup() {
+ mTextView = new TextView(ApplicationProvider.getApplicationContext());
+ mTextViewHelper = new EmojiTextViewHelper(mTextView);
+ }
+
+ @Test
+ public void testUpdateTransformationMethod() {
+ mTextView.setTransformationMethod(mock(TransformationMethod.class));
+
+ mTextViewHelper.updateTransformationMethod();
+
+ assertThat(mTextView.getTransformationMethod(),
+ instanceOf(EmojiTransformationMethod.class));
+ }
+
+ @Test
+ public void testUpdateTransformationMethod_doesNotUpdateForPasswordTransformation() {
+ final PasswordTransformationMethod transformationMethod =
+ new PasswordTransformationMethod();
+ mTextView.setTransformationMethod(transformationMethod);
+
+ mTextViewHelper.updateTransformationMethod();
+
+ assertEquals(transformationMethod, mTextView.getTransformationMethod());
+ }
+
+ @Test
+ public void testUpdateTransformationMethod_doesNotCreateNewInstance() {
+ mTextView.setTransformationMethod(mock(TransformationMethod.class));
+
+ mTextViewHelper.updateTransformationMethod();
+ final TransformationMethod tm = mTextView.getTransformationMethod();
+ assertThat(tm, instanceOf(EmojiTransformationMethod.class));
+
+ // call the function again
+ mTextViewHelper.updateTransformationMethod();
+ assertSame(tm, mTextView.getTransformationMethod());
+ }
+
+ @Test
+ public void testGetFilters() {
+ final InputFilter existingFilter = mock(InputFilter.class);
+ final InputFilter[] filters = new InputFilter[]{existingFilter};
+
+ final InputFilter[] newFilters = mTextViewHelper.getFilters(filters);
+
+ assertEquals(2, newFilters.length);
+ assertThat(Arrays.asList(newFilters), hasItem(existingFilter));
+ assertNotNull(findEmojiInputFilter(newFilters));
+ }
+
+ @Test
+ public void testGetFilters_doesNotAddSecondInstance() {
+ final InputFilter existingFilter = mock(InputFilter.class);
+ final InputFilter[] filters = new InputFilter[]{existingFilter};
+
+ InputFilter[] newFilters = mTextViewHelper.getFilters(filters);
+ EmojiInputFilter emojiInputFilter = findEmojiInputFilter(newFilters);
+ assertNotNull(emojiInputFilter);
+
+ // run it again with the updated filters and see that it does not add new filter
+ newFilters = mTextViewHelper.getFilters(newFilters);
+
+ assertEquals(2, newFilters.length);
+ assertThat(Arrays.asList(newFilters), hasItem(existingFilter));
+ assertThat(Arrays.asList(newFilters), hasItem(emojiInputFilter));
+ }
+
+ private EmojiInputFilter findEmojiInputFilter(final InputFilter[] filters) {
+ for (int i = 0; i < filters.length; i++) {
+ if (filters[i] instanceof EmojiInputFilter) {
+ return (EmojiInputFilter) filters[i];
+ }
+ }
+ return null;
+ }
+
+ @Test
+ public void testWrapTransformationMethod() {
+ assertThat(mTextViewHelper.wrapTransformationMethod(null),
+ instanceOf(EmojiTransformationMethod.class));
+ }
+
+ @Test
+ public void testWrapTransformationMethod_doesNotCreateNewInstance() {
+ final TransformationMethod tm1 = mTextViewHelper.wrapTransformationMethod(null);
+ final TransformationMethod tm2 = mTextViewHelper.wrapTransformationMethod(tm1);
+ assertSame(tm1, tm2);
+ }
+
+ @Test
+ public void testSetAllCaps_withTrueSetsTransformationMethod() {
+ mTextView.setTransformationMethod(mock(TransformationMethod.class));
+ mTextViewHelper.setAllCaps(true);
+ assertThat(mTextView.getTransformationMethod(),
+ instanceOf(EmojiTransformationMethod.class));
+ }
+
+ @Test
+ public void testSetAllCaps_withFalseDoesNotSetTransformationMethod() {
+ mTextView.setTransformationMethod(null);
+ mTextViewHelper.setAllCaps(false);
+ assertNull(mTextView.getTransformationMethod());
+ }
+
+ @Test
+ public void testSetAllCaps_withPasswordTransformationDoesNotSetTransformationMethod() {
+ final PasswordTransformationMethod transformationMethod =
+ new PasswordTransformationMethod();
+ mTextView.setTransformationMethod(transformationMethod);
+ mTextViewHelper.setAllCaps(true);
+ assertSame(transformationMethod, mTextView.getTransformationMethod());
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiTextWatcherTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiTextWatcherTest.java
new file mode 100644
index 0000000..35b5d2e
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiTextWatcherTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.widget;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.widget.EditText;
+
+import androidx.emoji2.text.EmojiCompat;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class EmojiTextWatcherTest {
+
+ private EmojiTextWatcher mTextWatcher;
+ private EmojiCompat mEmojiCompat;
+
+ @Before
+ public void setup() {
+ final EditText editText = mock(EditText.class);
+ mEmojiCompat = mock(EmojiCompat.class);
+ EmojiCompat.reset(mEmojiCompat);
+ mTextWatcher = new EmojiTextWatcher(editText);
+ }
+
+ @Test
+ public void testOnTextChanged_callsProcess() {
+ final Spannable testString = new SpannableString("abc");
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_SUCCEEDED);
+
+ mTextWatcher.onTextChanged(testString, 0, 0, 1);
+
+ verify(mEmojiCompat, times(1)).process(
+ EmojiMatcher.sameCharSequence(testString),
+ eq(0),
+ eq(1),
+ eq(Integer.MAX_VALUE),
+ anyInt());
+ verify(mEmojiCompat, times(0)).registerInitCallback(any(EmojiCompat.InitCallback.class));
+ }
+
+ @Test
+ public void testOnTextChanged_whenEmojiCompatLoading() {
+ final Spannable testString = new SpannableString("abc");
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_LOADING);
+
+ mTextWatcher.onTextChanged(testString, 0, 0, 1);
+
+ verify(mEmojiCompat, times(0)).process(any(Spannable.class), anyInt(), anyInt(), anyInt(),
+ anyInt());
+ verify(mEmojiCompat, times(1)).registerInitCallback(any(EmojiCompat.InitCallback.class));
+ }
+
+ @Test
+ public void testOnTextChanged_whenEmojiCompatLoadFailed() {
+ final Spannable testString = new SpannableString("abc");
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_FAILED);
+
+ mTextWatcher.onTextChanged(testString, 0, 0, 1);
+
+ verify(mEmojiCompat, times(0)).process(any(Spannable.class), anyInt(), anyInt(), anyInt(),
+ anyInt());
+ verify(mEmojiCompat, times(0)).registerInitCallback(any(EmojiCompat.InitCallback.class));
+ }
+
+ @Test
+ public void testSetEmojiReplaceStrategy() {
+ final Spannable testString = new SpannableString("abc");
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_SUCCEEDED);
+
+ assertEquals(EmojiCompat.REPLACE_STRATEGY_DEFAULT, mTextWatcher.getEmojiReplaceStrategy());
+
+ mTextWatcher.onTextChanged(testString, 0, 0, 1);
+
+ verify(mEmojiCompat, times(1)).process(any(Spannable.class), anyInt(), anyInt(), anyInt(),
+ eq(EmojiCompat.REPLACE_STRATEGY_DEFAULT));
+
+ mTextWatcher.setEmojiReplaceStrategy(EmojiCompat.REPLACE_STRATEGY_ALL);
+
+ mTextWatcher.onTextChanged(testString, 0, 0, 1);
+
+ verify(mEmojiCompat, times(1)).process(any(Spannable.class), anyInt(), anyInt(), anyInt(),
+ eq(EmojiCompat.REPLACE_STRATEGY_ALL));
+ }
+
+ @Test
+ public void testFilter_withManualLoadStrategy() {
+ final Spannable testString = new SpannableString("abc");
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_DEFAULT);
+
+ mTextWatcher.onTextChanged(testString, 0, 0, 1);
+
+ verify(mEmojiCompat, times(0)).process(any(Spannable.class), anyInt(), anyInt());
+ verify(mEmojiCompat, times(1)).registerInitCallback(any(EmojiCompat.InitCallback.class));
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiTransformationMethodTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiTransformationMethodTest.java
new file mode 100644
index 0000000..31122c8
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/EmojiTransformationMethodTest.java
@@ -0,0 +1,157 @@
+/*
+ * 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.widget;
+
+import static junit.framework.TestCase.assertSame;
+
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.method.TransformationMethod;
+import android.view.View;
+
+import androidx.emoji2.text.EmojiCompat;
+import androidx.emoji2.util.EmojiMatcher;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class EmojiTransformationMethodTest {
+
+ private EmojiTransformationMethod mTransformationMethod;
+ private TransformationMethod mWrappedTransformationMethod;
+ private View mView;
+ private EmojiCompat mEmojiCompat;
+ private final String mTestString = "abc";
+
+ @Before
+ public void setup() {
+ mEmojiCompat = mock(EmojiCompat.class);
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_SUCCEEDED);
+ when(mEmojiCompat.process(any(CharSequence.class))).thenAnswer(new Answer<CharSequence>() {
+ @Override
+ public CharSequence answer(InvocationOnMock invocation) {
+ Object[] args = invocation.getArguments();
+ return new SpannableString((String) args[0]);
+ }
+ });
+ EmojiCompat.reset(mEmojiCompat);
+
+ mView = mock(View.class);
+ when(mView.isInEditMode()).thenReturn(false);
+
+ mWrappedTransformationMethod = mock(TransformationMethod.class);
+ when(mWrappedTransformationMethod.getTransformation(any(CharSequence.class),
+ any(View.class))).thenAnswer(new Answer<CharSequence>() {
+ @Override
+ public CharSequence answer(InvocationOnMock invocation) {
+ Object[] args = invocation.getArguments();
+ return (String) args[0];
+ }
+ });
+
+ mTransformationMethod = new EmojiTransformationMethod(mWrappedTransformationMethod);
+ }
+
+ @Test
+ public void testFilter_withNullSource() {
+ assertNull(mTransformationMethod.getTransformation(null, mView));
+ verify(mEmojiCompat, never()).process(any(CharSequence.class));
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testFilter_withNullView() {
+ mTransformationMethod.getTransformation("", null);
+ }
+
+ @Test
+ public void testFilter_withNullTransformationMethod() {
+ mTransformationMethod = new EmojiTransformationMethod(null);
+
+ final CharSequence result = mTransformationMethod.getTransformation(mTestString, mView);
+
+ assertTrue(TextUtils.equals(new SpannableString(mTestString), result));
+ verify(mEmojiCompat, times(1)).process(EmojiMatcher.sameCharSequence(mTestString));
+ }
+
+ @Test
+ public void testFilter() {
+ final CharSequence result = mTransformationMethod.getTransformation(mTestString, mView);
+
+ assertTrue(TextUtils.equals(new SpannableString(mTestString), result));
+ assertTrue(result instanceof Spannable);
+ verify(mWrappedTransformationMethod, times(1)).getTransformation(
+ EmojiMatcher.sameCharSequence(mTestString), same(mView));
+ verify(mEmojiCompat, times(1)).process(EmojiMatcher.sameCharSequence(mTestString));
+ verify(mEmojiCompat, never()).registerInitCallback(any(EmojiCompat.InitCallback.class));
+ }
+
+ @Test
+ public void testFilter_whenEmojiCompatLoading() {
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_LOADING);
+
+ final CharSequence result = mTransformationMethod.getTransformation(mTestString, mView);
+
+ assertSame(mTestString, result);
+ verify(mWrappedTransformationMethod, times(1)).getTransformation(
+ EmojiMatcher.sameCharSequence(mTestString), same(mView));
+ verify(mEmojiCompat, never()).process(EmojiMatcher.sameCharSequence(mTestString));
+ verify(mEmojiCompat, never()).registerInitCallback(any(EmojiCompat.InitCallback.class));
+ }
+
+ @Test
+ public void testFilter_whenEmojiCompatLoadFailed() {
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_FAILED);
+
+ final CharSequence result = mTransformationMethod.getTransformation(mTestString, mView);
+
+ assertSame(mTestString, result);
+ verify(mWrappedTransformationMethod, times(1)).getTransformation(
+ EmojiMatcher.sameCharSequence(mTestString), same(mView));
+ verify(mEmojiCompat, never()).process(EmojiMatcher.sameCharSequence(mTestString));
+ verify(mEmojiCompat, never()).registerInitCallback(any(EmojiCompat.InitCallback.class));
+ }
+
+ @Test
+ public void testFilter_withManualLoadStrategy() {
+ when(mEmojiCompat.getLoadState()).thenReturn(EmojiCompat.LOAD_STATE_DEFAULT);
+
+ final CharSequence result = mTransformationMethod.getTransformation(mTestString, mView);
+
+ assertSame(mTestString, result);
+ verify(mWrappedTransformationMethod, times(1)).getTransformation(
+ EmojiMatcher.sameCharSequence(mTestString), same(mView));
+ verify(mEmojiCompat, never()).process(EmojiMatcher.sameCharSequence(mTestString));
+ verify(mEmojiCompat, never()).registerInitCallback(any(EmojiCompat.InitCallback.class));
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/SpannableBuilderTest.java b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/SpannableBuilderTest.java
new file mode 100644
index 0000000..919ba54
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/java/androidx/emoji2/widget/SpannableBuilderTest.java
@@ -0,0 +1,214 @@
+/*
+ * 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.widget;
+
+import static org.hamcrest.Matchers.arrayWithSize;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyObject;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.withSettings;
+
+import android.text.Editable;
+import android.text.SpanWatcher;
+import android.text.Spannable;
+import android.text.Spanned;
+import android.text.TextWatcher;
+import android.text.style.QuoteSpan;
+
+import androidx.emoji2.text.EmojiSpan;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.MediumTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class SpannableBuilderTest {
+
+ private TextWatcher mWatcher;
+ private Class<?> mClass;
+
+ @Before
+ public void setup() {
+ mWatcher = mock(TextWatcher.class, withSettings().extraInterfaces(SpanWatcher.class));
+ mClass = mWatcher.getClass();
+ }
+
+ @Test
+ public void testConstructor() {
+ new SpannableBuilder(mClass);
+
+ new SpannableBuilder(mClass, "abc");
+
+ new SpannableBuilder(mClass, "abc", 0, 3);
+
+ // test spannable copying? do I need it?
+ }
+
+ @Test
+ public void testSubSequence() {
+ final SpannableBuilder spannable = new SpannableBuilder(mClass, "abc");
+ final QuoteSpan span1 = mock(QuoteSpan.class);
+ final QuoteSpan span2 = mock(QuoteSpan.class);
+ spannable.setSpan(span1, 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ spannable.setSpan(span2, 2, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ final CharSequence subsequence = spannable.subSequence(0, 1);
+ assertNotNull(subsequence);
+ assertThat(subsequence, instanceOf(SpannableBuilder.class));
+
+ final QuoteSpan[] spans = spannable.getSpans(0, 1, QuoteSpan.class);
+ assertThat(spans, arrayWithSize(1));
+ assertSame(spans[0], span1);
+ }
+
+ @Test
+ public void testSetAndGetSpan() {
+ final SpannableBuilder spannable = new SpannableBuilder(mClass, "abcde");
+ spannable.setSpan(mWatcher, 1, 2, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+
+ // getSpans should return the span
+ Object[] spans = spannable.getSpans(0, spannable.length(), mClass);
+ assertNotNull(spans);
+ assertThat(spans, arrayWithSize(1));
+ assertSame(mWatcher, spans[0]);
+
+ // span attributes should be correct
+ assertEquals(1, spannable.getSpanStart(mWatcher));
+ assertEquals(2, spannable.getSpanEnd(mWatcher));
+ assertEquals(Spanned.SPAN_INCLUSIVE_INCLUSIVE, spannable.getSpanFlags(mWatcher));
+
+ // should remove the span
+ spannable.removeSpan(mWatcher);
+ spans = spannable.getSpans(0, spannable.length(), QuoteSpan.class);
+ assertNotNull(spans);
+ assertThat(spans, arrayWithSize(0));
+ }
+
+ @Test
+ public void testNextSpanTransition() {
+ final SpannableBuilder spannable = new SpannableBuilder(mClass, "abcde");
+ spannable.setSpan(mWatcher, 1, 2, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ final int start = spannable.nextSpanTransition(0, spannable.length(), mClass);
+ assertEquals(1, start);
+ }
+
+ @Test
+ public void testBlocksSpanCallbacks_forEmojiSpans() {
+ final EmojiSpan span = mock(EmojiSpan.class);
+ final SpannableBuilder spannable = new SpannableBuilder(mClass, "123456");
+ spannable.setSpan(mWatcher, 0, spannable.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ spannable.setSpan(span, 1, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ reset(mWatcher);
+
+ spannable.delete(0, 3);
+
+ // verify that characters are deleted
+ assertEquals("456", spannable.toString());
+ // verify EmojiSpan is deleted
+ EmojiSpan[] spans = spannable.getSpans(0, spannable.length(), EmojiSpan.class);
+ assertThat(spans, arrayWithSize(0));
+
+ // verify the call to span callbacks are blocked
+ verify((SpanWatcher) mWatcher, never()).onSpanRemoved(any(Spannable.class),
+ same(span), anyInt(), anyInt());
+ verify((SpanWatcher) mWatcher, never()).onSpanAdded(any(Spannable.class),
+ same(span), anyInt(), anyInt());
+ verify((SpanWatcher) mWatcher, never()).onSpanChanged(any(Spannable.class),
+ same(span), anyInt(), anyInt(), anyInt(), anyInt());
+
+ // verify the call to TextWatcher callbacks are called
+ verify(mWatcher, times(1)).beforeTextChanged(any(CharSequence.class), anyInt(),
+ anyInt(), anyInt());
+ verify(mWatcher, times(1)).onTextChanged(any(CharSequence.class), anyInt(), anyInt(),
+ anyInt());
+ verify(mWatcher, times(1)).afterTextChanged(any(Editable.class));
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void testDoesNotBlockSpanCallbacks_forNonEmojiSpans() {
+ final QuoteSpan span = mock(QuoteSpan.class);
+ final SpannableBuilder spannable = new SpannableBuilder(mClass, "123456");
+ spannable.setSpan(mWatcher, 0, spannable.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ spannable.setSpan(span, 1, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ reset(mWatcher);
+
+ spannable.delete(0, 3);
+
+ // verify that characters are deleted
+ assertEquals("456", spannable.toString());
+ // verify QuoteSpan is deleted
+ QuoteSpan[] spans = spannable.getSpans(0, spannable.length(), QuoteSpan.class);
+ assertThat(spans, arrayWithSize(0));
+
+ // verify the call to span callbacks are not blocked
+ verify((SpanWatcher) mWatcher, times(1)).onSpanRemoved(any(Spannable.class),
+ anyObject(), anyInt(), anyInt());
+
+ // verify the call to TextWatcher callbacks are called
+ verify(mWatcher, times(1)).beforeTextChanged(any(CharSequence.class), anyInt(), anyInt(),
+ anyInt());
+ verify(mWatcher, times(1)).onTextChanged(any(CharSequence.class), anyInt(), anyInt(),
+ anyInt());
+ verify(mWatcher, times(1)).afterTextChanged(any(Editable.class));
+ }
+
+ @Test
+ public void testDoesNotBlockSpanCallbacksForOtherWatchers() {
+ final TextWatcher textWatcher = mock(TextWatcher.class);
+ final SpanWatcher spanWatcher = mock(SpanWatcher.class);
+
+ final EmojiSpan span = mock(EmojiSpan.class);
+ final SpannableBuilder spannable = new SpannableBuilder(mClass, "123456");
+ spannable.setSpan(textWatcher, 0, spannable.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ spannable.setSpan(spanWatcher, 0, spannable.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ spannable.setSpan(span, 1, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ reset(textWatcher);
+
+ spannable.delete(0, 3);
+
+ // verify that characters are deleted
+ assertEquals("456", spannable.toString());
+ // verify EmojiSpan is deleted
+ EmojiSpan[] spans = spannable.getSpans(0, spannable.length(), EmojiSpan.class);
+ assertThat(spans, arrayWithSize(0));
+
+ // verify the call to span callbacks are blocked
+ verify(spanWatcher, times(1)).onSpanRemoved(any(Spannable.class), same(span),
+ anyInt(), anyInt());
+
+ // verify the call to TextWatcher callbacks are called
+ verify(textWatcher, times(1)).beforeTextChanged(any(CharSequence.class), anyInt(),
+ anyInt(), anyInt());
+ verify(textWatcher, times(1)).onTextChanged(any(CharSequence.class), anyInt(), anyInt(),
+ anyInt());
+ verify(textWatcher, times(1)).afterTextChanged(any(Editable.class));
+ }
+}
diff --git a/emoji2/emoji2/src/androidTest/res/layout/activity_default.xml b/emoji2/emoji2/src/androidTest/res/layout/activity_default.xml
new file mode 100644
index 0000000..2d7968c
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/res/layout/activity_default.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="8sp"/>
+
+ <androidx.emoji2.widget.EmojiEditText
+ android:id="@+id/editText"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+
+ <androidx.emoji2.widget.EmojiEditText
+ android:id="@+id/editTextWithMaxCount"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:maxEmojiCount="5"/>
+
+</LinearLayout>
diff --git a/emoji2/emoji2/src/androidTest/res/layout/extract_view.xml b/emoji2/emoji2/src/androidTest/res/layout/extract_view.xml
new file mode 100644
index 0000000..b1a53fd
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/res/layout/extract_view.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+ ~ 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.
+ -->
+
+<androidx.emoji2.widget.EmojiExtractTextLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"/>
\ No newline at end of file
diff --git a/emoji2/emoji2/src/androidTest/res/layout/extract_view_with_attrs.xml b/emoji2/emoji2/src/androidTest/res/layout/extract_view_with_attrs.xml
new file mode 100644
index 0000000..f91c105
--- /dev/null
+++ b/emoji2/emoji2/src/androidTest/res/layout/extract_view_with_attrs.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+ ~ 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.
+ -->
+
+<androidx.emoji2.widget.EmojiExtractTextLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/emojiExtractTextLayout"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:emojiReplaceStrategy="nonExistent"/>
\ No newline at end of file
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/text/EmojiCompat.java b/emoji2/emoji2/src/main/java/androidx/emoji2/text/EmojiCompat.java
new file mode 100644
index 0000000..1dc1ac4
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/text/EmojiCompat.java
@@ -0,0 +1,1335 @@
+/*
+ * 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_GROUP_PREFIX;
+
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.os.Build;
+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.annotation.VisibleForTesting;
+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}. It is a singleton class that can be configured using a {@link
+ * EmojiCompat.Config} instance.
+ * <p/>
+ * EmojiCompat has to be initialized using {@link #init(EmojiCompat.Config)} function before it can
+ * process a {@link CharSequence}.
+ * <pre><code>EmojiCompat.init(/* a config instance */);</code></pre>
+ * <p/>
+ * It is suggested to make the initialization as early as possible in your app. Please check {@link
+ * EmojiCompat.Config} for more configuration parameters. Once {@link #init(EmojiCompat.Config)} is
+ * called a singleton instance will be created. Any call after that will not create a new instance
+ * and will return immediately.
+ * <p/>
+ * During initialization information about emojis is loaded on a background thread. Before the
+ * EmojiCompat instance is initialized, calls to functions such as {@link
+ * EmojiCompat#process(CharSequence)} will throw an exception. You can use the {@link InitCallback}
+ * class to be informed about the state of initialization.
+ * <p/>
+ * 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.
+ * <p/>
+ * <pre><code>CharSequence processedSequence = EmojiCompat.get().process("some string")</pre>
+ */
+@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.
+ * <p/>
+ * 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()
+ */
+ 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_GROUP_PREFIX)
+ @IntDef({LOAD_STATE_DEFAULT, LOAD_STATE_LOADING, LOAD_STATE_SUCCEEDED, LOAD_STATE_FAILED})
+ @Retention(RetentionPolicy.SOURCE)
+ public @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_GROUP_PREFIX)
+ @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_GROUP_PREFIX)
+ @IntDef({LOAD_STRATEGY_DEFAULT, LOAD_STRATEGY_MANUAL})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface LoadStrategy {
+ }
+
+ /**
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ static final int EMOJI_COUNT_UNLIMITED = Integer.MAX_VALUE;
+
+ private static final Object INSTANCE_LOCK = new Object();
+
+ @GuardedBy("INSTANCE_LOCK")
+ private static volatile EmojiCompat sInstance;
+
+ private final ReadWriteLock mInitLock;
+
+ @GuardedBy("mInitLock")
+ private final Set<InitCallback> mInitCallbacks;
+
+ @GuardedBy("mInitLock")
+ @LoadState
+ private int mLoadState;
+
+ /**
+ * Handler with main looper to run the callbacks on.
+ */
+ private final Handler mMainHandler;
+
+ /**
+ * Helper class for pre 19 compatibility.
+ */
+ private final CompatInternal mHelper;
+
+ /**
+ * Metadata loader instance given in the Config instance.
+ */
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ final MetadataRepoLoader mMetadataLoader;
+
+ /**
+ * @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 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 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<>();
+ 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 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")
+ public static EmojiCompat init(@NonNull final Config config) {
+ if (sInstance == null) {
+ synchronized (INSTANCE_LOCK) {
+ if (sInstance == null) {
+ sInstance = new EmojiCompat(config);
+ }
+ }
+ }
+ return sInstance;
+ }
+
+ /**
+ * 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
+ */
+ @SuppressWarnings("GuardedBy")
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ @VisibleForTesting
+ public static EmojiCompat reset(@NonNull final Config config) {
+ synchronized (INSTANCE_LOCK) {
+ sInstance = new EmojiCompat(config);
+ }
+ return sInstance;
+ }
+
+ /**
+ * Used by the tests to reset EmojiCompat with a new singleton instance.
+ *
+ * @hide
+ */
+ @SuppressWarnings("GuardedBy")
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ @VisibleForTesting
+ public static EmojiCompat reset(final EmojiCompat emojiCompat) {
+ synchronized (INSTANCE_LOCK) {
+ sInstance = emojiCompat;
+ }
+ return sInstance;
+ }
+
+ /**
+ * 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)}
+ */
+ public static EmojiCompat get() {
+ synchronized (INSTANCE_LOCK) {
+ Preconditions.checkState(sInstance != null,
+ "EmojiCompat is not initialized. Please call EmojiCompat.init() first");
+ return sInstance;
+ }
+ }
+
+ /**
+ * 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:
+ * <ul>
+ * <li>the metadata is already loaded successfully and {@link #getLoadState()} is
+ * {@link #LOAD_STATE_SUCCEEDED}.
+ * </li>
+ * <li>a previous load attempt is not finished yet and {@link #getLoadState()} is
+ * {@link #LOAD_STATE_LOADING}.</li>
+ * </ul>
+ *
+ * @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<InitCallback> 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<InitCallback> 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.
+ * <p/>
+ * 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)
+ */
+ 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.
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ boolean isEmojiSpanIndicatorEnabled() {
+ return mEmojiSpanIndicatorEnabled;
+ }
+
+ /**
+ * @return whether a background should be drawn for the emoji.
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ @ColorInt int getEmojiSpanIndicatorColor() {
+ return mEmojiSpanIndicatorColor;
+ }
+
+ /**
+ * 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.
+ * <p/>
+ * If there is a selection where selection start is not equal to selection end, does not
+ * delete.
+ * <p/>
+ * 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,
+ 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.
+ * <p/>
+ * If there is a selection where selection start is not equal to selection end, does not
+ * delete.
+ * <p/>
+ * 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}.
+ *
+ * @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
+ */
+ 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}.
+ *
+ * @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
+ */
+ 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);
+ }
+
+ /**
+ * 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)
+ */
+ @CheckResult
+ public CharSequence process(@NonNull 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.
+ //noinspection ConstantConditions
+ @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.
+ * <p>
+ * <ul>
+ * <li>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.</li>
+ * <li>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. </li>
+ * <li>If the given input is a Spannable, the same instance is returned. </li>
+ * </ul>
+ * 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()}
+ */
+ @CheckResult
+ public CharSequence process(@NonNull 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.
+ * <p>
+ * <ul>
+ * <li>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.</li>
+ * <li>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. </li>
+ * <li>If the given input is a Spannable, the same instance is returned. </li>
+ * </ul>
+ * 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}
+ */
+ @CheckResult
+ public CharSequence process(@NonNull 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.
+ * <p>
+ * <ul>
+ * <li>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.</li>
+ * <li>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. </li>
+ * <li>If the given input is a Spannable, the same instance is returned. </li>
+ * </ul>
+ * 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}
+ */
+ @CheckResult
+ public CharSequence process(@NonNull 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
+ //noinspection ConstantConditions
+ if (charSequence == null) {
+ return charSequence;
+ }
+
+ 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.
+ *
+ * @param outAttrs EditorInfo instance passed to
+ * {@link android.widget.TextView#onCreateInputConnection(EditorInfo)}
+ *
+ * @see #EDITOR_INFO_METAVERSION_KEY
+ * @see #EDITOR_INFO_REPLACE_ALL_KEY
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ public void updateEditorInfoAttrs(@NonNull final EditorInfo outAttrs) {
+ //noinspection ConstantConditions
+ if (isInitialized() && outAttrs != null && outAttrs.extras != null) {
+ mHelper.updateEditorInfoAttrs(outAttrs);
+ }
+ }
+
+ /**
+ * Factory class that creates the EmojiSpans. By default it creates {@link TypefaceEmojiSpan}.
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ @RequiresApi(19)
+ static class SpanFactory {
+ /**
+ * Create EmojiSpan instance.
+ *
+ * @param metadata EmojiMetadata instance
+ *
+ * @return EmojiSpan instance
+ */
+ EmojiSpan createSpan(@NonNull final EmojiMetadata metadata) {
+ return new TypefaceEmojiSpan(metadata);
+ }
+ }
+
+ /**
+ * 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(@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
+ */
+ 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.
+ *
+ * <p>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.
+ *
+ * <p>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.
+ *
+ * <p>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.
+ *
+ * <p>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.
+ *
+ * <p>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 */
+ final MetadataRepoLoader mMetadataLoader;
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ boolean mReplaceAll;
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ boolean mUseEmojiAsDefaultStyle;
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ int[] mEmojiAsDefaultStyleExceptions;
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ Set<InitCallback> 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 */
+ GlyphChecker mGlyphChecker = new EmojiProcessor.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
+ */
+ 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
+ */
+ 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
+ */
+ 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
+ */
+ 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.
+ */
+ public Config setUseEmojiAsDefaultStyle(final boolean useEmojiAsDefaultStyle,
+ @Nullable final List<Integer> 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
+ */
+ 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)
+ */
+ 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.
+ * <p/>
+ * 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}.
+ * <p/>
+ * 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.
+ * <pre>
+ * 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());
+ * </pre>
+ *
+ * @param strategy one of {@link EmojiCompat#LOAD_STRATEGY_DEFAULT},
+ * {@link EmojiCompat#LOAD_STRATEGY_MANUAL}
+ *
+ */
+ public Config setMetadataLoadStrategy(@LoadStrategy int strategy) {
+ mMetadataLoadStrategy = strategy;
+ 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}.
+ */
+ protected final MetadataRepoLoader getMetadataRepoLoader() {
+ return mMetadataLoader;
+ }
+ }
+
+ /**
+ * Runnable to call success/failure case for the listeners.
+ */
+ private static class ListenerDispatcher implements Runnable {
+ private final List<InitCallback> 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<InitCallback> initCallbacks,
+ @LoadState final int loadState) {
+ this(initCallbacks, loadState, null);
+ }
+
+ ListenerDispatcher(@NonNull final Collection<InitCallback> 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;
+ }
+
+ 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 "";
+ }
+ }
+
+ @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,
+ new SpanFactory(),
+ mEmojiCompat.mGlyphChecker,
+ mEmojiCompat.mUseEmojiAsDefaultStyle,
+ mEmojiCompat.mEmojiAsDefaultStyleExceptions);
+
+ mEmojiCompat.onMetadataLoadSuccess();
+ }
+
+ @Override
+ boolean hasEmojiGlyph(@NonNull CharSequence sequence) {
+ return mProcessor.getEmojiMetadata(sequence) != null;
+ }
+
+ @Override
+ boolean hasEmojiGlyph(@NonNull CharSequence sequence, int metadataVersion) {
+ final EmojiMetadata emojiMetadata = mProcessor.getEmojiMetadata(sequence);
+ return emojiMetadata != null && emojiMetadata.getCompatAdded() <= metadataVersion;
+ }
+
+ @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/emoji2/emoji2/src/main/java/androidx/emoji2/text/EmojiMetadata.java b/emoji2/emoji2/src/main/java/androidx/emoji2/text/EmojiMetadata.java
new file mode 100644
index 0000000..ba46f82
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/text/EmojiMetadata.java
@@ -0,0 +1,235 @@
+/*
+ * 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_GROUP_PREFIX;
+
+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.text.emoji.flatbuffer.MetadataItem;
+import androidx.text.emoji.flatbuffer.MetadataList;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Information about a single emoji.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+@AnyThread
+@RequiresApi(19)
+public class EmojiMetadata {
+ /**
+ * Defines whether the system can render the emoji.
+ */
+ @IntDef({HAS_GLYPH_UNKNOWN, HAS_GLYPH_ABSENT, HAS_GLYPH_EXISTS})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface HasGlyph {
+ }
+
+ /**
+ * Not calculated on device yet.
+ */
+ public static final int HAS_GLYPH_UNKNOWN = 0;
+
+ /**
+ * Device cannot render the emoji.
+ */
+ public static final int HAS_GLYPH_ABSENT = 1;
+
+ /**
+ * Device can render the emoji.
+ */
+ public static final int HAS_GLYPH_EXISTS = 2;
+
+ /**
+ * @see #getMetadataItem()
+ */
+ private static final ThreadLocal<MetadataItem> sMetadataItem = new ThreadLocal<>();
+
+ /**
+ * Index of the EmojiMetadata in {@link MetadataList}.
+ */
+ private final int mIndex;
+
+ /**
+ * MetadataRepo that holds this instance.
+ */
+ private final MetadataRepo mMetadataRepo;
+
+ /**
+ * Whether the system can render the emoji. Calculated at runtime on the device.
+ */
+ @HasGlyph
+ private volatile int mHasGlyph = HAS_GLYPH_UNKNOWN;
+
+ EmojiMetadata(@NonNull final MetadataRepo metadataRepo, @IntRange(from = 0) final int index) {
+ mMetadataRepo = metadataRepo;
+ mIndex = index;
+ }
+
+ /**
+ * Draws the emoji represented by this EmojiMetadata 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 this metadata
+ */
+ public Typeface getTypeface() {
+ return mMetadataRepo.getTypeface();
+ }
+
+ /**
+ * @return a ThreadLocal instance of MetadataItem for this EmojiMetadata
+ */
+ 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 EmojiMetadata, 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 EmojiMetadata classes in the same thread.
+ mMetadataRepo.getMetadataList().list(result, mIndex);
+ return result;
+ }
+
+ /**
+ * @return unique id for the emoji
+ */
+ public int getId() {
+ return getMetadataItem().id();
+ }
+
+ /**
+ * @return width of the emoji image
+ */
+ public short getWidth() {
+ return getMetadataItem().width();
+ }
+
+ /**
+ * @return height of the emoji image
+ */
+ public short getHeight() {
+ return getMetadataItem().height();
+ }
+
+ /**
+ * @return in which metadata version the emoji was added to metadata
+ */
+ public short getCompatAdded() {
+ return getMetadataItem().compatAdded();
+ }
+
+ /**
+ * @return first SDK that the support for this emoji was added
+ */
+ public short getSdkAdded() {
+ return getMetadataItem().sdkAdded();
+ }
+
+ /**
+ * @return whether the emoji is in Emoji Presentation by default (without emoji
+ * style selector 0xFE0F)
+ */
+ @HasGlyph
+ public int getHasGlyph() {
+ return mHasGlyph;
+ }
+
+ /**
+ * Set whether the system can render the emoji.
+ *
+ * @param hasGlyph {@code true} if system can render the emoji
+ */
+ public void setHasGlyph(boolean hasGlyph) {
+ mHasGlyph = hasGlyph ? HAS_GLYPH_EXISTS : HAS_GLYPH_ABSENT;
+ }
+
+ /**
+ * @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();
+ }
+
+ @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/emoji2/emoji2/src/main/java/androidx/emoji2/text/EmojiProcessor.java b/emoji2/emoji2/src/main/java/androidx/emoji2/text/EmojiProcessor.java
new file mode 100644
index 0000000..d8c4765
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/text/EmojiProcessor.java
@@ -0,0 +1,834 @@
+/*
+ * 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_GROUP_PREFIX;
+
+import android.os.Build;
+import android.text.Editable;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.TextPaint;
+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 androidx.core.graphics.PaintCompat;
+import androidx.emoji2.widget.SpannableBuilder;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Processes the CharSequence and adds the emojis.
+ *
+ * @hide
+ */
+@AnyThread
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+@RequiresApi(19)
+final class EmojiProcessor {
+
+ /**
+ * State transition commands.
+ */
+ @IntDef({ACTION_ADVANCE_BOTH, ACTION_ADVANCE_END, ACTION_FLUSH})
+ @Retention(RetentionPolicy.SOURCE)
+ private @interface Action {
+ }
+
+ /**
+ * 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;
+
+ /**
+ * Factory used to create EmojiSpans.
+ */
+ private final EmojiCompat.SpanFactory mSpanFactory;
+
+ /**
+ * Emoji metadata repository.
+ */
+ private final MetadataRepo mMetadataRepo;
+
+ /**
+ * Utility class that checks if the system can render a given glyph.
+ */
+ private EmojiCompat.GlyphChecker mGlyphChecker;
+
+ /**
+ * @see EmojiCompat.Config#setUseEmojiAsDefaultStyle(boolean)
+ */
+ private final boolean mUseEmojiAsDefaultStyle;
+
+ /**
+ * @see EmojiCompat.Config#setUseEmojiAsDefaultStyle(boolean, List)
+ */
+ 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
+ ) {
+ mSpanFactory = spanFactory;
+ mMetadataRepo = metadataRepo;
+ mGlyphChecker = glyphChecker;
+ mUseEmojiAsDefaultStyle = useEmojiAsDefaultStyle;
+ mEmojiAsDefaultStyleExceptions = emojiAsDefaultStyleExceptions;
+ }
+
+ EmojiMetadata getEmojiMetadata(@NonNull final CharSequence charSequence) {
+ final ProcessorSm sm = new ProcessorSm(mMetadataRepo.getRootNode(),
+ mUseEmojiAsDefaultStyle, mEmojiAsDefaultStyleExceptions);
+ final int end = charSequence.length();
+ int currentOffset = 0;
+
+ while (currentOffset < end) {
+ final int codePoint = Character.codePointAt(charSequence, currentOffset);
+ final int action = sm.check(codePoint);
+ if (action != ACTION_ADVANCE_END) {
+ return null;
+ }
+ currentOffset += Character.charCount(codePoint);
+ }
+
+ if (sm.isInFlushableState()) {
+ return sm.getCurrentMetadata();
+ }
+
+ return null;
+ }
+
+ /**
+ * Checks a given CharSequence for emojis, and adds EmojiSpans if any emojis are found.
+ * <p>
+ * <ul>
+ * <li>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.</li>
+ * <li>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. </li>
+ * <li>If the given input is a Spannable, the same instance is returned. </li>
+ * </ul>
+ *
+ * @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 {
+ Spannable 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 = (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 SpannableString(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;
+ }
+ // add new ones
+ int addedCount = 0;
+ final ProcessorSm sm = new ProcessorSm(mMetadataRepo.getRootNode(),
+ mUseEmojiAsDefaultStyle, mEmojiAsDefaultStyleExceptions);
+
+ int currentOffset = start;
+ int codePoint = Character.codePointAt(charSequence, currentOffset);
+
+ while (currentOffset < end && addedCount < maxEmojiCount) {
+ 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 (replaceAll || !hasGlyph(charSequence, start, currentOffset,
+ sm.getFlushMetadata())) {
+ if (spannable == null) {
+ spannable = new SpannableString(charSequence);
+ }
+ addEmoji(spannable, sm.getFlushMetadata(), start, currentOffset);
+ 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) {
+ if (replaceAll || !hasGlyph(charSequence, start, currentOffset,
+ sm.getCurrentMetadata())) {
+ if (spannable == null) {
+ spannable = new SpannableString(charSequence);
+ }
+ addEmoji(spannable, sm.getCurrentMetadata(), start, currentOffset);
+ addedCount++;
+ }
+ }
+ return spannable == null ? charSequence : spannable;
+ } finally {
+ if (isSpannableBuilder) {
+ ((SpannableBuilder) charSequence).endBatchEdit();
+ }
+ }
+ }
+
+ /**
+ * 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.
+ * <p/>
+ * 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,
+ 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(final Editable content, 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.
+ * <p/>
+ * 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(KeyEvent event) {
+ return !KeyEvent.metaStateHasNoModifiers(event.getMetaState());
+ }
+
+ private void addEmoji(@NonNull final Spannable spannable, final EmojiMetadata metadata,
+ final int start, final int end) {
+ final EmojiSpan span = mSpanFactory.createSpan(metadata);
+ spannable.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ /**
+ * 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 metadata EmojiMetadata 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 EmojiMetadata metadata) {
+ // if the existence is not calculated yet
+ if (metadata.getHasGlyph() == EmojiMetadata.HAS_GLYPH_UNKNOWN) {
+ final boolean hasGlyph = mGlyphChecker.hasGlyph(charSequence, start, end,
+ metadata.getSdkAdded());
+ metadata.setHasGlyph(hasGlyph);
+ }
+
+ return metadata.getHasGlyph() == EmojiMetadata.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
+ */
+ EmojiMetadata getFlushMetadata() {
+ return mFlushNode.getData();
+ }
+
+ /**
+ * @return current pointer to the metadata node in the trie
+ */
+ EmojiMetadata 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.
+ */
+ private static final class CodepointIndexFinder {
+ private static final int INVALID_INDEX = -1;
+
+ /**
+ * 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;
+ }
+ }
+ }
+
+ /**
+ * Utility class that checks if the system can render a given glyph.
+ *
+ * @hide
+ */
+ @AnyThread
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ public static 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<StringBuilder> 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/emoji2/emoji2/src/main/java/androidx/emoji2/text/EmojiSpan.java b/emoji2/emoji2/src/main/java/androidx/emoji2/text/EmojiSpan.java
new file mode 100644
index 0000000..70dd537
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/text/EmojiSpan.java
@@ -0,0 +1,142 @@
+/*
+ * 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_GROUP_PREFIX;
+
+import android.graphics.Paint;
+import android.text.style.ReplacementSpan;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.VisibleForTesting;
+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.
+ */
+ private final EmojiMetadata mMetadata;
+
+ /**
+ * 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 metadata information about the emoji, cannot be {@code null}
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ EmojiSpan(@NonNull final EmojiMetadata metadata) {
+ Preconditions.checkNotNull(metadata, "metadata cannot be null");
+ mMetadata = metadata;
+ }
+
+ @Override
+ public int getSize(@NonNull final Paint paint, final CharSequence text, final int start,
+ final int end, final Paint.FontMetricsInt fm) {
+ paint.getFontMetricsInt(mTmpFontMetrics);
+ final int fontHeight = Math.abs(mTmpFontMetrics.descent - mTmpFontMetrics.ascent);
+
+ mRatio = fontHeight * 1.0f / mMetadata.getHeight();
+ mHeight = (short) (mMetadata.getHeight() * mRatio);
+ mWidth = (short) (mMetadata.getWidth() * mRatio);
+
+ if (fm != null) {
+ fm.ascent = mTmpFontMetrics.ascent;
+ fm.descent = mTmpFontMetrics.descent;
+ fm.top = mTmpFontMetrics.top;
+ fm.bottom = mTmpFontMetrics.bottom;
+ }
+
+ return mWidth;
+ }
+
+ /**
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ final EmojiMetadata getMetadata() {
+ return mMetadata;
+ }
+
+ /**
+ * @return width of the span
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ final int getWidth() {
+ return mWidth;
+ }
+
+ /**
+ * @return height of the span
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ final int getHeight() {
+ return mHeight;
+ }
+
+ /**
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ final float getRatio() {
+ return mRatio;
+ }
+
+ /**
+ * @return unique id for the emoji that this EmojiSpan is used for
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ @VisibleForTesting
+ public final int getId() {
+ return getMetadata().getId();
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java b/emoji2/emoji2/src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java
new file mode 100644
index 0000000..4ade79f
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/text/FontRequestEmojiCompatConfig.java
@@ -0,0 +1,363 @@
+/*
+ * 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.HandlerThread;
+import android.os.Process;
+import android.os.SystemClock;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.core.graphics.TypefaceCompatUtil;
+import androidx.core.provider.FontRequest;
+import androidx.core.provider.FontsContractCompat;
+import androidx.core.provider.FontsContractCompat.FontFamilyResult;
+import androidx.core.util.Preconditions;
+
+import java.nio.ByteBuffer;
+
+/**
+ * {@link EmojiCompat.Config} implementation that asynchronously fetches the required font and the
+ * metadata using a {@link FontRequest}. FontRequest should be constructed to fetch an EmojiCompat
+ * compatible emoji font.
+ * <p/>
+ */
+public class FontRequestEmojiCompatConfig extends EmojiCompat.Config {
+
+ /**
+ * Retry policy used when the font provider is not ready to give the font file.
+ *
+ * To control the thread the retries are handled on, see
+ * {@link FontRequestEmojiCompatConfig#setHandler}.
+ */
+ public abstract static class RetryPolicy {
+ /**
+ * Called each time the metadata loading fails.
+ *
+ * This is primarily due to a pending download of the font.
+ * If a value larger than zero is returned, metadata loader will retry after the given
+ * milliseconds.
+ * <br />
+ * If {@code zero} is returned, metadata loader will retry immediately.
+ * <br/>
+ * If a value less than 0 is returned, the metadata loader will stop retrying and
+ * EmojiCompat will get into {@link EmojiCompat#LOAD_STATE_FAILED} state.
+ * <p/>
+ * Note that the retry may happen earlier than you specified if the font provider notifies
+ * that the download is completed.
+ *
+ * @return long milliseconds to wait until next retry
+ */
+ public abstract long getRetryDelay();
+ }
+
+ /**
+ * A retry policy implementation that doubles the amount of time in between retries.
+ *
+ * If downloading hasn't finish within given amount of time, this policy give up and the
+ * EmojiCompat will get into {@link EmojiCompat#LOAD_STATE_FAILED} state.
+ */
+ public static class ExponentialBackoffRetryPolicy extends RetryPolicy {
+ private final long mTotalMs;
+ private long mRetryOrigin;
+
+ /**
+ * @param totalMs A total amount of time to wait in milliseconds.
+ */
+ public ExponentialBackoffRetryPolicy(long totalMs) {
+ mTotalMs = totalMs;
+ }
+
+ @Override
+ public long getRetryDelay() {
+ if (mRetryOrigin == 0) {
+ mRetryOrigin = SystemClock.uptimeMillis();
+ // Since download may be completed after getting query result and before registering
+ // observer, requesting later at the same time.
+ return 0;
+ } else {
+ // Retry periodically since we can't trust notify change event. Some font provider
+ // may not notify us.
+ final long elapsedMillis = SystemClock.uptimeMillis() - mRetryOrigin;
+ if (elapsedMillis > mTotalMs) {
+ return -1; // Give up since download hasn't finished in 10 min.
+ }
+ // Wait until the same amount of the time from the first scheduled time, but adjust
+ // the minimum request interval is 1 sec and never exceeds 10 min in total.
+ return Math.min(Math.max(elapsedMillis, 1000), mTotalMs - elapsedMillis);
+ }
+ }
+ };
+
+ /**
+ * @param context Context instance, cannot be {@code null}
+ * @param request {@link FontRequest} to fetch the font asynchronously, cannot be {@code null}
+ */
+ public FontRequestEmojiCompatConfig(@NonNull Context context, @NonNull FontRequest request) {
+ super(new FontRequestMetadataLoader(context, request, DEFAULT_FONTS_CONTRACT));
+ }
+
+ /**
+ * @hide
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+ public FontRequestEmojiCompatConfig(@NonNull Context context, @NonNull FontRequest request,
+ @NonNull FontProviderHelper fontProviderHelper) {
+ super(new FontRequestMetadataLoader(context, request, fontProviderHelper));
+ }
+
+ /**
+ * Sets the custom handler to be used for initialization.
+ *
+ * Since font fetch take longer time, the metadata loader will fetch the fonts on the background
+ * thread. You can pass your own handler for this background fetching. This handler is also used
+ * for retrying.
+ *
+ * @param handler A {@link Handler} to be used for initialization. Can be {@code null}. In case
+ * of {@code null}, the metadata loader creates own {@link HandlerThread} for
+ * initialization.
+ */
+ public FontRequestEmojiCompatConfig setHandler(Handler handler) {
+ ((FontRequestMetadataLoader) getMetadataRepoLoader()).setHandler(handler);
+ return this;
+ }
+
+ /**
+ * Sets the retry policy.
+ *
+ * {@see RetryPolicy}
+ * @param policy The policy to be used when the font provider is not ready to give the font
+ * file. Can be {@code null}. In case of {@code null}, the metadata loader never
+ * retries.
+ */
+ public FontRequestEmojiCompatConfig setRetryPolicy(RetryPolicy policy) {
+ ((FontRequestMetadataLoader) getMetadataRepoLoader()).setRetryPolicy(policy);
+ return this;
+ }
+
+ /**
+ * MetadataRepoLoader implementation that uses FontsContractCompat and TypefaceCompat to load a
+ * given FontRequest.
+ */
+ private static class FontRequestMetadataLoader implements EmojiCompat.MetadataRepoLoader {
+ private final Context mContext;
+ private final FontRequest mRequest;
+ private final FontProviderHelper mFontProviderHelper;
+
+ private final Object mLock = new Object();
+ @GuardedBy("mLock")
+ private Handler mHandler;
+ @GuardedBy("mLock")
+ private HandlerThread mThread;
+ @GuardedBy("mLock")
+ private @Nullable RetryPolicy mRetryPolicy;
+
+ // Following three variables must be touched only on the thread associated with mHandler.
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ EmojiCompat.MetadataRepoLoaderCallback mCallback;
+ private ContentObserver mObserver;
+ private Runnable mHandleMetadataCreationRunner;
+
+ FontRequestMetadataLoader(@NonNull Context context, @NonNull FontRequest request,
+ @NonNull FontProviderHelper fontProviderHelper) {
+ Preconditions.checkNotNull(context, "Context cannot be null");
+ Preconditions.checkNotNull(request, "FontRequest cannot be null");
+ mContext = context.getApplicationContext();
+ mRequest = request;
+ mFontProviderHelper = fontProviderHelper;
+ }
+
+ public void setHandler(Handler handler) {
+ synchronized (mLock) {
+ mHandler = handler;
+ }
+ }
+
+ public void setRetryPolicy(RetryPolicy policy) {
+ synchronized (mLock) {
+ mRetryPolicy = policy;
+ }
+ }
+
+ @Override
+ @RequiresApi(19)
+ public void load(@NonNull final EmojiCompat.MetadataRepoLoaderCallback loaderCallback) {
+ Preconditions.checkNotNull(loaderCallback, "LoaderCallback cannot be null");
+ synchronized (mLock) {
+ if (mHandler == null) {
+ // Developer didn't give a thread for fetching. Create our own one.
+ mThread = new HandlerThread("emojiCompat", Process.THREAD_PRIORITY_BACKGROUND);
+ mThread.start();
+ mHandler = new Handler(mThread.getLooper());
+ }
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mCallback = loaderCallback;
+ createMetadata();
+ }
+ });
+ }
+ }
+
+ private FontsContractCompat.FontInfo retrieveFontInfo() {
+ final FontsContractCompat.FontFamilyResult result;
+ try {
+ result = mFontProviderHelper.fetchFonts(mContext, mRequest);
+ } catch (NameNotFoundException e) {
+ throw new RuntimeException("provider not found", e);
+ }
+ if (result.getStatusCode() != FontsContractCompat.FontFamilyResult.STATUS_OK) {
+ throw new RuntimeException("fetchFonts failed (" + result.getStatusCode() + ")");
+ }
+ final FontsContractCompat.FontInfo[] fonts = result.getFonts();
+ if (fonts == null || fonts.length == 0) {
+ throw new RuntimeException("fetchFonts failed (empty result)");
+ }
+ return fonts[0]; // Assuming the GMS Core provides only one font file.
+ }
+
+ // Must be called on the mHandler.
+ @RequiresApi(19)
+ private void scheduleRetry(Uri uri, long waitMs) {
+ synchronized (mLock) {
+ if (mObserver == null) {
+ mObserver = new ContentObserver(mHandler) {
+ @Override
+ public void onChange(boolean selfChange, Uri uri) {
+ createMetadata();
+ }
+ };
+ mFontProviderHelper.registerObserver(mContext, uri, mObserver);
+ }
+ if (mHandleMetadataCreationRunner == null) {
+ mHandleMetadataCreationRunner = new Runnable() {
+ @Override
+ public void run() {
+ createMetadata();
+ }
+ };
+ }
+ mHandler.postDelayed(mHandleMetadataCreationRunner, waitMs);
+ }
+ }
+
+ // Must be called on the mHandler.
+ private void cleanUp() {
+ mCallback = null;
+ if (mObserver != null) {
+ mFontProviderHelper.unregisterObserver(mContext, mObserver);
+ mObserver = null;
+ }
+ synchronized (mLock) {
+ mHandler.removeCallbacks(mHandleMetadataCreationRunner);
+ if (mThread != null) {
+ mThread.quit();
+ }
+ mHandler = null;
+ mThread = null;
+ }
+ }
+
+ // Must be called on the mHandler.
+ @RequiresApi(19)
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ void createMetadata() {
+ if (mCallback == null) {
+ return; // Already handled or cancelled. Do nothing.
+ }
+ try {
+ final FontsContractCompat.FontInfo font = retrieveFontInfo();
+
+ final int resultCode = font.getResultCode();
+ if (resultCode == FontsContractCompat.Columns.RESULT_CODE_FONT_UNAVAILABLE) {
+ // The font provider is now downloading. Ask RetryPolicy for when to retry next.
+ synchronized (mLock) {
+ if (mRetryPolicy != null) {
+ final long delayMs = mRetryPolicy.getRetryDelay();
+ if (delayMs >= 0) {
+ scheduleRetry(font.getUri(), delayMs);
+ return;
+ }
+ }
+ }
+ }
+
+ if (resultCode != FontsContractCompat.Columns.RESULT_CODE_OK) {
+ throw new RuntimeException("fetchFonts result is not OK. (" + resultCode + ")");
+ }
+
+ // TODO: Good to add new API to create Typeface from FD not to open FD twice.
+ final Typeface typeface = mFontProviderHelper.buildTypeface(mContext, font);
+ final ByteBuffer buffer = TypefaceCompatUtil.mmap(mContext, null, font.getUri());
+ if (buffer == null) {
+ throw new RuntimeException("Unable to open file.");
+ }
+ mCallback.onLoaded(MetadataRepo.create(typeface, buffer));
+ cleanUp();
+ } catch (Throwable t) {
+ mCallback.onFailed(t);
+ cleanUp();
+ }
+ }
+ }
+
+ /**
+ * Delegate class for mocking FontsContractCompat.fetchFonts.
+ * @hide
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+ public static class FontProviderHelper {
+ /** Calls FontsContractCompat.fetchFonts. */
+ public FontFamilyResult fetchFonts(@NonNull Context context,
+ @NonNull FontRequest request) throws NameNotFoundException {
+ return FontsContractCompat.fetchFonts(context, null /* cancellation signal */, request);
+ }
+
+ /** Calls FontsContractCompat.buildTypeface. */
+ public Typeface buildTypeface(@NonNull Context context,
+ @NonNull FontsContractCompat.FontInfo font) throws NameNotFoundException {
+ return FontsContractCompat.buildTypeface(context, null /* cancellation signal */,
+ new FontsContractCompat.FontInfo[] { font });
+ }
+
+ /** Calls Context.getContentObserver().registerObserver */
+ public void registerObserver(@NonNull Context context, @NonNull Uri uri,
+ @NonNull ContentObserver observer) {
+ context.getContentResolver().registerContentObserver(
+ uri, false /* notifyForDescendants */, observer);
+
+ }
+ /** Calls Context.getContentObserver().unregisterObserver */
+ public void unregisterObserver(@NonNull Context context,
+ @NonNull ContentObserver observer) {
+ context.getContentResolver().unregisterContentObserver(observer);
+ }
+ };
+
+ private static final FontProviderHelper DEFAULT_FONTS_CONTRACT = new FontProviderHelper();
+
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/text/MetadataListReader.java b/emoji2/emoji2/src/main/java/androidx/emoji2/text/MetadataListReader.java
new file mode 100644
index 0000000..f9f397f
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/text/MetadataListReader.java
@@ -0,0 +1,349 @@
+/*
+ * 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_GROUP_PREFIX;
+
+import android.content.res.AssetManager;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntRange;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.text.emoji.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(LIBRARY_GROUP_PREFIX)
+@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 byte[] mByteArray;
+ private final ByteBuffer mByteBuffer;
+ private final 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(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 ByteBuffer mByteBuffer;
+
+ /**
+ * Constructs the reader with the given ByteBuffer.
+ *
+ * @param byteBuffer ByteBuffer to read from
+ */
+ ByteBufferReader(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/emoji2/emoji2/src/main/java/androidx/emoji2/text/MetadataRepo.java b/emoji2/emoji2/src/main/java/androidx/emoji2/text/MetadataRepo.java
new file mode 100644
index 0000000..2f5ea66
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/text/MetadataRepo.java
@@ -0,0 +1,246 @@
+/*
+ * 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_GROUP_PREFIX;
+
+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.util.Preconditions;
+import androidx.text.emoji.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;
+
+ /**
+ * MetadataList that contains the emoji metadata.
+ */
+ private final MetadataList mMetadataList;
+
+ /**
+ * char presentation of all EmojiMetadata'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 char[] mEmojiCharArray;
+
+ /**
+ * Empty root node of the trie.
+ */
+ private final Node mRootNode;
+
+ /**
+ * Typeface to be used to render emojis.
+ */
+ private final Typeface mTypeface;
+
+ /**
+ * Constructor used for tests.
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ MetadataRepo() {
+ mTypeface = null;
+ mMetadataList = null;
+ mRootNode = new Node(DEFAULT_ROOT_SIZE);
+ mEmojiCharArray = new char[0];
+ }
+
+ /**
+ * 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 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
+ */
+ public static MetadataRepo create(@NonNull final Typeface typeface,
+ @NonNull final InputStream inputStream) throws IOException {
+ return new MetadataRepo(typeface, MetadataListReader.read(inputStream));
+ }
+
+ /**
+ * 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
+ */
+ public static MetadataRepo create(@NonNull final Typeface typeface,
+ @NonNull final ByteBuffer byteBuffer) throws IOException {
+ return new MetadataRepo(typeface, MetadataListReader.read(byteBuffer));
+ }
+
+ /**
+ * 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
+ */
+ public static MetadataRepo create(@NonNull final AssetManager assetManager,
+ final String assetPath) throws IOException {
+ final Typeface typeface = Typeface.createFromAsset(assetManager, assetPath);
+ return new MetadataRepo(typeface, MetadataListReader.read(assetManager, assetPath));
+ }
+
+ /**
+ * 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 EmojiMetadata metadata = new EmojiMetadata(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
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ Typeface getTypeface() {
+ return mTypeface;
+ }
+
+ /**
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ int getMetadataVersion() {
+ return mMetadataList.version();
+ }
+
+ /**
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ Node getRootNode() {
+ return mRootNode;
+ }
+
+ /**
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ public char[] getEmojiCharArray() {
+ return mEmojiCharArray;
+ }
+
+ /**
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ public MetadataList getMetadataList() {
+ return mMetadataList;
+ }
+
+ /**
+ * Add an EmojiMetadata to the index.
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ @VisibleForTesting
+ void put(@NonNull final EmojiMetadata 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 EmojiMetadata. A single codepoint
+ * emoji is represented by a child of the root node.
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ static class Node {
+ private final SparseArray<Node> mChildren;
+ private EmojiMetadata 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 EmojiMetadata getData() {
+ return mData;
+ }
+
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ void put(@NonNull final EmojiMetadata 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/emoji2/emoji2/src/main/java/androidx/emoji2/text/TypefaceEmojiSpan.java b/emoji2/emoji2/src/main/java/androidx/emoji2/text/TypefaceEmojiSpan.java
new file mode 100644
index 0000000..2c3ab58
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/text/TypefaceEmojiSpan.java
@@ -0,0 +1,72 @@
+/*
+ * 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_GROUP_PREFIX;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.text.TextPaint;
+
+import androidx.annotation.IntRange;
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+
+/**
+ * EmojiSpan subclass used to render emojis using Typeface.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+@RequiresApi(19)
+public final class TypefaceEmojiSpan extends EmojiSpan {
+
+ /**
+ * Paint object used to draw a background in debug mode.
+ */
+ private static Paint sDebugPaint;
+
+ /**
+ * Default constructor.
+ *
+ * @param metadata metadata representing the emoji that this span will draw
+ */
+ public TypefaceEmojiSpan(final EmojiMetadata metadata) {
+ super(metadata);
+ }
+
+ @Override
+ public void draw(@NonNull final Canvas canvas, 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) {
+ if (EmojiCompat.get().isEmojiSpanIndicatorEnabled()) {
+ canvas.drawRect(x, top , x + getWidth(), bottom, getDebugPaint());
+ }
+ getMetadata().draw(canvas, x, y, paint);
+ }
+
+ 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/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EditTextAttributeHelper.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EditTextAttributeHelper.java
new file mode 100644
index 0000000..7157c64
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EditTextAttributeHelper.java
@@ -0,0 +1,54 @@
+/*
+ * 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.widget;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.emoji2.R;
+
+/**
+ * Helper class to parse EmojiCompat EditText attributes.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+public class EditTextAttributeHelper {
+ static final int MAX_EMOJI_COUNT = Integer.MAX_VALUE;
+ private int mMaxEmojiCount;
+
+ public EditTextAttributeHelper(@NonNull View view, AttributeSet attrs, int defStyleAttr,
+ int defStyleRes) {
+ if (attrs != null) {
+ final Context context = view.getContext();
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.EmojiEditText,
+ defStyleAttr, defStyleRes);
+ mMaxEmojiCount = a.getInteger(R.styleable.EmojiEditText_maxEmojiCount, MAX_EMOJI_COUNT);
+ a.recycle();
+ }
+ }
+
+ public int getMaxEmojiCount() {
+ return mMaxEmojiCount;
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiButton.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiButton.java
new file mode 100644
index 0000000..3fb4089
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiButton.java
@@ -0,0 +1,96 @@
+/*
+ * 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.widget;
+
+import android.content.Context;
+import android.os.Build;
+import android.text.InputFilter;
+import android.util.AttributeSet;
+import android.view.ActionMode;
+import android.widget.Button;
+
+import androidx.annotation.RequiresApi;
+import androidx.core.widget.TextViewCompat;
+
+/**
+ * Button widget enhanced with emoji capability by using {@link EmojiTextViewHelper}. When used
+ * on devices running API 18 or below, this widget acts as a regular {@link Button}.
+ */
+public class EmojiButton extends Button {
+ private EmojiTextViewHelper mEmojiTextViewHelper;
+
+ /**
+ * Prevent calling {@link #init()} multiple times in case super() constructors
+ * call other constructors.
+ */
+ private boolean mInitialized;
+
+ public EmojiButton(Context context) {
+ super(context);
+ init();
+ }
+
+ public EmojiButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public EmojiButton(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+ public EmojiButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ private void init() {
+ if (!mInitialized) {
+ mInitialized = true;
+ getEmojiTextViewHelper().updateTransformationMethod();
+ }
+ }
+
+ @Override
+ public void setFilters(InputFilter[] filters) {
+ super.setFilters(getEmojiTextViewHelper().getFilters(filters));
+ }
+
+ @Override
+ public void setAllCaps(boolean allCaps) {
+ super.setAllCaps(allCaps);
+ getEmojiTextViewHelper().setAllCaps(allCaps);
+ }
+
+ private EmojiTextViewHelper getEmojiTextViewHelper() {
+ if (mEmojiTextViewHelper == null) {
+ mEmojiTextViewHelper = new EmojiTextViewHelper(this);
+ }
+ return mEmojiTextViewHelper;
+ }
+
+ /**
+ * See
+ * {@link TextViewCompat#setCustomSelectionActionModeCallback(TextView, ActionMode.Callback)}
+ */
+ @Override
+ public void setCustomSelectionActionModeCallback(ActionMode.Callback actionModeCallback) {
+ super.setCustomSelectionActionModeCallback(TextViewCompat
+ .wrapCustomSelectionActionModeCallback(this, actionModeCallback));
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiEditText.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiEditText.java
new file mode 100644
index 0000000..a97688b
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiEditText.java
@@ -0,0 +1,137 @@
+/*
+ * 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.widget;
+
+import android.content.Context;
+import android.os.Build;
+import android.text.method.KeyListener;
+import android.util.AttributeSet;
+import android.view.ActionMode;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.EditText;
+
+import androidx.annotation.IntRange;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.core.widget.TextViewCompat;
+import androidx.emoji2.text.EmojiCompat;
+
+/**
+ * EditText widget enhanced with emoji capability by using {@link EmojiEditTextHelper}. When used
+ * on devices running API 18 or below, this widget acts as a regular {@link EditText}.
+ *
+ * {@link androidx.emoji.R.attr#maxEmojiCount}
+ */
+public class EmojiEditText extends EditText {
+ private EmojiEditTextHelper mEmojiEditTextHelper;
+
+ /**
+ * Prevent calling {@link #init(AttributeSet, int, int)} multiple times in case super()
+ * constructors call other constructors.
+ */
+ private boolean mInitialized;
+
+ public EmojiEditText(Context context) {
+ super(context);
+ init(null /*attrs*/, 0 /*defStyleAttr*/, 0 /*defStyleRes*/);
+ }
+
+ public EmojiEditText(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, android.R.attr.editTextStyle, 0 /*defStyleRes*/);
+ }
+
+ public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs, defStyleAttr, 0 /*defStyleRes*/);
+ }
+
+ @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+ public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init(attrs, defStyleAttr, defStyleRes);
+ }
+
+ private void init(@Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ if (!mInitialized) {
+ mInitialized = true;
+ final EditTextAttributeHelper attrHelper = new EditTextAttributeHelper(this, attrs,
+ defStyleAttr, defStyleRes);
+ setMaxEmojiCount(attrHelper.getMaxEmojiCount());
+ setKeyListener(super.getKeyListener());
+ }
+ }
+
+ @Override
+ public void setKeyListener(@Nullable KeyListener keyListener) {
+ if (keyListener != null) {
+ keyListener = getEmojiEditTextHelper().getKeyListener(keyListener);
+ }
+ super.setKeyListener(keyListener);
+ }
+
+ @Override
+ public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
+ final InputConnection inputConnection = super.onCreateInputConnection(outAttrs);
+ return getEmojiEditTextHelper().onCreateInputConnection(inputConnection, outAttrs);
+ }
+
+ /**
+ * Set the maximum number of EmojiSpans to be added to a CharSequence. The number of spans in a
+ * CharSequence affects the performance of the EditText insert/delete operations. Insert/delete
+ * operations slow down as the number of spans increases.
+ *
+ * @param maxEmojiCount maximum number of EmojiSpans to be added to a single CharSequence,
+ * should be equal or greater than 0
+ *
+ * @see EmojiCompat#process(CharSequence, int, int, int)
+ *
+ * {@link androidx.emoji.R.attr#maxEmojiCount}
+ */
+ public void setMaxEmojiCount(@IntRange(from = 0) int maxEmojiCount) {
+ getEmojiEditTextHelper().setMaxEmojiCount(maxEmojiCount);
+ }
+
+ /**
+ * Returns the maximum number of EmojiSpans to be added to a CharSequence.
+ *
+ * @see #setMaxEmojiCount(int)
+ * @see EmojiCompat#process(CharSequence, int, int, int)
+ *
+ * {@link androidx.emoji.R.attr#maxEmojiCount}
+ */
+ public int getMaxEmojiCount() {
+ return getEmojiEditTextHelper().getMaxEmojiCount();
+ }
+
+ private EmojiEditTextHelper getEmojiEditTextHelper() {
+ if (mEmojiEditTextHelper == null) {
+ mEmojiEditTextHelper = new EmojiEditTextHelper(this);
+ }
+ return mEmojiEditTextHelper;
+ }
+
+ /**
+ * See
+ * {@link TextViewCompat#setCustomSelectionActionModeCallback(TextView, ActionMode.Callback)}
+ */
+ @Override
+ public void setCustomSelectionActionModeCallback(ActionMode.Callback actionModeCallback) {
+ super.setCustomSelectionActionModeCallback(TextViewCompat
+ .wrapCustomSelectionActionModeCallback(this, actionModeCallback));
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiEditTextHelper.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiEditTextHelper.java
new file mode 100644
index 0000000..32ee18b
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiEditTextHelper.java
@@ -0,0 +1,241 @@
+/*
+ * 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.widget;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+import android.os.Build;
+import android.text.method.KeyListener;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import androidx.annotation.IntRange;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.core.util.Preconditions;
+import androidx.emoji2.text.EmojiCompat;
+import androidx.emoji2.text.EmojiSpan;
+
+/**
+ * Utility class to enhance custom EditText widgets with {@link EmojiCompat}.
+ * <p/>
+ * <pre>
+ * public class MyEmojiEditText extends EditText {
+ * public MyEmojiEditText(Context context) {
+ * super(context);
+ * init();
+ * }
+ * // ...
+ * private void init() {
+ * super.setKeyListener(getEmojiEditTextHelper().getKeyListener(getKeyListener()));
+ * }
+ *
+ * {@literal @}Override
+ * public void setKeyListener(android.text.method.KeyListener keyListener) {
+ * super.setKeyListener(getEmojiEditTextHelper().getKeyListener(keyListener));
+ * }
+ *
+ * {@literal @}Override
+ * public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
+ * InputConnection inputConnection = super.onCreateInputConnection(outAttrs);
+ * return getEmojiEditTextHelper().onCreateInputConnection(inputConnection, outAttrs);
+ * }
+ *
+ * private EmojiEditTextHelper getEmojiEditTextHelper() {
+ * if (mEmojiEditTextHelper == null) {
+ * mEmojiEditTextHelper = new EmojiEditTextHelper(this);
+ * }
+ * return mEmojiEditTextHelper;
+ * }
+ * }
+ * </pre>
+ *
+ */
+public final class EmojiEditTextHelper {
+ private final HelperInternal mHelper;
+ private int mMaxEmojiCount = EditTextAttributeHelper.MAX_EMOJI_COUNT;
+ @EmojiCompat.ReplaceStrategy
+ private int mEmojiReplaceStrategy = EmojiCompat.REPLACE_STRATEGY_DEFAULT;
+
+ /**
+ * Default constructor.
+ *
+ * @param editText EditText instance
+ */
+ public EmojiEditTextHelper(@NonNull final EditText editText) {
+ Preconditions.checkNotNull(editText, "editText cannot be null");
+ mHelper = Build.VERSION.SDK_INT >= 19 ? new HelperInternal19(editText)
+ : new HelperInternal();
+ }
+
+ /**
+ * Set the maximum number of EmojiSpans to be added to a CharSequence. The number of spans in a
+ * CharSequence affects the performance of the EditText insert/delete operations. Insert/delete
+ * operations slow down as the number of spans increases.
+ * <p/>
+ *
+ * @param maxEmojiCount maximum number of EmojiSpans to be added to a single CharSequence,
+ * should be equal or greater than 0
+ *
+ * @see EmojiCompat#process(CharSequence, int, int, int)
+ */
+ public void setMaxEmojiCount(@IntRange(from = 0) int maxEmojiCount) {
+ Preconditions.checkArgumentNonnegative(maxEmojiCount,
+ "maxEmojiCount should be greater than 0");
+ mMaxEmojiCount = maxEmojiCount;
+ mHelper.setMaxEmojiCount(maxEmojiCount);
+ }
+
+ /**
+ * Returns the maximum number of EmojiSpans to be added to a CharSequence.
+ *
+ * @see #setMaxEmojiCount(int)
+ * @see EmojiCompat#process(CharSequence, int, int, int)
+ */
+ public int getMaxEmojiCount() {
+ return mMaxEmojiCount;
+ }
+
+ /**
+ * Attaches EmojiCompat KeyListener to the widget. Should be called from {@link
+ * TextView#setKeyListener(KeyListener)}. Existing keyListener is wrapped into EmojiCompat
+ * KeyListener. When used on devices running API 18 or below, this method returns
+ * {@code keyListener} that is given as a parameter.
+ *
+ * @param keyListener KeyListener passed into {@link TextView#setKeyListener(KeyListener)}
+ *
+ * @return a new KeyListener instance that wraps {@code keyListener}.
+ */
+ @NonNull
+ public KeyListener getKeyListener(@NonNull final KeyListener keyListener) {
+ Preconditions.checkNotNull(keyListener, "keyListener cannot be null");
+ return mHelper.getKeyListener(keyListener);
+ }
+
+ /**
+ * Updates the InputConnection with emoji support. Should be called from {@link
+ * TextView#onCreateInputConnection(EditorInfo)}. When used on devices running API 18 or below,
+ * this method returns {@code inputConnection} that is given as a parameter. If
+ * {@code inputConnection} is {@code null}, returns {@code null}.
+ *
+ * @param inputConnection InputConnection instance created by TextView
+ * @param outAttrs EditorInfo passed into
+ * {@link TextView#onCreateInputConnection(EditorInfo)}
+ *
+ * @return a new InputConnection instance that wraps {@code inputConnection}
+ */
+ @Nullable
+ public InputConnection onCreateInputConnection(@Nullable final InputConnection inputConnection,
+ @NonNull final EditorInfo outAttrs) {
+ if (inputConnection == null) return null;
+ return mHelper.onCreateInputConnection(inputConnection, outAttrs);
+ }
+
+ /**
+ * Sets whether to replace all emoji with {@link EmojiSpan}s. Default value is
+ * {@link EmojiCompat#REPLACE_STRATEGY_DEFAULT}.
+ *
+ * @param replaceStrategy should be one of {@link EmojiCompat#REPLACE_STRATEGY_DEFAULT},
+ * {@link EmojiCompat#REPLACE_STRATEGY_NON_EXISTENT},
+ * {@link EmojiCompat#REPLACE_STRATEGY_ALL}
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ void setEmojiReplaceStrategy(@EmojiCompat.ReplaceStrategy int replaceStrategy) {
+ mEmojiReplaceStrategy = replaceStrategy;
+ mHelper.setEmojiReplaceStrategy(replaceStrategy);
+ }
+
+ /**
+ * Returns whether to replace all emoji with {@link EmojiSpan}s. Default value is
+ * {@link EmojiCompat#REPLACE_STRATEGY_DEFAULT}.
+ *
+ * @return one of {@link EmojiCompat#REPLACE_STRATEGY_DEFAULT},
+ * {@link EmojiCompat#REPLACE_STRATEGY_NON_EXISTENT},
+ * {@link EmojiCompat#REPLACE_STRATEGY_ALL}
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ int getEmojiReplaceStrategy() {
+ return mEmojiReplaceStrategy;
+ }
+
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ static class HelperInternal {
+
+ KeyListener getKeyListener(@NonNull KeyListener keyListener) {
+ return keyListener;
+ }
+
+ InputConnection onCreateInputConnection(@NonNull InputConnection inputConnection,
+ @NonNull EditorInfo outAttrs) {
+ return inputConnection;
+ }
+
+ void setMaxEmojiCount(int maxEmojiCount) {
+ // do nothing
+ }
+
+ void setEmojiReplaceStrategy(@EmojiCompat.ReplaceStrategy int replaceStrategy) {
+ // do nothing
+ }
+ }
+
+ @RequiresApi(19)
+ private static class HelperInternal19 extends HelperInternal {
+ private final EditText mEditText;
+ private final EmojiTextWatcher mTextWatcher;
+
+ HelperInternal19(@NonNull EditText editText) {
+ mEditText = editText;
+ mTextWatcher = new EmojiTextWatcher(mEditText);
+ mEditText.addTextChangedListener(mTextWatcher);
+ mEditText.setEditableFactory(EmojiEditableFactory.getInstance());
+ }
+
+ @Override
+ void setMaxEmojiCount(int maxEmojiCount) {
+ mTextWatcher.setMaxEmojiCount(maxEmojiCount);
+ }
+
+ @Override
+ void setEmojiReplaceStrategy(@EmojiCompat.ReplaceStrategy int replaceStrategy) {
+ mTextWatcher.setEmojiReplaceStrategy(replaceStrategy);
+ }
+
+ @Override
+ KeyListener getKeyListener(@NonNull final KeyListener keyListener) {
+ if (keyListener instanceof EmojiKeyListener) {
+ return keyListener;
+ }
+ return new EmojiKeyListener(keyListener);
+ }
+
+ @Override
+ InputConnection onCreateInputConnection(@NonNull final InputConnection inputConnection,
+ @NonNull final EditorInfo outAttrs) {
+ if (inputConnection instanceof EmojiInputConnection) {
+ return inputConnection;
+ }
+ return new EmojiInputConnection(mEditText, inputConnection, outAttrs);
+ }
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiEditableFactory.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiEditableFactory.java
new file mode 100644
index 0000000..b9c1abd
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiEditableFactory.java
@@ -0,0 +1,78 @@
+/*
+ * 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.widget;
+
+import android.annotation.SuppressLint;
+import android.text.Editable;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * EditableFactory used to improve editing operations on an EditText.
+ * <p>
+ * EditText uses DynamicLayout, which attaches to the Spannable instance that is being edited using
+ * ChangeWatcher. ChangeWatcher implements SpanWatcher and Textwatcher. Currently every delete/add
+ * operation is reported to DynamicLayout, for every span that has changed. For each change,
+ * DynamicLayout performs some expensive computations. i.e. if there is 100 EmojiSpans and the first
+ * span is deleted, DynamicLayout gets 99 calls about the change of position occurred in the
+ * remaining spans. This causes a huge delay in response time.
+ * <p>
+ * Since "android.text.DynamicLayout$ChangeWatcher" class is not a public class,
+ * EmojiEditableFactory checks if the watcher is in the classpath, and if so uses the modified
+ * Spannable which reduces the total number of calls to DynamicLayout for operations that affect
+ * EmojiSpans.
+ *
+ * @see SpannableBuilder
+ */
+final class EmojiEditableFactory extends Editable.Factory {
+ private static final Object INSTANCE_LOCK = new Object();
+ @GuardedBy("INSTANCE_LOCK")
+ private static volatile Editable.Factory sInstance;
+
+ @Nullable private static Class<?> sWatcherClass;
+
+ @SuppressLint("PrivateApi")
+ private EmojiEditableFactory() {
+ try {
+ String className = "android.text.DynamicLayout$ChangeWatcher";
+ sWatcherClass = Class.forName(className, false, getClass().getClassLoader());
+ } catch (Throwable t) {
+ // ignore
+ }
+ }
+
+ @SuppressWarnings("GuardedBy")
+ public static Editable.Factory getInstance() {
+ if (sInstance == null) {
+ synchronized (INSTANCE_LOCK) {
+ if (sInstance == null) {
+ sInstance = new EmojiEditableFactory();
+ }
+ }
+ }
+ return sInstance;
+ }
+
+ @Override
+ public Editable newEditable(@NonNull final CharSequence source) {
+ if (sWatcherClass != null) {
+ return SpannableBuilder.create(sWatcherClass, source);
+ }
+ return super.newEditable(source);
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java
new file mode 100644
index 0000000..288d3d9
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiExtractEditText.java
@@ -0,0 +1,165 @@
+/*
+ * 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.widget;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+import android.content.Context;
+import android.inputmethodservice.ExtractEditText;
+import android.os.Build;
+import android.text.method.KeyListener;
+import android.util.AttributeSet;
+import android.view.ActionMode;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.TextView;
+
+import androidx.annotation.IntRange;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.core.widget.TextViewCompat;
+import androidx.emoji2.text.EmojiCompat;
+import androidx.emoji2.text.EmojiSpan;
+
+/**
+ * ExtractEditText widget enhanced with emoji capability by using {@link EmojiEditTextHelper}.
+ * When used on devices running API 18 or below, this widget acts as a {@link ExtractEditText} and
+ * does not provide any emoji compatibility feature.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+public class EmojiExtractEditText extends ExtractEditText {
+ private EmojiEditTextHelper mEmojiEditTextHelper;
+
+ /**
+ * Prevent calling {@link #init(AttributeSet, int)} multiple times in case super() constructors
+ * call other constructors.
+ */
+ private boolean mInitialized;
+
+ public EmojiExtractEditText(Context context) {
+ super(context);
+ init(null /*attrs*/, 0 /*defStyleAttr*/, 0 /*defStyleRes*/);
+ }
+
+ public EmojiExtractEditText(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, android.R.attr.editTextStyle, 0 /*defStyleRes*/);
+ }
+
+ public EmojiExtractEditText(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs, defStyleAttr, 0 /*defStyleRes*/);
+ }
+
+ @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+ public EmojiExtractEditText(Context context, AttributeSet attrs, int defStyleAttr,
+ int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init(attrs, defStyleAttr, defStyleRes);
+ }
+
+ private void init(@Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ if (!mInitialized) {
+ mInitialized = true;
+ final EditTextAttributeHelper attrHelper = new EditTextAttributeHelper(this, attrs,
+ defStyleAttr, defStyleRes);
+ setMaxEmojiCount(attrHelper.getMaxEmojiCount());
+ setKeyListener(super.getKeyListener());
+ }
+ }
+
+ @Override
+ public void setKeyListener(@Nullable KeyListener keyListener) {
+ if (keyListener != null) {
+ keyListener = getEmojiEditTextHelper().getKeyListener(keyListener);
+ }
+ super.setKeyListener(keyListener);
+ }
+
+ @Override
+ public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
+ final InputConnection inputConnection = super.onCreateInputConnection(outAttrs);
+ return getEmojiEditTextHelper().onCreateInputConnection(inputConnection, outAttrs);
+ }
+
+ /**
+ * Set the maximum number of EmojiSpans to be added to a CharSequence. The number of spans in a
+ * CharSequence affects the performance of the EditText insert/delete operations. Insert/delete
+ * operations slow down as the number of spans increases.
+ *
+ * @param maxEmojiCount maximum number of EmojiSpans to be added to a single CharSequence,
+ * should be equal or greater than 0
+ * @see EmojiCompat#process(CharSequence, int, int, int)
+ */
+ public void setMaxEmojiCount(@IntRange(from = 0) int maxEmojiCount) {
+ getEmojiEditTextHelper().setMaxEmojiCount(maxEmojiCount);
+ }
+
+ /**
+ * Sets whether to replace all emoji with {@link EmojiSpan}s. Default value is
+ * {@link EmojiCompat#REPLACE_STRATEGY_DEFAULT}.
+ *
+ * @param replaceStrategy should be one of {@link EmojiCompat#REPLACE_STRATEGY_DEFAULT},
+ * {@link EmojiCompat#REPLACE_STRATEGY_NON_EXISTENT},
+ * {@link EmojiCompat#REPLACE_STRATEGY_ALL}
+ */
+ public void setEmojiReplaceStrategy(@EmojiCompat.ReplaceStrategy int replaceStrategy) {
+ getEmojiEditTextHelper().setEmojiReplaceStrategy(replaceStrategy);
+ }
+
+ /**
+ * Returns whether to replace all emoji with {@link EmojiSpan}s. Default value is
+ * {@link EmojiCompat#REPLACE_STRATEGY_DEFAULT}.
+ *
+ * @return one of {@link EmojiCompat#REPLACE_STRATEGY_DEFAULT},
+ * {@link EmojiCompat#REPLACE_STRATEGY_NON_EXISTENT},
+ * {@link EmojiCompat#REPLACE_STRATEGY_ALL}
+ */
+ public int getEmojiReplaceStrategy() {
+ return getEmojiEditTextHelper().getEmojiReplaceStrategy();
+ }
+
+ /**
+ * Returns the maximum number of EmojiSpans to be added to a CharSequence.
+ *
+ * @see #setMaxEmojiCount(int)
+ * @see EmojiCompat#process(CharSequence, int, int, int)
+ */
+ public int getMaxEmojiCount() {
+ return getEmojiEditTextHelper().getMaxEmojiCount();
+ }
+
+ private EmojiEditTextHelper getEmojiEditTextHelper() {
+ if (mEmojiEditTextHelper == null) {
+ mEmojiEditTextHelper = new EmojiEditTextHelper(this);
+ }
+ return mEmojiEditTextHelper;
+ }
+
+ /**
+ * See
+ * {@link TextViewCompat#setCustomSelectionActionModeCallback(TextView, ActionMode.Callback)}
+ */
+ @Override
+ public void setCustomSelectionActionModeCallback(ActionMode.Callback actionModeCallback) {
+ super.setCustomSelectionActionModeCallback(TextViewCompat
+ .wrapCustomSelectionActionModeCallback(this, actionModeCallback));
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiExtractTextLayout.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiExtractTextLayout.java
new file mode 100644
index 0000000..ba476e1
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiExtractTextLayout.java
@@ -0,0 +1,228 @@
+/*
+ * 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.widget;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.inputmethodservice.InputMethodService;
+import android.os.Build;
+import android.text.InputType;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.LinearLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.core.view.ViewCompat;
+import androidx.emoji2.R;
+import androidx.emoji2.text.EmojiCompat;
+import androidx.emoji2.text.EmojiSpan;
+
+/**
+ * Layout that contains emoji compatibility enhanced ExtractEditText. Should be used by
+ * {@link InputMethodService} implementations.
+ * <p/>
+ * Call {@link #onUpdateExtractingViews(InputMethodService, EditorInfo)} from
+ * {@link InputMethodService#onUpdateExtractingViews(EditorInfo)
+ * InputMethodService#onUpdateExtractingViews(EditorInfo)}.
+ * <pre>
+ * public class MyInputMethodService extends InputMethodService {
+ * // ..
+ * {@literal @}Override
+ * public View onCreateExtractTextView() {
+ * mExtractView = getLayoutInflater().inflate(R.layout.emoji_input_method_extract_layout,
+ * null);
+ * return mExtractView;
+ * }
+ *
+ * {@literal @}Override
+ * public void onUpdateExtractingViews(EditorInfo ei) {
+ * mExtractView.onUpdateExtractingViews(this, ei);
+ * }
+ * }
+ * </pre>
+ *
+ * {@link androidx.emoji.R.attr#emojiReplaceStrategy}
+ */
+public class EmojiExtractTextLayout extends LinearLayout {
+
+ private ExtractButtonCompat mExtractAction;
+ private EmojiExtractEditText mExtractEditText;
+ private ViewGroup mExtractAccessories;
+ private View.OnClickListener mButtonOnClickListener;
+
+ /**
+ * Prevent calling {@link #init(Context, AttributeSet, int)}} multiple times in case super()
+ * constructors call other constructors.
+ */
+ private boolean mInitialized;
+
+ public EmojiExtractTextLayout(Context context) {
+ super(context);
+ init(context, null /*attrs*/, 0 /*defStyleAttr*/, 0 /*defStyleRes*/);
+ }
+
+ public EmojiExtractTextLayout(Context context,
+ @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ init(context, attrs, 0 /*defStyleAttr*/, 0 /*defStyleRes*/);
+ }
+
+ public EmojiExtractTextLayout(Context context,
+ @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(context, attrs, defStyleAttr, 0 /*defStyleRes*/);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+ public EmojiExtractTextLayout(Context context, AttributeSet attrs,
+ int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ private void init(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr,
+ int defStyleRes) {
+ if (!mInitialized) {
+ mInitialized = true;
+ setOrientation(HORIZONTAL);
+ final View view = LayoutInflater.from(context)
+ .inflate(R.layout.input_method_extract_view, this /*root*/,
+ true /*attachToRoot*/);
+ mExtractAccessories = view.findViewById(R.id.inputExtractAccessories);
+ mExtractAction = view.findViewById(R.id.inputExtractAction);
+ mExtractEditText = view.findViewById(android.R.id.inputExtractEditText);
+
+ if (attrs != null) {
+ final TypedArray a = context.obtainStyledAttributes(attrs,
+ R.styleable.EmojiExtractTextLayout, defStyleAttr, defStyleRes);
+ ViewCompat.saveAttributeDataForStyleable(
+ this, context, R.styleable.EmojiExtractTextLayout, attrs, a, defStyleAttr,
+ defStyleRes);
+ final int replaceStrategy = a.getInteger(
+ R.styleable.EmojiExtractTextLayout_emojiReplaceStrategy,
+ EmojiCompat.REPLACE_STRATEGY_DEFAULT);
+ mExtractEditText.setEmojiReplaceStrategy(replaceStrategy);
+ a.recycle();
+ }
+ }
+ }
+
+ /**
+ * Sets whether to replace all emoji with {@link EmojiSpan}s. Default value is
+ * {@link EmojiCompat#REPLACE_STRATEGY_DEFAULT}.
+ *
+ * @param replaceStrategy should be one of {@link EmojiCompat#REPLACE_STRATEGY_DEFAULT},
+ * {@link EmojiCompat#REPLACE_STRATEGY_NON_EXISTENT},
+ * {@link EmojiCompat#REPLACE_STRATEGY_ALL}
+ *
+ * {@link androidx.emoji.R.attr#emojiReplaceStrategy}
+ */
+ public void setEmojiReplaceStrategy(@EmojiCompat.ReplaceStrategy int replaceStrategy) {
+ mExtractEditText.setEmojiReplaceStrategy(replaceStrategy);
+ }
+
+ /**
+ * Returns whether to replace all emoji with {@link EmojiSpan}s. Default value is
+ * {@link EmojiCompat#REPLACE_STRATEGY_DEFAULT}.
+ *
+ * @return one of {@link EmojiCompat#REPLACE_STRATEGY_DEFAULT},
+ * {@link EmojiCompat#REPLACE_STRATEGY_NON_EXISTENT},
+ * {@link EmojiCompat#REPLACE_STRATEGY_ALL}
+ *
+ * {@link androidx.emoji.R.attr#emojiReplaceStrategy}
+ */
+ public int getEmojiReplaceStrategy() {
+ return mExtractEditText.getEmojiReplaceStrategy();
+ }
+
+ /**
+ * Initializes the layout. Call this function from
+ * {@link InputMethodService#onUpdateExtractingViews(EditorInfo)
+ * InputMethodService#onUpdateExtractingViews(EditorInfo)}.
+ */
+ public void onUpdateExtractingViews(InputMethodService inputMethodService, EditorInfo ei) {
+ // the following code is ported as it is from InputMethodService.onUpdateExtractingViews
+ if (!inputMethodService.isExtractViewShown()) {
+ return;
+ }
+
+ if (mExtractAccessories == null) {
+ return;
+ }
+
+ final boolean hasAction = ei.actionLabel != null
+ || ((ei.imeOptions & EditorInfo.IME_MASK_ACTION) != EditorInfo.IME_ACTION_NONE
+ && (ei.imeOptions & EditorInfo.IME_FLAG_NO_ACCESSORY_ACTION) == 0
+ && ei.inputType != InputType.TYPE_NULL);
+
+ if (hasAction) {
+ mExtractAccessories.setVisibility(View.VISIBLE);
+ if (mExtractAction != null) {
+ if (ei.actionLabel != null) {
+ mExtractAction.setText(ei.actionLabel);
+ } else {
+ mExtractAction.setText(inputMethodService.getTextForImeAction(ei.imeOptions));
+ }
+ mExtractAction.setOnClickListener(getButtonClickListener(inputMethodService));
+ }
+ } else {
+ mExtractAccessories.setVisibility(View.GONE);
+ if (mExtractAction != null) {
+ mExtractAction.setOnClickListener(null);
+ }
+ }
+ }
+
+ private View.OnClickListener getButtonClickListener(
+ final InputMethodService inputMethodService) {
+ if (mButtonOnClickListener == null) {
+ mButtonOnClickListener = new ButtonOnclickListener(inputMethodService);
+ }
+ return mButtonOnClickListener;
+ }
+
+ private static final class ButtonOnclickListener implements View.OnClickListener {
+ private final InputMethodService mInputMethodService;
+
+ ButtonOnclickListener(InputMethodService inputMethodService) {
+ mInputMethodService = inputMethodService;
+ }
+
+ /**
+ * The following code is ported as it is from InputMethodService.mActionClickListener.
+ */
+ @Override
+ public void onClick(View v) {
+ final EditorInfo ei = mInputMethodService.getCurrentInputEditorInfo();
+ final InputConnection ic = mInputMethodService.getCurrentInputConnection();
+ if (ei != null && ic != null) {
+ if (ei.actionId != 0) {
+ ic.performEditorAction(ei.actionId);
+ } else if ((ei.imeOptions & EditorInfo.IME_MASK_ACTION)
+ != EditorInfo.IME_ACTION_NONE) {
+ ic.performEditorAction(ei.imeOptions & EditorInfo.IME_MASK_ACTION);
+ }
+ }
+ }
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiInputConnection.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiInputConnection.java
new file mode 100644
index 0000000..53e8ee4
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiInputConnection.java
@@ -0,0 +1,72 @@
+/*
+ * 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.widget;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+import android.text.Editable;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputConnectionWrapper;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.emoji2.text.EmojiCompat;
+
+/**
+ * InputConnectionWrapper for EditText delete operations. Keyboard does not have knowledge about
+ * emojis and therefore might send commands to delete a part of the emoji sequence which creates
+ * invalid codeunits/getCodepointAt in the text.
+ * <p/>
+ * This class tries to correctly delete an emoji checking if there is an emoji span.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+@RequiresApi(19)
+final class EmojiInputConnection extends InputConnectionWrapper {
+ private final TextView mTextView;
+
+ EmojiInputConnection(
+ @NonNull final TextView textView,
+ @NonNull final InputConnection inputConnection,
+ @NonNull final EditorInfo outAttrs) {
+ super(inputConnection, false);
+ mTextView = textView;
+ EmojiCompat.get().updateEditorInfoAttrs(outAttrs);
+ }
+
+ @Override
+ public boolean deleteSurroundingText(final int beforeLength, final int afterLength) {
+ final boolean result = EmojiCompat.handleDeleteSurroundingText(this, getEditable(),
+ beforeLength, afterLength, false /*inCodePoints*/);
+ return result || super.deleteSurroundingText(beforeLength, afterLength);
+ }
+
+ @Override
+ public boolean deleteSurroundingTextInCodePoints(final int beforeLength,
+ final int afterLength) {
+ final boolean result = EmojiCompat.handleDeleteSurroundingText(this, getEditable(),
+ beforeLength, afterLength, true /*inCodePoints*/);
+ return result || super.deleteSurroundingTextInCodePoints(beforeLength, afterLength);
+ }
+
+ private Editable getEditable() {
+ return mTextView.getEditableText();
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiInputFilter.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiInputFilter.java
new file mode 100644
index 0000000..fb1bd11
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiInputFilter.java
@@ -0,0 +1,134 @@
+/*
+ * 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.widget;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.Spanned;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.emoji2.text.EmojiCompat;
+import androidx.emoji2.text.EmojiCompat.InitCallback;
+
+import java.lang.ref.Reference;
+import java.lang.ref.WeakReference;
+
+/**
+ * InputFilter to add EmojiSpans to the CharSequence set in a TextView. Unlike EditText where a
+ * TextWatcher is used to enhance the CharSequence, InputFilter is used on TextView. The reason is
+ * that if you add a TextWatcher to a TextView, its internal layout mechanism change, and therefore
+ * depending on the CharSequence provided, adding a TextWatcher might have performance side
+ * effects.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+@RequiresApi(19)
+final class EmojiInputFilter implements android.text.InputFilter {
+ private final TextView mTextView;
+ private InitCallback mInitCallback;
+
+ EmojiInputFilter(@NonNull final TextView textView) {
+ mTextView = textView;
+ }
+
+ @Override
+ public CharSequence filter(final CharSequence source, final int sourceStart,
+ final int sourceEnd, final Spanned dest, final int destStart, final int destEnd) {
+ if (mTextView.isInEditMode()) {
+ return source;
+ }
+
+ switch (EmojiCompat.get().getLoadState()){
+ case EmojiCompat.LOAD_STATE_SUCCEEDED:
+ boolean process = true;
+ if (destEnd == 0 && destStart == 0 && dest.length() == 0) {
+ final CharSequence oldText = mTextView.getText();
+ if (source == oldText) {
+ process = false;
+ }
+ }
+
+ if (process && source != null) {
+ final CharSequence text;
+ if (sourceStart == 0 && sourceEnd == source.length()) {
+ text = source;
+ } else {
+ text = source.subSequence(sourceStart, sourceEnd);
+ }
+ return EmojiCompat.get().process(text, 0, text.length());
+ }
+
+ return source;
+ case EmojiCompat.LOAD_STATE_LOADING:
+ case EmojiCompat.LOAD_STATE_DEFAULT:
+ EmojiCompat.get().registerInitCallback(getInitCallback());
+ return source;
+
+ case EmojiCompat.LOAD_STATE_FAILED:
+ default:
+ return source;
+ }
+ }
+
+ private InitCallback getInitCallback() {
+ if (mInitCallback == null) {
+ mInitCallback = new InitCallbackImpl(mTextView);
+ }
+ return mInitCallback;
+ }
+
+ private static class InitCallbackImpl extends InitCallback {
+ private final Reference<TextView> mViewRef;
+
+ InitCallbackImpl(TextView textView) {
+ mViewRef = new WeakReference<>(textView);
+ }
+
+ @Override
+ public void onInitialized() {
+ super.onInitialized();
+ final TextView textView = mViewRef.get();
+ if (textView != null && textView.isAttachedToWindow()) {
+ final CharSequence result = EmojiCompat.get().process(textView.getText());
+
+ final int selectionStart = Selection.getSelectionStart(result);
+ final int selectionEnd = Selection.getSelectionEnd(result);
+
+ textView.setText(result);
+
+ if (result instanceof Spannable) {
+ updateSelection((Spannable) result, selectionStart, selectionEnd);
+ }
+ }
+ }
+ }
+
+ static void updateSelection(Spannable spannable, final int start, final int end) {
+ if (start >= 0 && end >= 0) {
+ Selection.setSelection(spannable, start, end);
+ } else if (start >= 0) {
+ Selection.setSelection(spannable, start);
+ } else if (end >= 0) {
+ Selection.setSelection(spannable, end);
+ }
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiKeyListener.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiKeyListener.java
new file mode 100644
index 0000000..8bac084e
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiKeyListener.java
@@ -0,0 +1,67 @@
+/*
+ * 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.widget;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+import android.text.Editable;
+import android.view.KeyEvent;
+import android.view.View;
+
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.emoji2.text.EmojiCompat;
+
+/**
+ * KeyListener class to handle delete operations correctly.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+@RequiresApi(19)
+final class EmojiKeyListener implements android.text.method.KeyListener {
+ private final android.text.method.KeyListener mKeyListener;
+
+ EmojiKeyListener(android.text.method.KeyListener keyListener) {
+ mKeyListener = keyListener;
+ }
+
+ @Override
+ public int getInputType() {
+ return mKeyListener.getInputType();
+ }
+
+ @Override
+ public boolean onKeyDown(View view, Editable content, int keyCode, KeyEvent event) {
+ final boolean result = EmojiCompat.handleOnKeyDown(content, keyCode, event);
+ return result || mKeyListener.onKeyDown(view, content, keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyUp(View view, Editable text, int keyCode, KeyEvent event) {
+ return mKeyListener.onKeyUp(view, text, keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyOther(View view, Editable text, KeyEvent event) {
+ return mKeyListener.onKeyOther(view, text, event);
+ }
+
+ @Override
+ public void clearMetaKeyState(View view, Editable content, int states) {
+ mKeyListener.clearMetaKeyState(view, content, states);
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiTextView.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiTextView.java
new file mode 100644
index 0000000..a9a7492
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiTextView.java
@@ -0,0 +1,96 @@
+/*
+ * 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.widget;
+
+import android.content.Context;
+import android.os.Build;
+import android.text.InputFilter;
+import android.util.AttributeSet;
+import android.view.ActionMode;
+import android.widget.TextView;
+
+import androidx.annotation.RequiresApi;
+import androidx.core.widget.TextViewCompat;
+
+/**
+ * TextView widget enhanced with emoji capability by using {@link EmojiTextViewHelper}. When used
+ * on devices running API 18 or below, this widget acts as a regular {@link TextView}.
+ */
+public class EmojiTextView extends TextView {
+ private EmojiTextViewHelper mEmojiTextViewHelper;
+
+ /**
+ * Prevent calling {@link #init()} multiple times in case super() constructors
+ * call other constructors.
+ */
+ private boolean mInitialized;
+
+ public EmojiTextView(Context context) {
+ super(context);
+ init();
+ }
+
+ public EmojiTextView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+ public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ private void init() {
+ if (!mInitialized) {
+ mInitialized = true;
+ getEmojiTextViewHelper().updateTransformationMethod();
+ }
+ }
+
+ @Override
+ public void setFilters(InputFilter[] filters) {
+ super.setFilters(getEmojiTextViewHelper().getFilters(filters));
+ }
+
+ @Override
+ public void setAllCaps(boolean allCaps) {
+ super.setAllCaps(allCaps);
+ getEmojiTextViewHelper().setAllCaps(allCaps);
+ }
+
+ private EmojiTextViewHelper getEmojiTextViewHelper() {
+ if (mEmojiTextViewHelper == null) {
+ mEmojiTextViewHelper = new EmojiTextViewHelper(this);
+ }
+ return mEmojiTextViewHelper;
+ }
+
+ /**
+ * See
+ * {@link TextViewCompat#setCustomSelectionActionModeCallback(TextView, ActionMode.Callback)}
+ */
+ @Override
+ public void setCustomSelectionActionModeCallback(ActionMode.Callback actionModeCallback) {
+ super.setCustomSelectionActionModeCallback(TextViewCompat
+ .wrapCustomSelectionActionModeCallback(this, actionModeCallback));
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiTextViewHelper.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiTextViewHelper.java
new file mode 100644
index 0000000..d6a34e4
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiTextViewHelper.java
@@ -0,0 +1,197 @@
+/*
+ * 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.widget;
+
+import android.os.Build;
+import android.text.InputFilter;
+import android.text.method.PasswordTransformationMethod;
+import android.text.method.TransformationMethod;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.core.util.Preconditions;
+import androidx.emoji2.text.EmojiCompat;
+
+/**
+ * Utility class to enhance custom TextView widgets with {@link EmojiCompat}.
+ * <pre>
+ * public class MyEmojiTextView extends TextView {
+ * public MyEmojiTextView(Context context) {
+ * super(context);
+ * init();
+ * }
+ * // ..
+ * private void init() {
+ * getEmojiTextViewHelper().updateTransformationMethod();
+ * }
+ *
+ * {@literal @}Override
+ * public void setFilters(InputFilter[] filters) {
+ * super.setFilters(getEmojiTextViewHelper().getFilters(filters));
+ * }
+ *
+ * {@literal @}Override
+ * public void setAllCaps(boolean allCaps) {
+ * super.setAllCaps(allCaps);
+ * getEmojiTextViewHelper().setAllCaps(allCaps);
+ * }
+ *
+ * private EmojiTextViewHelper getEmojiTextViewHelper() {
+ * if (mEmojiTextViewHelper == null) {
+ * mEmojiTextViewHelper = new EmojiTextViewHelper(this);
+ * }
+ * return mEmojiTextViewHelper;
+ * }
+ * }
+ * </pre>
+ */
+public final class EmojiTextViewHelper {
+
+ private final HelperInternal mHelper;
+
+ /**
+ * Default constructor.
+ *
+ * @param textView TextView instance
+ */
+ public EmojiTextViewHelper(@NonNull TextView textView) {
+ Preconditions.checkNotNull(textView, "textView cannot be null");
+ mHelper = Build.VERSION.SDK_INT >= 19 ? new HelperInternal19(textView)
+ : new HelperInternal();
+ }
+
+ /**
+ * Updates widget's TransformationMethod so that the transformed text can be processed.
+ * Should be called in the widget constructor. When used on devices running API 18 or below,
+ * this method does nothing.
+ *
+ * @see #wrapTransformationMethod(TransformationMethod)
+ */
+ public void updateTransformationMethod() {
+ mHelper.updateTransformationMethod();
+ }
+
+ /**
+ * Appends EmojiCompat InputFilters to the widget InputFilters. Should be called by {@link
+ * TextView#setFilters(InputFilter[])} to update the InputFilters. When used on devices running
+ * API 18 or below, this method returns {@code filters} that is given as a parameter.
+ *
+ * @param filters InputFilter array passed to {@link TextView#setFilters(InputFilter[])}
+ *
+ * @return same copy if the array already contains EmojiCompat InputFilter. A new array copy if
+ * not.
+ */
+ @NonNull
+ public InputFilter[] getFilters(@NonNull final InputFilter[] filters) {
+ return mHelper.getFilters(filters);
+ }
+
+ /**
+ * Returns transformation method that can update the transformed text to display emojis. When
+ * used on devices running API 18 or below, this method returns {@code transformationMethod}
+ * that is given as a parameter.
+ *
+ * @param transformationMethod instance to be wrapped
+ */
+ @Nullable
+ public TransformationMethod wrapTransformationMethod(
+ @Nullable TransformationMethod transformationMethod) {
+ return mHelper.wrapTransformationMethod(transformationMethod);
+ }
+
+ /**
+ * Call when allCaps is set on TextView. When used on devices running API 18 or below, this
+ * method does nothing.
+ *
+ * @param allCaps allCaps parameter passed to {@link TextView#setAllCaps(boolean)}
+ */
+ public void setAllCaps(boolean allCaps) {
+ mHelper.setAllCaps(allCaps);
+ }
+
+ @SuppressWarnings("WeakerAccess") /* synthetic access */
+ static class HelperInternal {
+
+ void updateTransformationMethod() {
+ // do nothing
+ }
+
+ InputFilter[] getFilters(@NonNull final InputFilter[] filters) {
+ return filters;
+ }
+
+ TransformationMethod wrapTransformationMethod(TransformationMethod transformationMethod) {
+ return transformationMethod;
+ }
+
+ void setAllCaps(boolean allCaps) {
+ // do nothing
+ }
+ }
+
+ @RequiresApi(19)
+ private static class HelperInternal19 extends HelperInternal {
+ private final TextView mTextView;
+ private final EmojiInputFilter mEmojiInputFilter;
+
+ HelperInternal19(TextView textView) {
+ mTextView = textView;
+ mEmojiInputFilter = new EmojiInputFilter(textView);
+ }
+
+ @Override
+ void updateTransformationMethod() {
+ final TransformationMethod tm = mTextView.getTransformationMethod();
+ if (tm != null && !(tm instanceof PasswordTransformationMethod)) {
+ mTextView.setTransformationMethod(wrapTransformationMethod(tm));
+ }
+ }
+
+ @Override
+ InputFilter[] getFilters(@NonNull final InputFilter[] filters) {
+ final int count = filters.length;
+ for (int i = 0; i < count; i++) {
+ if (filters[i] instanceof EmojiInputFilter) {
+ return filters;
+ }
+ }
+ final InputFilter[] newFilters = new InputFilter[filters.length + 1];
+ System.arraycopy(filters, 0, newFilters, 0, count);
+ newFilters[count] = mEmojiInputFilter;
+ return newFilters;
+ }
+
+ @Override
+ TransformationMethod wrapTransformationMethod(TransformationMethod transformationMethod) {
+ if (transformationMethod instanceof EmojiTransformationMethod) {
+ return transformationMethod;
+ }
+ return new EmojiTransformationMethod(transformationMethod);
+ }
+
+ @Override
+ void setAllCaps(boolean allCaps) {
+ // When allCaps is set to false TextView sets the transformation method to be null. We
+ // are only interested when allCaps is set to true in order to wrap the original method.
+ if (allCaps) {
+ updateTransformationMethod();
+ }
+ }
+
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiTextWatcher.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiTextWatcher.java
new file mode 100644
index 0000000..46fae2b
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiTextWatcher.java
@@ -0,0 +1,133 @@
+/*
+ * 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.widget;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+import android.text.Editable;
+import android.text.Selection;
+import android.text.Spannable;
+import android.widget.EditText;
+
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.emoji2.text.EmojiCompat;
+import androidx.emoji2.text.EmojiCompat.InitCallback;
+
+import java.lang.ref.Reference;
+import java.lang.ref.WeakReference;
+
+/**
+ * TextWatcher used for an EditText.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+@RequiresApi(19)
+final class EmojiTextWatcher implements android.text.TextWatcher {
+ private final EditText mEditText;
+ private InitCallback mInitCallback;
+ private int mMaxEmojiCount = EditTextAttributeHelper.MAX_EMOJI_COUNT;
+ @EmojiCompat.ReplaceStrategy
+ private int mEmojiReplaceStrategy = EmojiCompat.REPLACE_STRATEGY_DEFAULT;
+
+ EmojiTextWatcher(EditText editText) {
+ mEditText = editText;
+ }
+
+ void setMaxEmojiCount(int maxEmojiCount) {
+ this.mMaxEmojiCount = maxEmojiCount;
+ }
+
+ int getMaxEmojiCount() {
+ return mMaxEmojiCount;
+ }
+
+ @EmojiCompat.ReplaceStrategy int getEmojiReplaceStrategy() {
+ return mEmojiReplaceStrategy;
+ }
+
+ void setEmojiReplaceStrategy(@EmojiCompat.ReplaceStrategy int replaceStrategy) {
+ mEmojiReplaceStrategy = replaceStrategy;
+ }
+
+ @Override
+ public void onTextChanged(CharSequence charSequence, final int start, final int before,
+ final int after) {
+ if (mEditText.isInEditMode()) {
+ return;
+ }
+
+ //before > after --> a deletion occurred
+ if (before <= after && charSequence instanceof Spannable) {
+ switch (EmojiCompat.get().getLoadState()){
+ case EmojiCompat.LOAD_STATE_SUCCEEDED:
+ final Spannable s = (Spannable) charSequence;
+ EmojiCompat.get().process(s, start, start + after, mMaxEmojiCount,
+ mEmojiReplaceStrategy);
+ break;
+ case EmojiCompat.LOAD_STATE_LOADING:
+ case EmojiCompat.LOAD_STATE_DEFAULT:
+ EmojiCompat.get().registerInitCallback(getInitCallback());
+ break;
+ case EmojiCompat.LOAD_STATE_FAILED:
+ default:
+ break;
+ }
+ }
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ // do nothing
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ // do nothing
+ }
+
+ private InitCallback getInitCallback() {
+ if (mInitCallback == null) {
+ mInitCallback = new InitCallbackImpl(mEditText);
+ }
+ return mInitCallback;
+ }
+
+ private static class InitCallbackImpl extends InitCallback {
+ private final Reference<EditText> mViewRef;
+
+ InitCallbackImpl(EditText editText) {
+ mViewRef = new WeakReference<>(editText);
+ }
+
+ @Override
+ public void onInitialized() {
+ super.onInitialized();
+ final EditText editText = mViewRef.get();
+ if (editText != null && editText.isAttachedToWindow()) {
+ final Editable text = editText.getEditableText();
+
+ final int selectionStart = Selection.getSelectionStart(text);
+ final int selectionEnd = Selection.getSelectionEnd(text);
+
+ EmojiCompat.get().process(text);
+
+ EmojiInputFilter.updateSelection(text, selectionStart, selectionEnd);
+ }
+ }
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiTransformationMethod.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiTransformationMethod.java
new file mode 100644
index 0000000..16a4780
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/EmojiTransformationMethod.java
@@ -0,0 +1,76 @@
+/*
+ * 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.widget;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+import android.graphics.Rect;
+import android.text.method.TransformationMethod;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.emoji2.text.EmojiCompat;
+
+/**
+ * TransformationMethod wrapper in order to update transformed text with emojis.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+@RequiresApi(19)
+class EmojiTransformationMethod implements TransformationMethod {
+ private final TransformationMethod mTransformationMethod;
+
+ EmojiTransformationMethod(TransformationMethod transformationMethod) {
+ mTransformationMethod = transformationMethod;
+ }
+
+ @Override
+ public CharSequence getTransformation(@Nullable CharSequence source, @NonNull final View view) {
+ if (view.isInEditMode()) {
+ return source;
+ }
+
+ if (mTransformationMethod != null) {
+ source = mTransformationMethod.getTransformation(source, view);
+ }
+
+ if (source != null) {
+ switch (EmojiCompat.get().getLoadState()){
+ case EmojiCompat.LOAD_STATE_SUCCEEDED:
+ return EmojiCompat.get().process(source);
+ case EmojiCompat.LOAD_STATE_LOADING:
+ case EmojiCompat.LOAD_STATE_FAILED:
+ case EmojiCompat.LOAD_STATE_DEFAULT:
+ default:
+ break;
+ }
+ }
+ return source;
+ }
+
+ @Override
+ public void onFocusChanged(final View view, final CharSequence sourceText,
+ final boolean focused, final int direction, final Rect previouslyFocusedRect) {
+ if (mTransformationMethod != null) {
+ mTransformationMethod.onFocusChanged(view, sourceText, focused, direction,
+ previouslyFocusedRect);
+ }
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/ExtractButtonCompat.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/ExtractButtonCompat.java
new file mode 100644
index 0000000..26070ab
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/ExtractButtonCompat.java
@@ -0,0 +1,74 @@
+/*
+ * 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.widget;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+import android.content.Context;
+import android.os.Build;
+import android.util.AttributeSet;
+import android.view.ActionMode;
+import android.widget.Button;
+
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.core.widget.TextViewCompat;
+
+/**
+ * Support library implementation for ExtractButton. Used by {@link EmojiExtractViewHelper} while
+ * inflating {@link EmojiExtractEditText} for keyboard use.
+ * @hide
+ */
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+public class ExtractButtonCompat extends Button {
+ public ExtractButtonCompat(Context context) {
+ super(context, null);
+ }
+
+ public ExtractButtonCompat(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public ExtractButtonCompat(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+ public ExtractButtonCompat(Context context, AttributeSet attrs, int defStyleAttr,
+ int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ /**
+ * Pretend like the window this view is in always has focus, so it will
+ * highlight when selected.
+ */
+ @Override
+ public boolean hasWindowFocus() {
+ return isEnabled() && getVisibility() == VISIBLE ? true : false;
+ }
+
+ /**
+ * See
+ * {@link TextViewCompat#setCustomSelectionActionModeCallback(TextView, ActionMode.Callback)}
+ */
+ @Override
+ public void setCustomSelectionActionModeCallback(ActionMode.Callback actionModeCallback) {
+ super.setCustomSelectionActionModeCallback(TextViewCompat
+ .wrapCustomSelectionActionModeCallback(this, actionModeCallback));
+ }
+}
diff --git a/emoji2/emoji2/src/main/java/androidx/emoji2/widget/SpannableBuilder.java b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/SpannableBuilder.java
new file mode 100644
index 0000000..97fd4e7
--- /dev/null
+++ b/emoji2/emoji2/src/main/java/androidx/emoji2/widget/SpannableBuilder.java
@@ -0,0 +1,436 @@
+/*
+ * 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.widget;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+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 androidx.emoji2.text.EmojiSpan;
+
+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.
+ * <p>
+ * 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
+ * @see EmojiEditableFactory
+ */
+@RestrictTo(LIBRARY_GROUP_PREFIX)
+public final class SpannableBuilder extends SpannableStringBuilder {
+ /**
+ * DynamicLayout$ChangeWatcher class.
+ */
+ private final Class<?> mWatcherClass;
+
+ /**
+ * All WatcherWrappers.
+ */
+ private final List<WatcherWrapper> mWatchers = new ArrayList<>();
+
+ /**
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ SpannableBuilder(@NonNull Class<?> watcherClass) {
+ Preconditions.checkNotNull(watcherClass, "watcherClass cannot be null");
+ mWatcherClass = watcherClass;
+ }
+
+ /**
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ SpannableBuilder(@NonNull Class<?> watcherClass, @NonNull CharSequence text) {
+ super(text);
+ Preconditions.checkNotNull(watcherClass, "watcherClass cannot be null");
+ mWatcherClass = watcherClass;
+ }
+
+ /**
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ 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
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ 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;
+ }
+
+ @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(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.
+ */
+ @SuppressWarnings("unchecked")
+ @Override
+ public <T> T[] getSpans(int queryStart, int queryEnd, Class<T> 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(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(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(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(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, Class type) {
+ if (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(LIBRARY_GROUP_PREFIX)
+ public void beginBatchEdit() {
+ blockWatchers();
+ }
+
+ /**
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP_PREFIX)
+ 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());
+ }
+ }
+
+ @Override
+ public SpannableStringBuilder replace(int start, int end, CharSequence tb) {
+ blockWatchers();
+ super.replace(start, end, tb);
+ unblockwatchers();
+ return this;
+ }
+
+ @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;
+ }
+
+ @Override
+ public SpannableStringBuilder insert(int where, CharSequence tb) {
+ super.insert(where, tb);
+ return this;
+ }
+
+ @Override
+ public SpannableStringBuilder insert(int where, CharSequence tb, int start, int end) {
+ super.insert(where, tb, start, end);
+ return this;
+ }
+
+ @Override
+ public SpannableStringBuilder delete(int start, int end) {
+ super.delete(start, end);
+ return this;
+ }
+
+ @Override
+ public SpannableStringBuilder append(CharSequence text) {
+ super.append(text);
+ return this;
+ }
+
+ @Override
+ public SpannableStringBuilder append(char text) {
+ super.append(text);
+ return this;
+ }
+
+ @Override
+ public SpannableStringBuilder append(CharSequence text, int start, int end) {
+ super.append(text, start, end);
+ return this;
+ }
+
+ @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;
+ }
+ ((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/emoji2/emoji2/src/main/res-public/values/public_attrs.xml b/emoji2/emoji2/src/main/res-public/values/public_attrs.xml
new file mode 100644
index 0000000..56634ff
--- /dev/null
+++ b/emoji2/emoji2/src/main/res-public/values/public_attrs.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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.
+ -->
+
+<!-- Definitions of attributes to be exposed as public -->
+<resources>
+ <public type="attr" name="maxEmojiCount"/>
+ <public type="attr" name="emojiReplaceStrategy"/>
+</resources>
diff --git a/emoji2/emoji2/src/main/res/layout/input_method_extract_view.xml b/emoji2/emoji2/src/main/res/layout/input_method_extract_view.xml
new file mode 100644
index 0000000..0a4d9b0
--- /dev/null
+++ b/emoji2/emoji2/src/main/res/layout/input_method_extract_view.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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.
+ -->
+
+<merge
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.emoji2.widget.EmojiExtractEditText
+ android:id="@android:id/inputExtractEditText"
+ android:layout_width="0px"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:gravity="top"
+ android:inputType="text"
+ android:minLines="1"
+ android:scrollbars="vertical"/>
+
+ <FrameLayout
+ android:id="@+id/inputExtractAccessories"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:paddingEnd="8dip"
+ android:paddingStart="8dip">
+
+ <androidx.emoji2.widget.ExtractButtonCompat
+ android:id="@+id/inputExtractAction"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"/>
+
+ </FrameLayout>
+
+</merge>
\ No newline at end of file
diff --git a/emoji2/emoji2/src/main/res/values/attrs.xml b/emoji2/emoji2/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..77254a4
--- /dev/null
+++ b/emoji2/emoji2/src/main/res/values/attrs.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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.
+ -->
+<resources>
+ <declare-styleable name="EmojiEditText">
+ <attr name="maxEmojiCount" format="integer"/>
+ </declare-styleable>
+ <declare-styleable name="EmojiExtractTextLayout">
+ <attr name="emojiReplaceStrategy" format="enum">
+ <!-- Replace strategy that uses the value given in EmojiCompat.Config. Default
+ value. -->
+ <enum name="defaultStrategy" value="0" />
+ <!-- Replace strategy to add EmojiSpans for all emoji that were found. -->
+ <enum name="all" value="1" />
+ <!-- Replace strategy to add EmojiSpans only for emoji that do not exist in the
+ system. -->
+ <enum name="nonExistent" value="2" />
+ </attr>
+ </declare-styleable>
+</resources>
diff --git a/settings.gradle b/settings.gradle
index 40848ca..f2170d8 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -348,6 +348,8 @@
includeProject(":emoji", "emoji/core", [BuildType.MAIN])
includeProject(":emoji-appcompat", "emoji/appcompat", [BuildType.MAIN])
includeProject(":emoji-bundled", "emoji/bundled", [BuildType.MAIN])
+includeProject(":emoji2:emoji2", "emoji2/emoji2", [BuildType.MAIN])
+includeProject(":emoji2:emoji2-bundled", "emoji2/emoji2-bundled", [BuildType.MAIN])
includeProject(":enterprise-feedback", "enterprise/feedback", [BuildType.MAIN])
includeProject(":enterprise-feedback-testing", "enterprise/feedback/testing", [BuildType.MAIN])
includeProject(":exifinterface:exifinterface", "exifinterface/exifinterface", [BuildType.MAIN])