EmojiTextViewHelper.java

/*
 * 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.viewsintegration;

import android.os.Build;
import android.text.InputFilter;
import android.text.method.PasswordTransformationMethod;
import android.text.method.TransformationMethod;
import android.util.SparseArray;
import android.widget.TextView;

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;

/**
 * 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) {
        this(textView, true);
    }

    /**
     * Allows skipping of all processing until EmojiCompat.init is called.
     *
     * This is useful when integrating EmojiTextViewHelper into libraries that subclass TextView
     * that do not have control over EmojiCompat initialization by the app that uses the TextView
     * subclass.
     *
     * If this helper is initialized prior to EmojiCompat.init, the TextView it's configuring
     * will not display emoji using EmojiCompat after init is called until the transformation
     * method and filter are updated. The easiest way to do that is call
     * {@link EmojiTextViewHelper#setEnabled(boolean)}.
     *
     * @param textView TextView instance
     * @param expectInitializedEmojiCompat if true, this helper will assume init has been called
     *                                     and throw if it has not. If false, the methods on this
     *                                     helper will have no effect until EmojiCompat.init is
     *                                     called.
     */
    public EmojiTextViewHelper(@NonNull TextView textView, boolean expectInitializedEmojiCompat) {
        Preconditions.checkNotNull(textView, "textView cannot be null");
        if (Build.VERSION.SDK_INT < 19) {
            mHelper = new HelperInternal();
        } else if (!expectInitializedEmojiCompat) {
            mHelper = new SkippingHelper19(textView);
        } else {
            mHelper = new HelperInternal19(textView);
        }
    }

    /**
     * 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.
     */
    @SuppressWarnings("ArrayReturn")
    @NonNull
    public InputFilter[] getFilters(
            @SuppressWarnings("ArrayReturn") @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);
    }

    /**
     * When enabled, methods will have their documented behavior.
     *
     * When disabled, all methods will have no effect and emoji will not be processed.
     *
     * Setting this to disable will also have the side effect of setting both the transformation
     * method and filter if enabled has changed since the last call. By default
     * EmojiTextViewHelper is enabled.
     *
     * You do not need to call {@link EmojiTextViewHelper#updateTransformationMethod()} again after
     * calling setEnabled.
     *
     * @param enabled if this helper should process emoji.
     */
    public void setEnabled(boolean enabled) {
        mHelper.setEnabled(enabled);
    }

    /**
     * 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);
    }

    /**
     * @return current enabled state for this helper
     */
    public boolean isEnabled() {
        return mHelper.isEnabled();
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static class HelperInternal {

        void updateTransformationMethod() {
            // do nothing
        }

        @NonNull
        InputFilter[] getFilters(@NonNull final InputFilter[] filters) {
            return filters;
        }

        @Nullable
        TransformationMethod wrapTransformationMethod(
                @Nullable TransformationMethod transformationMethod) {
            return transformationMethod;
        }

        void setAllCaps(boolean allCaps) {
            // do nothing
        }

        void setEnabled(boolean processEmoji) {
            // do nothing
        }

        public boolean isEnabled() {
            return false;
        }
    }

    /**
     * This helper allows EmojiTextViewHelper to skip all calls to EmojiCompat until
     * {@link EmojiCompat#isConfigured()} returns true on devices that are 19+.
     *
     * When isConfigured returns true, this delegates to {@link HelperInternal19} to provide
     * EmojiCompat behavior. This has the effect of making EmojiCompat calls a "no-op" when
     * EmojiCompat is not configured on a device.
     *
     * There is no mechanism to be informed when isConfigured becomes true as it will lead to
     * likely memory leaks in situations where isConfigured never becomes true, and it is the
     * responsibility of the caller to call
     * {@link EmojiTextViewHelper#updateTransformationMethod()} after configuring EmojiCompat if
     * TextView's using EmojiTextViewHelper are already displayed to the user.
     */
    @RequiresApi(19)
    private static class SkippingHelper19 extends HelperInternal {
        private final HelperInternal19 mHelperDelegate;

        SkippingHelper19(TextView textView) {
            mHelperDelegate = new HelperInternal19(textView);
        }

        private boolean skipBecauseEmojiCompatNotInitialized() {
            return !EmojiCompat.isConfigured();
        }

        /**
         * {@inheritDoc}
         *
         * This method will have no effect if !{@link EmojiCompat#isConfigured()}
         */
        @Override
        void updateTransformationMethod() {
            if (skipBecauseEmojiCompatNotInitialized()) {
                return;
            }
            mHelperDelegate.updateTransformationMethod();
        }

        /**
         * {@inheritDoc}
         *
         * This method will have no effect if !{@link EmojiCompat#isConfigured()}
         */
        @NonNull
        @Override
        InputFilter[] getFilters(@NonNull InputFilter[] filters) {
            if (skipBecauseEmojiCompatNotInitialized()) {
                return filters;
            }
            return mHelperDelegate.getFilters(filters);
        }

        /**
         * {@inheritDoc}
         *
         * This method will have no effect if !{@link EmojiCompat#isConfigured()}
         */
        @Nullable
        @Override
        TransformationMethod wrapTransformationMethod(
                @Nullable TransformationMethod transformationMethod) {
            if (skipBecauseEmojiCompatNotInitialized()) {
                return transformationMethod;
            }
            return mHelperDelegate.wrapTransformationMethod(transformationMethod);
        }

        /**
         * {@inheritDoc}
         *
         * This method will have no effect if !{@link EmojiCompat#isConfigured()}
         */
        @Override
        void setAllCaps(boolean allCaps) {
            if (skipBecauseEmojiCompatNotInitialized()) {
                return;
            }
            mHelperDelegate.setAllCaps(allCaps);
        }

        /**
         * {@inheritDoc}
         *
         * This method will track enabled, but have no other effect if
         * !{@link EmojiCompat#isConfigured()}
         */
        @Override
        void setEnabled(boolean processEmoji) {
            if (skipBecauseEmojiCompatNotInitialized()) {
                mHelperDelegate.setEnabledUnsafe(processEmoji);
            } else {
                mHelperDelegate.setEnabled(processEmoji);
            }
        }

        @Override
        public boolean isEnabled() {
            return mHelperDelegate.isEnabled();
        }
    }

    @RequiresApi(19)
    private static class HelperInternal19 extends HelperInternal {
        private final TextView mTextView;
        private final EmojiInputFilter mEmojiInputFilter;
        private boolean mEnabled;

        HelperInternal19(TextView textView) {
            mTextView = textView;
            mEnabled = true;
            mEmojiInputFilter = new EmojiInputFilter(textView);
        }


        @Override
        void updateTransformationMethod() {
            // since this is not a pure function, we need to have a side effect for both enabled
            // and disabled
            final TransformationMethod tm =
                    wrapTransformationMethod(mTextView.getTransformationMethod());
            mTextView.setTransformationMethod(tm);
        }

        /**
         * Call whenever mEnabled changes
         */
        private void updateFilters() {
            InputFilter[] oldFilters = mTextView.getFilters();
            mTextView.setFilters(getFilters(oldFilters));
        }

        @NonNull
        @Override
        InputFilter[] getFilters(@NonNull final InputFilter[] filters) {
            if (!mEnabled) {
                // remove any EmojiInputFilter when disabled
                return removeEmojiInputFilterIfPresent(filters);
            } else {
                return addEmojiInputFilterIfMissing(filters);
            }
        }

        /**
         * Make sure that EmojiInputFilter is present in filters, or add it.
         *
         * @param filters to check
         * @return filters with mEmojiInputFilter added, if not previously present
         */
        @NonNull
        private InputFilter[] addEmojiInputFilterIfMissing(@NonNull InputFilter[] filters) {
            final int count = filters.length;
            for (int i = 0; i < count; i++) {
                if (filters[i] == mEmojiInputFilter) {
                    return filters;
                }
            }
            final InputFilter[] newFilters = new InputFilter[filters.length + 1];
            System.arraycopy(filters, 0, newFilters, 0, count);
            newFilters[count] = mEmojiInputFilter;
            return newFilters;
        }

        /**
         * Remove all EmojiInputFilter from filters
         *
         * @return filters.filter { it !== mEmojiInputFilter }
         */
        @NonNull
        private InputFilter[] removeEmojiInputFilterIfPresent(@NonNull InputFilter[] filters) {
            // find out the new size after removing (all) EmojiInputFilter
            SparseArray<InputFilter> filterSet = getEmojiInputFilterPositionArray(filters);
            if (filterSet.size() == 0) {
                return filters;
            }


            final int inCount = filters.length;
            int outCount = filters.length - filterSet.size();
            InputFilter[] result = new InputFilter[outCount];
            int destPosition = 0;
            for (int srcPosition = 0; srcPosition < inCount; srcPosition++) {
                if (filterSet.indexOfKey(srcPosition) < 0) {
                    result[destPosition] = filters[srcPosition];
                    destPosition++;
                }
            }
            return result;
        }

        /**
         * Populate a sparse array with true for all indexes that contain an EmojiInputFilter.
         */
        private SparseArray<InputFilter> getEmojiInputFilterPositionArray(
                @NonNull InputFilter[] filters) {
            SparseArray<InputFilter> result = new SparseArray<>(1);
            for (int pos = 0; pos < filters.length; pos++) {
                if (filters[pos] instanceof EmojiInputFilter) {
                    result.put(pos, filters[pos]);
                }
            }
            return result;
        }

        @Nullable
        @Override
        TransformationMethod wrapTransformationMethod(
                @Nullable TransformationMethod transformationMethod) {
            if (mEnabled) {
                return wrapForEnabled(transformationMethod);
            } else {
                return unwrapForDisabled(transformationMethod);
            }
        }

        /**
         * Unwrap EmojiTransformationMethods safely.
         */
        @Nullable
        private TransformationMethod unwrapForDisabled(
                @Nullable TransformationMethod transformationMethod) {
            if (transformationMethod instanceof EmojiTransformationMethod) {
                EmojiTransformationMethod etm =
                        (EmojiTransformationMethod) transformationMethod;
                return etm.getOriginalTransformationMethod();
            } else {
                return transformationMethod;
            }
        }

        /**
         * Wrap in EmojiTransformationMethod, but don't double wrap.
         *
         * This will not wrap {@link PasswordTransformationMethod}.
         */
        @NonNull
        private TransformationMethod wrapForEnabled(
                @Nullable TransformationMethod transformationMethod) {
            if (transformationMethod instanceof EmojiTransformationMethod) {
                return transformationMethod;
            } else if (transformationMethod instanceof PasswordTransformationMethod) {
                return transformationMethod;
            } else {
                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();
            }
        }

        @Override
        void setEnabled(boolean enabled) {
            mEnabled = enabled;
            updateTransformationMethod();
            updateFilters();
        }

        @Override
        public boolean isEnabled() {
            return mEnabled;
        }

        /**
         * Call to set enabled without side effects. Should only be used when EmojiCompat is not
         * initialized.
         *
         * @param processEmoji when true, this helper will process emoji
         * @hide
         */
        @RestrictTo(RestrictTo.Scope.LIBRARY)
        void setEnabledUnsafe(boolean processEmoji) {
            mEnabled = processEmoji;
        }
    }
}