blob: 572606f640c475d4eb92b4878f7c5f4a2ebca665 [file] [log] [blame]
/*
* Copyright 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.emoji.text;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import android.graphics.Color;
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(&#47;* a config instance *&#47;);</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.
*/
public static final int LOAD_STATE_DEFAULT = 3;
/**
* EmojiCompat is initializing.
*/
public static final int LOAD_STATE_LOADING = 0;
/**
* EmojiCompat successfully initialized.
*/
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.
*/
public static final int LOAD_STATE_FAILED = 2;
/**
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
@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}.
*/
public static final int REPLACE_STRATEGY_DEFAULT = 0;
/**
* Replace strategy to add {@link EmojiSpan}s for all emoji that were found.
*/
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)
@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.
*/
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.
*/
public static final int LOAD_STRATEGY_MANUAL = 1;
/**
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
@IntDef({LOAD_STRATEGY_DEFAULT, LOAD_STRATEGY_MANUAL})
@Retention(RetentionPolicy.SOURCE)
public @interface LoadStrategy {
}
/**
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
static final int EMOJI_COUNT_UNLIMITED = Integer.MAX_VALUE;
private static final Object sInstanceLock = new Object();
@GuardedBy("sInstanceLock")
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.
*/
private final MetadataRepoLoader mMetadataLoader;
/**
* @see Config#setReplaceAll(boolean)
*/
private final boolean mReplaceAll;
/**
* @see Config#setUseEmojiAsDefaultStyle(boolean)
*/
private final boolean mUseEmojiAsDefaultStyle;
/**
* @see Config#setUseEmojiAsDefaultStyle(boolean, List)
*/
private 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;
/**
* 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;
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 (sInstanceLock) {
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)
@VisibleForTesting
public static EmojiCompat reset(@NonNull final Config config) {
synchronized (sInstanceLock) {
sInstance = new EmojiCompat(config);
}
return sInstance;
}
/**
* Used by the tests to reset EmojiCompat with a new singleton instance.
*
* @hide
*/
@SuppressWarnings("GuardedBy")
@RestrictTo(LIBRARY_GROUP)
@VisibleForTesting
public static EmojiCompat reset(final EmojiCompat emojiCompat) {
synchronized (sInstanceLock) {
sInstance = emojiCompat;
}
return sInstance;
}
/**
* Used by the tests to set GlyphChecker for EmojiProcessor.
*
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
@VisibleForTesting
void setGlyphChecker(@NonNull final EmojiProcessor.GlyphChecker glyphChecker) {
mHelper.setGlyphChecker(glyphChecker);
}
/**
* 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 (sInstanceLock) {
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();
}
}
private 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));
}
private 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)
boolean isEmojiSpanIndicatorEnabled() {
return mEmojiSpanIndicatorEnabled;
}
/**
* @return whether a background should be drawn for the emoji.
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
@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 {@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()}
*
* @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 {@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}
*
* @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 {@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 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)
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)
@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);
}
/**
* 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 {
private final MetadataRepoLoader mMetadataLoader;
private boolean mReplaceAll;
private boolean mUseEmojiAsDefaultStyle;
private int[] mEmojiAsDefaultStyleExceptions;
private Set<InitCallback> mInitCallbacks;
private boolean mEmojiSpanIndicatorEnabled;
private int mEmojiSpanIndicatorColor = Color.GREEN;
@LoadStrategy private int mMetadataLoadStrategy = LOAD_STRATEGY_DEFAULT;
/**
* 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.
*
* @param strategy one of {@link EmojiCompat#LOAD_STRATEGY_DEFAULT},
* {@link EmojiCompat#LOAD_STRATEGY_MANUAL}
*
*/
public Config setMetadataLoadStrategy(@LoadStrategy int strategy) {
mMetadataLoadStrategy = strategy;
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.
}
void setGlyphChecker(@NonNull EmojiProcessor.GlyphChecker glyphChecker) {
// intentionally empty
}
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);
}
}
private 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.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
void setGlyphChecker(@NonNull EmojiProcessor.GlyphChecker glyphChecker) {
mProcessor.setGlyphChecker(glyphChecker);
}
@Override
String getAssetSignature() {
final String sha = mMetadataRepo.getMetadataList().sourceSha();
return sha == null ? "" : sha;
}
}
}