From e09bac77c6a95a456095030111dba75ef34aee37 Mon Sep 17 00:00:00 2001 From: Quin Gillespie Date: Mon, 26 Oct 2026 13:59:52 -0600 Subject: [PATCH] Let the app answer in the background before restoring focus when the screen turns on When the screen turns on, the app in front is busy drawing again, and restoring focus asked it for the last focused item from the main thread, which waited 100 to 300 ms on a watch (#152). The item is now fetched on a background thread first, and focus is restored once the app has answered, when its requests are quick, or after 500 ms at most. A restore still waiting is dropped when the screen changes again, or when focus moved in the meantime. --- differences.md | 2 + .../FocusProcessorForScreenStateChange.java | 29 ++++- .../focusmanagement/WakeFocusWarmUp.kt | 97 +++++++++++++++++ .../focusmanagement/WakeFocusWarmUpTest.kt | 103 ++++++++++++++++++ 4 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 talkback/src/main/java/com/google/android/accessibility/talkback/focusmanagement/WakeFocusWarmUp.kt create mode 100644 talkback/src/test/java/com/google/android/accessibility/talkback/focusmanagement/WakeFocusWarmUpTest.kt diff --git a/differences.md b/differences.md index 1fce82f99..540ae130f 100644 --- a/differences.md +++ b/differences.md @@ -72,6 +72,8 @@ When you touched an empty part of the screen, TalkBack waited 100 ms after the f After a window changes, TalkBack waits for the window to settle before it says the title. TalkBack counted that wait in a way that made 550 ms last about 900 ms. Backtalk counts the real time. +When the screen turns on, the app in front is busy drawing again. TalkBack asked it for the last focused item to put focus back, and stopped everything else until the app answered, for up to 300 ms on a watch. Backtalk asks in the background and puts focus back once the app answers, or after half a second at most. + The **Turn off animations** setting in **Controls** > **Touch and activation** > **Reduce delay** turns off animations for the whole phone, so screens change immediately and the wait is only 200 ms. TalkBack called this setting **Reduce window announcement delay**, and had disabled the code that turns animations off, so the setting did nothing. Backtalk makes the setting work, and turns animations back on when you turn Backtalk off. On Android 12 and earlier, which don't let Backtalk turn animations off, the setting only shortens the wait. The setting is on by default. When you activated an item, or pressed a key, while the item was being spoken or just after, TalkBack still spoke its usage hint, such as "Double-tap to activate", and the name of the new window waited for the hint. Backtalk drops the hint. diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/focusmanagement/FocusProcessorForScreenStateChange.java b/talkback/src/main/java/com/google/android/accessibility/talkback/focusmanagement/FocusProcessorForScreenStateChange.java index 2f83ddbef..652f54169 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/focusmanagement/FocusProcessorForScreenStateChange.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/focusmanagement/FocusProcessorForScreenStateChange.java @@ -36,6 +36,7 @@ import com.google.android.accessibility.utils.Performance.EventId; import com.google.android.accessibility.utils.input.WindowEventInterpreter; import com.google.android.libraries.accessibility.utils.log.LogUtils; +import org.checkerframework.checker.nullness.qual.Nullable; /** The event-interpreter for window-events affecting focus. */ public class FocusProcessorForScreenStateChange { @@ -100,6 +101,8 @@ public void setActorState(ActorState actorState) { * @return return {@code true} if event is interpreted as a valid focus-event */ public boolean onScreenStateChanged(ScreenState screenState, EventId eventId) { + // A restore still waiting for the app after the screen turned on is for an older screen. + WakeFocusWarmUp.cancel(); long windowEventInterpreterDelayTimeMs = SystemClock.uptimeMillis() - screenState.getScreenTransitionStartTime(); LogUtils.d( @@ -167,10 +170,28 @@ FocusResult onScreenStateChangedInternal(ScreenState screenState, EventId eventI boolean forceRestoreFocus = (actorState.getOverrideFocusRestoreUptimeMs() > handledOverrideFocusRestoreUptimeMs); - pipeline.input( - eventId, - /* event= */ null, - Interpretation.WindowChange.create(forceRestoreFocus, screenState)); + Runnable setInitialFocus = + () -> + pipeline.input( + eventId, + /* event= */ null, + Interpretation.WindowChange.create(forceRestoreFocus, screenState)); + if (screenState.isInterpretFirstTimeWhenWakeUp() && (lastRecordOnActiveWindow != null)) { + // The app is drawing again after the screen turned on, and restoring focus would wait for + // it on the main thread. See WakeFocusWarmUp. + long requestedUptimeMs = SystemClock.uptimeMillis(); + WakeFocusWarmUp.restoreAfterAppAnswers( + lastRecordOnActiveWindow.getFocusedNode(), + () -> { + // Leave focus that the user moved while the app was answering. + @Nullable FocusActionRecord last = history.getLastFocusActionRecord(); + if ((last == null) || (last.getActionTime() < requestedUptimeMs)) { + setInitialFocus.run(); + } + }); + } else { + setInitialFocus.run(); + } return FocusResult.SUCCESS; diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/focusmanagement/WakeFocusWarmUp.kt b/talkback/src/main/java/com/google/android/accessibility/talkback/focusmanagement/WakeFocusWarmUp.kt new file mode 100644 index 000000000..cbf2041a6 --- /dev/null +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/focusmanagement/WakeFocusWarmUp.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2026 Backtalk contributors + * + * 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 com.google.android.accessibility.talkback.focusmanagement + +import android.os.Handler +import android.os.Looper +import androidx.annotation.VisibleForTesting +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.libraries.accessibility.utils.log.LogUtils +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException + +/** + * Lets the app answer once, off the main thread, before focus is restored when the screen turns on + * (#152). + * + * When the screen turns on, the app in front is busy drawing again, and the first request Backtalk + * sends it waits until that's done. Restoring focus sent that request from the main thread, which + * then stopped for 100 to 300 ms on a watch. Now the last focused item is fetched on a background + * thread first, and the restore runs once the app has answered, when its requests are quick. If the + * app takes longer than [TIMEOUT_MS], the restore runs anyway, as it did before. + */ +object WakeFocusWarmUp { + private const val TAG = "WakeFocusWarmUp" + + /** How long to wait for the app before restoring focus anyway. */ + @VisibleForTesting const val TIMEOUT_MS = 500L + + private val main by lazy { Handler(Looper.getMainLooper()) } + + @VisibleForTesting + var executor: Executor = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, TAG).apply { isDaemon = true } + } + + // Counts requests, so that only the latest one restores focus. Main thread only. + private var latest = 0 + + /** + * Runs [restore] on the main thread once the app of [node] has answered a request for it, or + * after [TIMEOUT_MS]. Call on the main thread. A later call, or [cancel], drops the restore. + */ + @JvmStatic + fun restoreAfterAppAnswers(node: AccessibilityNodeInfoCompat, restore: Runnable) { + val request = ++latest + val copy = AccessibilityNodeInfoCompat.obtain(node) + val once = + object : Runnable { + var ran = false + + override fun run() { + main.removeCallbacks(this) + if (ran || request != latest) { + return + } + ran = true + restore.run() + } + } + try { + executor.execute { + try { + copy.refresh() + } catch (e: RuntimeException) { + LogUtils.w(TAG, "Could not fetch the last focused item: %s", e) + } + main.post(once) + } + } catch (e: RejectedExecutionException) { + once.run() + return + } + main.postDelayed(once, TIMEOUT_MS) + } + + /** Drops a restore that is waiting for its app, as when the screen changes again. */ + @JvmStatic + fun cancel() { + latest++ + } +} diff --git a/talkback/src/test/java/com/google/android/accessibility/talkback/focusmanagement/WakeFocusWarmUpTest.kt b/talkback/src/test/java/com/google/android/accessibility/talkback/focusmanagement/WakeFocusWarmUpTest.kt new file mode 100644 index 000000000..12deb0134 --- /dev/null +++ b/talkback/src/test/java/com/google/android/accessibility/talkback/focusmanagement/WakeFocusWarmUpTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2026 Backtalk contributors + * + * 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 com.google.android.accessibility.talkback.focusmanagement + +import android.os.Looper +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import java.time.Duration +import java.util.concurrent.Executor +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class WakeFocusWarmUpTest { + // Holds the background work until a test lets the app "answer". + private val pending = ArrayDeque() + private val held = Executor { pending.add(it) } + private lateinit var original: Executor + private val node = AccessibilityNodeInfoCompat.obtain() + private var restores = 0 + + @Before + fun holdBackgroundWork() { + original = WakeFocusWarmUp.executor + WakeFocusWarmUp.executor = held + } + + @After + fun restoreExecutor() { + WakeFocusWarmUp.executor = original + } + + @Test + fun restore_waitsForTheApp() { + WakeFocusWarmUp.restoreAfterAppAnswers(node) { restores++ } + idleMain() + assertEquals(0, restores) + + answer() + assertEquals(1, restores) + } + + @Test + fun restore_runsAfterTheTimeout_andOnlyOnce() { + WakeFocusWarmUp.restoreAfterAppAnswers(node) { restores++ } + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(WakeFocusWarmUp.TIMEOUT_MS)) + assertEquals(1, restores) + + answer() + assertEquals(1, restores) + } + + @Test + fun cancel_dropsTheRestore() { + WakeFocusWarmUp.restoreAfterAppAnswers(node) { restores++ } + WakeFocusWarmUp.cancel() + answer() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(WakeFocusWarmUp.TIMEOUT_MS)) + assertEquals(0, restores) + } + + @Test + fun aLaterRequest_dropsTheEarlierOne() { + var earlier = 0 + WakeFocusWarmUp.restoreAfterAppAnswers(node) { earlier++ } + WakeFocusWarmUp.restoreAfterAppAnswers(node) { restores++ } + answer() + answer() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(WakeFocusWarmUp.TIMEOUT_MS)) + assertEquals(0, earlier) + assertEquals(1, restores) + } + + /** Lets the oldest background fetch finish, then runs what it posts to the main thread. */ + private fun answer() { + pending.removeFirst().run() + idleMain() + } + + private fun idleMain() { + shadowOf(Looper.getMainLooper()).idle() + } +}