SpannableBuilder.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.text;

import android.annotation.SuppressLint;
import android.text.Editable;
import android.text.SpanWatcher;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.TextWatcher;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.core.util.Preconditions;

import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * When setSpan functions is called on EmojiSpannableBuilder, it checks if the mObject is instance
 * of the DynamicLayout$ChangeWatcher. if so, it wraps it into another listener mObject
 * (WatcherWrapper) that implements the same interfaces.
 * <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
 */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public final class SpannableBuilder extends SpannableStringBuilder {
    /**
     * DynamicLayout$ChangeWatcher class.
     */
    private final @NonNull Class<?> mWatcherClass;

    /**
     * All WatcherWrappers.
     */
    private final @NonNull List<WatcherWrapper> mWatchers = new ArrayList<>();

    /**
     * @hide
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    SpannableBuilder(@NonNull Class<?> watcherClass) {
        Preconditions.checkNotNull(watcherClass, "watcherClass cannot be null");
        mWatcherClass = watcherClass;
    }

    /**
     * @hide
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    SpannableBuilder(@NonNull Class<?> watcherClass, @NonNull CharSequence text) {
        super(text);
        Preconditions.checkNotNull(watcherClass, "watcherClass cannot be null");
        mWatcherClass = watcherClass;
    }

    /**
     * @hide
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    SpannableBuilder(@NonNull Class<?> watcherClass, @NonNull CharSequence text, int start,
            int end) {
        super(text, start, end);
        Preconditions.checkNotNull(watcherClass, "watcherClass cannot be null");
        mWatcherClass = watcherClass;
    }

    /**
     * @hide
     */
    @NonNull
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public static SpannableBuilder create(@NonNull Class<?> clazz, @NonNull CharSequence text) {
        return new SpannableBuilder(clazz, text);
    }

    /**
     * Checks whether the mObject is instance of the DynamicLayout$ChangeWatcher.
     *
     * @param object mObject to be checked
     *
     * @return true if mObject is instance of the DynamicLayout$ChangeWatcher.
     */
    private boolean isWatcher(@Nullable Object object) {
        return object != null && isWatcher(object.getClass());
    }

    /**
     * Checks whether the class is DynamicLayout$ChangeWatcher.
     *
     * @param clazz class to be checked
     *
     * @return true if class is DynamicLayout$ChangeWatcher.
     */
    private boolean isWatcher(@NonNull Class<?> clazz) {
        return mWatcherClass == clazz;
    }

    @SuppressLint("UnknownNullness")
    @Override
    public CharSequence subSequence(int start, int end) {
        return new SpannableBuilder(mWatcherClass, this, start, end);
    }

    /**
     * If the span being added is instance of DynamicLayout$ChangeWatcher, wrap the watcher in
     * another internal watcher that will prevent EmojiSpan events to be fired to DynamicLayout. Set
     * this new mObject as the span.
     */
    @Override
    public void setSpan(@Nullable Object what, int start, int end, int flags) {
        if (isWatcher(what)) {
            final WatcherWrapper span = new WatcherWrapper(what);
            mWatchers.add(span);
            what = span;
        }
        super.setSpan(what, start, end, flags);
    }

    /**
     * If previously a DynamicLayout$ChangeWatcher was wrapped in a WatcherWrapper, return the
     * correct Object that the client has set.
     */
    @SuppressLint("UnknownNullness")
    @SuppressWarnings("unchecked")
    @Override
    public <T> T[] getSpans(int queryStart, int queryEnd, @NonNull 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(@Nullable Object what) {
        final WatcherWrapper watcher;
        if (isWatcher(what)) {
            watcher = getWatcherFor(what);
            if (watcher != null) {
                what = watcher;
            }
        } else {
            watcher = null;
        }

        super.removeSpan(what);

        if (watcher != null) {
            mWatchers.remove(watcher);
        }
    }

    /**
     * Return the correct start for the DynamicLayout$ChangeWatcher span.
     */
    @Override
    public int getSpanStart(@Nullable Object tag) {
        if (isWatcher(tag)) {
            final WatcherWrapper watcher = getWatcherFor(tag);
            if (watcher != null) {
                tag = watcher;
            }
        }
        return super.getSpanStart(tag);
    }

    /**
     * Return the correct end for the DynamicLayout$ChangeWatcher span.
     */
    @Override
    public int getSpanEnd(@Nullable Object tag) {
        if (isWatcher(tag)) {
            final WatcherWrapper watcher = getWatcherFor(tag);
            if (watcher != null) {
                tag = watcher;
            }
        }
        return super.getSpanEnd(tag);
    }

    /**
     * Return the correct flags for the DynamicLayout$ChangeWatcher span.
     */
    @Override
    public int getSpanFlags(@Nullable Object tag) {
        if (isWatcher(tag)) {
            final WatcherWrapper watcher = getWatcherFor(tag);
            if (watcher != null) {
                tag = watcher;
            }
        }
        return super.getSpanFlags(tag);
    }

    /**
     * Return the correct transition for the DynamicLayout$ChangeWatcher span.
     */
    @Override
    public int nextSpanTransition(int start, int limit, @Nullable Class type) {
        if (type == null || isWatcher(type)) {
            type = WatcherWrapper.class;
        }
        return super.nextSpanTransition(start, limit, type);
    }

    /**
     * Find the WatcherWrapper for a given DynamicLayout$ChangeWatcher.
     *
     * @param object DynamicLayout$ChangeWatcher mObject
     *
     * @return WatcherWrapper that wraps the mObject.
     */
    private WatcherWrapper getWatcherFor(Object object) {
        for (int i = 0; i < mWatchers.size(); i++) {
            WatcherWrapper watcher = mWatchers.get(i);
            if (watcher.mObject == object) {
                return watcher;
            }
        }
        return null;
    }

    /**
     * @hide
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public void beginBatchEdit() {
        blockWatchers();
    }

    /**
     * @hide
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public void endBatchEdit() {
        unblockwatchers();
        fireWatchers();
    }

    /**
     * Block all watcher wrapper events.
     */
    private void blockWatchers() {
        for (int i = 0; i < mWatchers.size(); i++) {
            mWatchers.get(i).blockCalls();
        }
    }

    /**
     * Unblock all watcher wrapper events.
     */
    private void unblockwatchers() {
        for (int i = 0; i < mWatchers.size(); i++) {
            mWatchers.get(i).unblockCalls();
        }
    }

    /**
     * Unblock all watcher wrapper events. Called by editing operations, namely
     * {@link SpannableStringBuilder#replace(int, int, CharSequence)}.
     */
    private void fireWatchers() {
        for (int i = 0; i < mWatchers.size(); i++) {
            mWatchers.get(i).onTextChanged(this, 0, this.length(), this.length());
        }
    }

    @SuppressLint("UnknownNullness")
    @Override
    public SpannableStringBuilder replace(int start, int end, CharSequence tb) {
        blockWatchers();
        super.replace(start, end, tb);
        unblockwatchers();
        return this;
    }

    @SuppressLint("UnknownNullness")
    @Override
    public SpannableStringBuilder replace(int start, int end, CharSequence tb, int tbstart,
            int tbend) {
        blockWatchers();
        super.replace(start, end, tb, tbstart, tbend);
        unblockwatchers();
        return this;
    }

    @SuppressLint("UnknownNullness")
    @Override
    public SpannableStringBuilder insert(int where, CharSequence tb) {
        super.insert(where, tb);
        return this;
    }

    @SuppressLint("UnknownNullness")
    @Override
    public SpannableStringBuilder insert(int where, CharSequence tb, int start, int end) {
        super.insert(where, tb, start, end);
        return this;
    }

    @SuppressLint("UnknownNullness")
    @Override
    public SpannableStringBuilder delete(int start, int end) {
        super.delete(start, end);
        return this;
    }

    @NonNull
    @Override
    public SpannableStringBuilder append(@SuppressLint("UnknownNullness") CharSequence text) {
        super.append(text);
        return this;
    }

    @NonNull
    @Override
    public SpannableStringBuilder append(char text) {
        super.append(text);
        return this;
    }

    @NonNull
    @Override
    public SpannableStringBuilder append(@SuppressLint("UnknownNullness") CharSequence text,
            int start,
            int end) {
        super.append(text, start, end);
        return this;
    }

    @SuppressLint("UnknownNullness")
    @Override
    public SpannableStringBuilder append(CharSequence text, Object what, int flags) {
        super.append(text, what, flags);
        return this;
    }

    /**
     * Wraps a DynamicLayout$ChangeWatcher in order to prevent firing of events to DynamicLayout.
     */
    private static class WatcherWrapper implements TextWatcher, SpanWatcher {
        @SuppressWarnings("WeakerAccess") /* synthetic access */
        final Object mObject;
        private final AtomicInteger mBlockCalls = new AtomicInteger(0);

        WatcherWrapper(Object object) {
            this.mObject = object;
        }

        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
            ((TextWatcher) mObject).beforeTextChanged(s, start, count, after);
        }

        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {
            ((TextWatcher) mObject).onTextChanged(s, start, before, count);
        }

        @Override
        public void afterTextChanged(Editable s) {
            ((TextWatcher) mObject).afterTextChanged(s);
        }

        /**
         * Prevent the onSpanAdded calls to DynamicLayout$ChangeWatcher if in a replace operation
         * (mBlockCalls is set) and the span that is added is an EmojiSpan.
         */
        @Override
        public void onSpanAdded(Spannable text, Object what, int start, int end) {
            if (mBlockCalls.get() > 0 && isEmojiSpan(what)) {
                return;
            }
            ((SpanWatcher) mObject).onSpanAdded(text, what, start, end);
        }

        /**
         * Prevent the onSpanRemoved calls to DynamicLayout$ChangeWatcher if in a replace operation
         * (mBlockCalls is set) and the span that is added is an EmojiSpan.
         */
        @Override
        public void onSpanRemoved(Spannable text, Object what, int start, int end) {
            if (mBlockCalls.get() > 0 && isEmojiSpan(what)) {
                return;
            }
            ((SpanWatcher) mObject).onSpanRemoved(text, what, start, end);
        }

        /**
         * Prevent the onSpanChanged calls to DynamicLayout$ChangeWatcher if in a replace operation
         * (mBlockCalls is set) and the span that is added is an EmojiSpan.
         */
        @Override
        public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart,
                int nend) {
            if (mBlockCalls.get() > 0 && isEmojiSpan(what)) {
                return;
            }
            ((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;
        }
    }

}