blob: 66fa30107d2d330180f4e387118864312f71c209 [file] [log] [blame]
/*
* 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 android.view.accessibility;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.util.Log;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewRootImpl;
import android.view.ViewRootImpl.CalledFromWrongThreadException;
/**
* A throttling {@link AccessibilityEvent} sender that relies on its currently associated
* 'source' view's {@link View#postDelayed delayed execution} to delay and possibly
* {@link #tryMerge merge} together any events that come in less than
* {@link ViewConfiguration#getSendRecurringAccessibilityEventsInterval
* the configured amount of milliseconds} apart.
*
* The suggested usage is to create a singleton extending this class, holding any state specific to
* the particular event type that the subclass represents, and have an 'entrypoint' method that
* delegates to {@link #scheduleFor(View)}.
* For example:
*
* {@code
* public void post(View view, String text, int resId) {
* mText = text;
* mId = resId;
* scheduleFor(view);
* }
* }
*
* @see #scheduleFor(View)
* @see #tryMerge(View, View)
* @see #performSendEvent(View)
* @hide
*/
public abstract class ThrottlingAccessibilityEventSender {
private static final boolean DEBUG = false;
private static final String LOG_TAG = "ThrottlingA11ySender";
View mSource;
private long mLastSendTimeMillis = Long.MIN_VALUE;
private boolean mIsPending = false;
private final Runnable mWorker = () -> {
View source = mSource;
if (DEBUG) Log.d(LOG_TAG, thisClass() + ".run(mSource = " + source + ")");
if (!checkAndResetIsPending() || source == null) {
resetStateInternal();
return;
}
// Accessibility may be turned off while we were waiting
if (isAccessibilityEnabled(source)) {
mLastSendTimeMillis = SystemClock.uptimeMillis();
performSendEvent(source);
}
resetStateInternal();
};
/**
* Populate and send an {@link AccessibilityEvent} using the given {@code source} view, as well
* as any extra data from this instance's state.
*
* Send the event via {@link View#sendAccessibilityEventUnchecked(AccessibilityEvent)} or
* {@link View#sendAccessibilityEvent(int)} on the provided {@code source} view to allow for
* overrides of those methods on {@link View} subclasses to take effect, and/or make sure that
* an {@link View#getAccessibilityDelegate() accessibility delegate} is not ignored if any.
*/
protected abstract void performSendEvent(@NonNull View source);
/**
* Perform optional cleanup after {@link #performSendEvent}
*
* @param source the view this event was associated with
*/
protected abstract void resetState(@Nullable View source);
/**
* Attempt to merge the pending events for source views {@code oldSource} and {@code newSource}
* into one, with source set to the resulting {@link View}
*
* A result of {@code null} means merger is not possible, resulting in the currently pending
* event being flushed before proceeding.
*/
protected @Nullable View tryMerge(@NonNull View oldSource, @NonNull View newSource) {
return null;
}
/**
* Schedules a {@link #performSendEvent} with the source {@link View} set to given
* {@code source}
*
* If an event is already scheduled a {@link #tryMerge merge} will be attempted.
* If merging is not possible (as indicated by the null result from {@link #tryMerge}),
* the currently scheduled event will be {@link #sendNow sent immediately} and the new one
* will be scheduled afterwards.
*/
protected final void scheduleFor(@NonNull View source) {
if (DEBUG) Log.d(LOG_TAG, thisClass() + ".scheduleFor(source = " + source + ")");
Handler uiHandler = source.getHandler();
if (uiHandler == null || uiHandler.getLooper() != Looper.myLooper()) {
CalledFromWrongThreadException e = new CalledFromWrongThreadException(
"Expected to be called from main thread but was called from "
+ Thread.currentThread());
// TODO: Throw the exception
Log.e(LOG_TAG, "Accessibility content change on non-UI thread. Future Android "
+ "versions will throw an exception.", e);
}
if (!isAccessibilityEnabled(source)) return;
if (mIsPending) {
View merged = tryMerge(mSource, source);
if (merged != null) {
setSource(merged);
return;
} else {
sendNow();
}
}
setSource(source);
final long timeSinceLastMillis = SystemClock.uptimeMillis() - mLastSendTimeMillis;
final long minEventIntervalMillis =
ViewConfiguration.getSendRecurringAccessibilityEventsInterval();
if (timeSinceLastMillis >= minEventIntervalMillis) {
sendNow();
} else {
mSource.postDelayed(mWorker, minEventIntervalMillis - timeSinceLastMillis);
}
}
static boolean isAccessibilityEnabled(@NonNull View contextProvider) {
return AccessibilityManager.getInstance(contextProvider.getContext()).isEnabled();
}
protected final void sendNow(View source) {
setSource(source);
sendNow();
}
private void sendNow() {
mSource.removeCallbacks(mWorker);
mWorker.run();
}
/**
* Flush the event if one is pending
*/
public void sendNowIfPending() {
if (mIsPending) sendNow();
}
/**
* Cancel the event if one is pending and is for the given view
*/
public final void cancelIfPendingFor(@NonNull View source) {
if (isPendingFor(source)) cancelIfPending(this);
}
/**
* @return whether an event is currently pending for the given source view
*/
protected final boolean isPendingFor(@Nullable View source) {
return mIsPending && mSource == source;
}
/**
* Cancel the event if one is not null and pending
*/
public static void cancelIfPending(@Nullable ThrottlingAccessibilityEventSender sender) {
if (sender == null || !sender.checkAndResetIsPending()) return;
sender.mSource.removeCallbacks(sender.mWorker);
sender.resetStateInternal();
}
void resetStateInternal() {
if (DEBUG) Log.d(LOG_TAG, thisClass() + ".resetStateInternal()");
resetState(mSource);
setSource(null);
}
boolean checkAndResetIsPending() {
if (mIsPending) {
mIsPending = false;
return true;
} else {
return false;
}
}
private void setSource(@Nullable View source) {
if (DEBUG) Log.d(LOG_TAG, thisClass() + ".setSource(" + source + ")");
if (source == null && mIsPending) {
Log.e(LOG_TAG, "mSource nullified while callback still pending: " + this);
return;
}
if (source != null && !mIsPending) {
// At most one can be pending at any given time
View oldSource = mSource;
if (oldSource != null) {
ViewRootImpl viewRootImpl = oldSource.getViewRootImpl();
if (viewRootImpl != null) {
viewRootImpl.flushPendingAccessibilityEvents();
}
}
mIsPending = true;
}
mSource = source;
}
String thisClass() {
return getClass().getSimpleName();
}
@Override
public String toString() {
return thisClass() + "(" + mSource + ")";
}
}