From 0c0a20a6a472f181f0c482426a0a64d71f61b34f Mon Sep 17 00:00:00 2001 From: willbilec Date: Sat, 10 Oct 2026 12:17:50 -0600 Subject: [PATCH 1/4] Add phone option to suppress extra screen-on speech --- differences.md | 4 + lint-baseline.xml | 22 ++ .../talkback/TalkBackService.java | 51 +++- .../actor/FocusActorForScreenStateChange.java | 6 +- .../talkback/actor/FocusManagerInternal.java | 3 +- .../actor/helper/FocusActorHelper.java | 10 +- .../talkback/compositor/Compositor.java | 9 + .../feedbackpolicy/ScreenFeedbackManager.java | 6 + .../monitor/RingerModeAndScreenMonitor.java | 4 + .../talkback/monitor/ScreenAnnouncements.kt | 8 +- .../monitor/ScreenOnSpeechSuppression.kt | 114 +++++++ .../preference/TalkBackPreferenceFilter.java | 2 + .../src/main/res/values/donottranslate.xml | 2 + talkback/src/main/res/values/strings.xml | 4 + .../xml/screen_announcement_preferences.xml | 5 + .../feedbackpolicy/ScreenOnSuppressionTest.kt | 279 ++++++++++++++++++ .../monitor/ScreenAnnouncementsTest.kt | 52 +++- 17 files changed, 565 insertions(+), 16 deletions(-) create mode 100644 talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt create mode 100644 talkback/src/test/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenOnSuppressionTest.kt diff --git a/differences.md b/differences.md index 2bd759db5..6e7343d31 100644 --- a/differences.md +++ b/differences.md @@ -357,6 +357,10 @@ Backtalk doesn't stop speech when something covers the proximity sensor. To turn In **Verbosity** > **Screen on and off**, you can choose what Backtalk says when the screen turns off, when it turns on, and when you unlock the phone. When the screen turns off, Backtalk says "Screen off" and the ringer mode. When the screen turns on, it says the time, and it can also say the battery, the Wi-Fi network, the mobile network, the ringer and Do Not Disturb, and airplane mode, after the time in the same announcement. When you unlock the phone, it says "Device unlocked". Each of these has its own switch. By default, everything except the extra screen-on items is on. +On phones, under **When the screen turns on**, **Suppress extra speech** limits automatic wake speech to the time and status items you selected. It skips lock-screen information, such as the carrier and Extend Unlock, and the automatically restored focused item. Focus still returns, and swiping or touching items speaks normally. With no screen-on items selected, waking is silent. This option is off by default and is hidden and inactive on Wear OS. The separate **Say "Device unlocked"** switch still controls the unlock announcement. + +Some phones send several lock-screen announcements and focus events as they wake. Suppression continues through those automatic events until you start interacting by touch, gesture, a navigation key, or a focus navigation command. Notifications and the selected time and status announcements still speak normally. + These switches are separate from **Status readout**, so the status gesture can say different things. Backtalk doesn't make the screen-on announcement during calls, and the lock screen no longer cuts off "Screen off". ## Pausing Backtalk diff --git a/lint-baseline.xml b/lint-baseline.xml index a15ed0d51..70b7a8056 100644 --- a/lint-baseline.xml +++ b/lint-baseline.xml @@ -25186,4 +25186,26 @@ column="13"/> + + + + + + + + diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/TalkBackService.java b/talkback/src/main/java/com/google/android/accessibility/talkback/TalkBackService.java index 36ca847e4..d1f041bab 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/TalkBackService.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/TalkBackService.java @@ -217,6 +217,7 @@ import com.google.android.accessibility.talkback.monitor.KeyboardLockMonitor; import com.google.android.accessibility.talkback.monitor.ProximitySensorMonitor; import com.google.android.accessibility.talkback.monitor.RingerModeAndScreenMonitor; +import com.google.android.accessibility.talkback.monitor.ScreenOnSpeechSuppression; import com.google.android.accessibility.talkback.monitor.VolumeMonitor; import com.google.android.accessibility.talkback.pause.PauseController; import com.google.android.accessibility.talkback.preference.PreferencesActivityUtils; @@ -820,6 +821,8 @@ public interface GestureDetectionState { private EventLatencyLogger eventLatencyLogger; private UserInterface userInterface; + private @Nullable ScreenOnSpeechSuppression screenOnSpeechSuppression; + private DisableTalkBackDialog disableTalkbackDialog; private Configuration lastConfiguration; @@ -1083,6 +1086,9 @@ private boolean didScreenPropertiesChange(Configuration oldConfig, Configuration @Override public void onAccessibilityEvent(AccessibilityEvent event) { + if (screenOnSpeechSuppression != null) { + screenOnSpeechSuppression.onAccessibilityEvent(event); + } // Paused Backtalk drops events, except the end of a touch that started before the pause, so // that nothing still thinks a finger is down after resuming. Its feedback is dropped too. if (PauseController.isPaused() @@ -1312,6 +1318,9 @@ && switch (physicalKeyboardEcho) { */ @Override protected final boolean onKeyEvent(KeyEvent keyEvent) { + if (screenOnSpeechSuppression != null) { + screenOnSpeechSuppression.onKeyEvent(keyEvent); + } boolean result = onKeyEventInternal(keyEvent); if (primesController != null) { @@ -1496,6 +1505,9 @@ private void onGestureDebug(GestureAnalyticsEvent analyticsEvent) { /** Called by {@link TouchInteractionMonitor} when gesture detection started. */ public void onGestureDetectionStarted() { + if (screenOnSpeechSuppression != null) { + screenOnSpeechSuppression.onUserInteraction(); + } if (processorPhoneticLetters != null) { processorPhoneticLetters.cancelPhoneticLetter(EVENT_ID_UNTRACKED); } @@ -1505,6 +1517,9 @@ private boolean handleOnGestureById(int displayId, int gestureId) { if (!isServiceActive() || PauseController.isPaused()) { return false; } + if (screenOnSpeechSuppression != null) { + screenOnSpeechSuppression.onUserInteraction(); + } // Return before feedback, training, menus and gesture recording. Both callback overloads // use this entry point; ignoring an action in GestureController still reports it as handled. if (VibrationWatchGestureSettings.shouldReserve( @@ -1936,6 +1951,9 @@ private void initializeFingerprintGestureCallback() { @Override public void onGestureDetected(int gesture) { if (isServiceActive() && !PauseController.isPaused() && gestureController != null) { + if (screenOnSpeechSuppression != null) { + screenOnSpeechSuppression.onUserInteraction(); + } Performance perf = Performance.getInstance(); EventId eventId = perf.onFingerprintGestureEventReceived(gesture); @@ -1974,6 +1992,15 @@ private void initializeInfrastructure() { // not unpaired // supportsTouchScreen = packageManager.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); displayMonitor = new DisplayMonitor(this); + screenOnSpeechSuppression = + new ScreenOnSpeechSuppression( + prefs, + getSystemService(android.hardware.display.DisplayManager.class) + .getDisplay(Display.DEFAULT_DISPLAY).getState() + == Display.STATE_ON, + FormFactorUtils.isAndroidWear()); + // Register first so wake feedback and focus listeners observe the same display transition. + displayMonitor.addDisplayStateChangedListener(screenOnSpeechSuppression); accessibilityEventProcessor = new AccessibilityEventProcessor(this, displayMonitor); feedbackController = new FeedbackController(this); feedbackController.setSoundHaptics(SoundVibrations.patternIds(this)); @@ -2097,6 +2124,7 @@ public long getSpeechCacheWindowCounts() { globalVariables); userInterface = new UserInterface(); + userInterface.registerListener(screenOnSpeechSuppression); userInterface.registerListener(focuser); inputDeviceMonitor = new InputDeviceMonitor(this); @@ -2397,6 +2425,7 @@ public long getSpeechCacheWindowCounts() { displayMonitor, statusReader, this); + ringerModeAndScreenMonitor.addScreenChangedListener(screenOnSpeechSuppression); if (speechCacheController != null) { ringerModeAndScreenMonitor.addScreenChangedListener(speechCacheController); } @@ -2460,14 +2489,16 @@ public void onPausedChanged(boolean paused) { eventFilter = new EventFilter(this, compositor, touchMonitor, globalVariables, earlyFocusSpeech); focuser.setFocusSetListener( - (node, info, eventId, actionTime) -> - eventFilter.onAccessibilityFocusSet( - node, - info, - eventId, - actionTime, - /* continuousReading= */ fullScreenReadActor != null - && fullScreenReadActor.isActive())); + (node, info, eventId, actionTime) -> { + screenOnSpeechSuppression.onFocusAction(info); + eventFilter.onAccessibilityFocusSet( + node, + info, + eventId, + actionTime, + /* continuousReading= */ fullScreenReadActor != null + && fullScreenReadActor.isActive()); + }); eventFilter.setTargetPredictor(directionNavigationActor::predictTarget); eventFilter.setFingerDownSupplier( () -> @@ -2681,6 +2712,10 @@ public WindowEventInterpreter getWindowEventInterpreter() { return windowEventInterpreter; } + public @Nullable ScreenOnSpeechSuppression getScreenOnSpeechSuppression() { + return screenOnSpeechSuppression; + } + private final TouchInteractingIndicator touchInteractingIndicator = new TouchInteractingIndicator() { @Override diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusActorForScreenStateChange.java b/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusActorForScreenStateChange.java index d2f8258d9..044fe9c5b 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusActorForScreenStateChange.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusActorForScreenStateChange.java @@ -209,7 +209,7 @@ public boolean restoreLastFocusedNode(ScreenState screenState, EventId eventId) boolean firstTime = screenState.isInterpretFirstTimeWhenWakeUp(); boolean forceMuteFeedback = - FocusActorHelper.shouldMuteFeedbackForFocusedNode(nodeToRestoreFocus, screenState); + FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, nodeToRestoreFocus, screenState); FocusActionInfo focusActionInfo = FOCUS_ACTION_INFO_RESTORED_BUILDER.setForceMuteFeedback(forceMuteFeedback).build(); @@ -270,7 +270,7 @@ public boolean syncAccessibilityFocusAndInputFocus(ScreenState screenState, Even boolean firstTime = screenState.isInterpretFirstTimeWhenWakeUp(); boolean forceMuteFeedback = - FocusActorHelper.shouldMuteFeedbackForFocusedNode(nodeForSync, screenState); + FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, nodeForSync, screenState); FocusActionInfo focusActionInfo = FOCUS_ACTION_INFO_SYNCED_INPUT_FOCUS_BUILDER .setForceMuteFeedback(forceMuteFeedback) @@ -433,7 +433,7 @@ public boolean accept(AccessibilityNodeInfoCompat node) { boolean firstTime = screenState.isInterpretFirstTimeWhenWakeUp(); boolean forceMuteFeedback = - FocusActorHelper.shouldMuteFeedbackForFocusedNode(nodeToFocus, screenState); + FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, nodeToFocus, screenState); FocusActionInfo focusActionInfo = FOCUS_ACTION_INFO_REQUEST_INITIAL_NODE_BUILDER diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusManagerInternal.java b/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusManagerInternal.java index 5638de8f6..8890757ff 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusManagerInternal.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusManagerInternal.java @@ -332,9 +332,8 @@ private boolean requestA11yFocusFromLastFocusRecord(EventId eventId) { boolean firstTime = stableScreenState != null && stableScreenState.isInterpretFirstTimeWhenWakeUp(); - // Cell broadcast is an emergent announcement, so we don't mute it. boolean forceMuteFeedback = - FocusActorHelper.shouldMuteFeedbackForFocusedNode(nodeToFocus, stableScreenState); + FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, nodeToFocus, stableScreenState); FocusActionInfo focusActionInfo = FocusActionInfo.builder() diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/actor/helper/FocusActorHelper.java b/talkback/src/main/java/com/google/android/accessibility/talkback/actor/helper/FocusActorHelper.java index bfde791c2..29d0598a7 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/actor/helper/FocusActorHelper.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/actor/helper/FocusActorHelper.java @@ -29,6 +29,7 @@ import com.google.android.accessibility.talkback.focusmanagement.interpreter.ScreenState; import com.google.android.accessibility.talkback.focusmanagement.record.FocusActionInfo; import com.google.android.accessibility.talkback.keyboard.KeyComboManagerHelper; +import com.google.android.accessibility.talkback.monitor.ScreenOnSpeechSuppression; import com.google.android.accessibility.utils.AccessibilityWindowInfoUtils; import com.google.android.accessibility.utils.BuildVersionUtils; import com.google.android.accessibility.utils.FormFactorUtils; @@ -45,11 +46,18 @@ public final class FocusActorHelper { * Checks whether the feedback for the focused node should be muted. * * @param nodeToFocus the node to check + * @param context the context used to read screen announcement preferences * @param screenState the screen state for current active window * @return {@code true} if the focused node should be muted. */ public static boolean shouldMuteFeedbackForFocusedNode( - AccessibilityNodeInfoCompat nodeToFocus, ScreenState screenState) { + Context context, AccessibilityNodeInfoCompat nodeToFocus, @Nullable ScreenState screenState) { + if (ScreenOnSpeechSuppression.isSuppressing(context)) { + return true; + } + if (screenState == null) { + return false; + } // We mute the feedback of focused node only when it wakes up on the Home Screen and not on CB. return FormFactorUtils.isAndroidWear() && screenState.isInterpretFirstTimeWhenWakeUp() diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/Compositor.java b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/Compositor.java index e8b906adc..0471c645e 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/Compositor.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/Compositor.java @@ -31,6 +31,7 @@ import com.google.android.accessibility.talkback.compositor.roledescription.RoleDescriptionExtractor; import com.google.android.accessibility.talkback.eventprocessor.ProcessorPhoneticLetters; import com.google.android.accessibility.talkback.imagecaption.ImageContents; +import com.google.android.accessibility.talkback.monitor.ScreenOnSpeechSuppression; import com.google.android.accessibility.talkback.scripting.Scripts; import com.google.android.accessibility.utils.AccessibilityEventUtils; import com.google.android.accessibility.utils.Performance.EventId; @@ -501,6 +502,14 @@ private void handleEvent( @Nullable EventId eventId, HandleEventOptions options, LoadSpeechResultNotifier synthesizeResultNotifier) { + // Selection and text interpreters can bypass EventFilter. Apply the wake policy at the common + // output boundary, after event interpretation/state updates, for every raw-event speech path. + ScreenOnSpeechSuppression wakeSuppression = ScreenOnSpeechSuppression.from(mContext); + if (options.eventObject != null + && wakeSuppression != null + && wakeSuppression.shouldSuppressEvent(options.eventObject)) { + return; + } // Extract options. @Nullable EventInterpretation eventInterpretation = options.eventInterpretation; if (eventInterpretation != null) { diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenFeedbackManager.java b/talkback/src/main/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenFeedbackManager.java index 23e97e766..4c8f8309f 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenFeedbackManager.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenFeedbackManager.java @@ -41,6 +41,7 @@ import com.google.android.accessibility.talkback.eventprocessor.EventState; import com.google.android.accessibility.talkback.eventprocessor.ProcessorAccessibilityHints; import com.google.android.accessibility.talkback.flags.FeatureFlagReader; +import com.google.android.accessibility.talkback.monitor.ScreenOnSpeechSuppression; import com.google.android.accessibility.talkback.gesture.GestureHints; import com.google.android.accessibility.talkback.gesture.GestureShortcutMapping; import com.google.android.accessibility.utils.AccessibilityEventListener; @@ -332,6 +333,11 @@ public Feedback composeFeedback( Feedback feedback = new Feedback(); + // Keep window interpretation and focus restoration intact; only omit automatic wake speech. + if (ScreenOnSpeechSuppression.isSuppressing(service)) { + return feedback; + } + if (isWakeUpForWear(interpretation)) { // Only announce unread notification feedback on wake up experience for wear and have early // return to avoid unwanted window title announcement. diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/RingerModeAndScreenMonitor.java b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/RingerModeAndScreenMonitor.java index a2efd73d4..d89b5dee8 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/RingerModeAndScreenMonitor.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/RingerModeAndScreenMonitor.java @@ -413,6 +413,10 @@ private void speakForDisplayOn(EventId eventId) { } } + if (ttsText.length() == 0) { + return; + } + SpeakOptions speakOptions = SpeakOptions.create() // Uses QUEUE_MODE_UNINTERRUPTIBLE_BY_NEW_SPEECH so that time announcement does not diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncements.kt b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncements.kt index e09a744a1..3ce6eec01 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncements.kt +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncements.kt @@ -28,6 +28,7 @@ object ScreenAnnouncementSettings { const val SAY_SCREEN_OFF = "pref_screen_off_say_screen_off" const val SCREEN_OFF_RINGER = "pref_screen_off_ringer_mode" const val SAY_UNLOCKED = "pref_unlock_say_unlocked" + const val SUPPRESS_EXTRA_SCREEN_ON_SPEECH = "pref_screen_on_suppress_extra_speech" /** * The status items that can be spoken after the time as the screen turns on, in the order they @@ -45,7 +46,12 @@ object ScreenAnnouncementSettings { /** Each setting's default, which is what Backtalk said before these settings existed. */ val DEFAULTS: Map = - mapOf(SAY_SCREEN_OFF to true, SCREEN_OFF_RINGER to true, SAY_UNLOCKED to true) + + mapOf( + SAY_SCREEN_OFF to true, + SCREEN_OFF_RINGER to true, + SAY_UNLOCKED to true, + SUPPRESS_EXTRA_SCREEN_ON_SPEECH to false, + ) + SCREEN_ON_STATUS.values.associateWith { false } @JvmStatic diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt new file mode 100644 index 000000000..ef827b4c6 --- /dev/null +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt @@ -0,0 +1,114 @@ +/* + * 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.monitor + +import android.content.Context +import android.content.SharedPreferences +import android.view.KeyEvent +import android.view.accessibility.AccessibilityEvent +import com.google.android.accessibility.talkback.TalkBackService +import com.google.android.accessibility.talkback.UserInterface.UserInputEventListener +import com.google.android.accessibility.talkback.focusmanagement.record.FocusActionInfo +import com.google.android.accessibility.utils.Performance.EventId +import com.google.android.accessibility.utils.monitor.DisplayMonitor.DisplayStateChangedListener + +/** + * A wake can produce several window changes, announcements, and focus events. Keep automatic + * feedback quiet throughout that sequence, until the user starts interacting. This does not mute + * the speech controller: selected status, unlock speech, and notifications use their normal paths. + */ +class ScreenOnSpeechSuppression( + private val prefs: SharedPreferences, + initiallyScreenOn: Boolean, + private val isWear: Boolean = false, +) : DisplayStateChangedListener, RingerModeAndScreenMonitor.ScreenChangedListener, + UserInputEventListener { + private var screenOn = initiallyScreenOn + private var waitingForInteraction = false + + override fun onDisplayStateChanged(displayOn: Boolean) { + if (displayOn != screenOn) { + screenOn = displayOn + waitingForInteraction = displayOn + } + } + + override fun onScreenChanged(isInteractive: Boolean, eventId: EventId?) { + onDisplayStateChanged(isInteractive) + } + + fun isSuppressing(): Boolean = + !isWear && waitingForInteraction && + ScreenAnnouncementSettings.isOn(prefs, ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH) + + fun onUserInteraction() { + waitingForInteraction = false + } + + override fun touchInteractionState(active: Boolean) { + if (active) onUserInteraction() + } + + fun onKeyEvent(event: KeyEvent) { + if (event.action == KeyEvent.ACTION_DOWN && + event.keyCode != KeyEvent.KEYCODE_POWER && + event.keyCode != KeyEvent.KEYCODE_WAKEUP && + event.keyCode != KeyEvent.KEYCODE_SLEEP) { + onUserInteraction() + } + } + + fun onFocusAction(info: FocusActionInfo) { + when (info.sourceAction) { + FocusActionInfo.LOGICAL_NAVIGATION, FocusActionInfo.TOUCH_EXPLORATION, + FocusActionInfo.MANUAL_SCROLL, FocusActionInfo.KEYBOARD_SHORTCUT_REFOCUS -> onUserInteraction() + } + } + + fun onAccessibilityEvent(event: AccessibilityEvent) { + when (event.eventType) { + AccessibilityEvent.TYPE_TOUCH_INTERACTION_START, + AccessibilityEvent.TYPE_VIEW_HOVER_ENTER -> onUserInteraction() + } + } + + /** Focus can arrive without Backtalk's action history; System UI also posts later live updates. */ + fun shouldSuppressEvent(event: AccessibilityEvent): Boolean { + if (!isSuppressing()) return false + return when (event.eventType) { + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, + AccessibilityEvent.TYPE_VIEW_FOCUSED -> true + AccessibilityEvent.TYPE_ANNOUNCEMENT, + AccessibilityEvent.TYPE_VIEW_SELECTED, + AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED, + AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED, + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, + AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> event.packageName?.toString() == "com.android.systemui" + else -> false + } + } + + companion object { + /** All speech paths share the service's wake state, rather than independent one-shot flags. */ + @JvmStatic + fun from(context: Context): ScreenOnSpeechSuppression? = + (context as? TalkBackService)?.screenOnSpeechSuppression + + @JvmStatic + fun isSuppressing(context: Context): Boolean = from(context)?.isSuppressing() == true + } +} diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/preference/TalkBackPreferenceFilter.java b/talkback/src/main/java/com/google/android/accessibility/talkback/preference/TalkBackPreferenceFilter.java index c6044db72..cdaea3448 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/preference/TalkBackPreferenceFilter.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/preference/TalkBackPreferenceFilter.java @@ -183,6 +183,8 @@ enum TalkBackPreference { // A watch is not held to the ear for calls. SPEAKERPHONE_AWAY_FROM_EAR( R.string.pref_speakerphone_away_from_ear_key, HIDDEN_ON_WATCH | HIDDEN_ON_TV), + SCREEN_ON_SUPPRESS_EXTRA_SPEECH( + R.string.pref_screen_on_suppress_extra_speech_key, HIDDEN_ON_WATCH), // Pausing is turned off on watches, so there is nothing to resume. RESUME_BACKTALK(R.string.pref_resume_backtalk_key, HIDDEN_ON_WATCH | HIDDEN_ON_TV), // Pausing is turned off on TVs too: the volume keys often go to the TV or a soundbar, and there diff --git a/talkback/src/main/res/values/donottranslate.xml b/talkback/src/main/res/values/donottranslate.xml index 40e3dbba8..d991b52a2 100644 --- a/talkback/src/main/res/values/donottranslate.xml +++ b/talkback/src/main/res/values/donottranslate.xml @@ -11,6 +11,8 @@ pref_category_audio_key pref_screenoff pref_proximity + + pref_screen_on_suppress_extra_speech pref_speakerphone_away_from_ear diff --git a/talkback/src/main/res/values/strings.xml b/talkback/src/main/res/values/strings.xml index faed2be48..b18294bd5 100644 --- a/talkback/src/main/res/values/strings.xml +++ b/talkback/src/main/res/values/strings.xml @@ -327,6 +327,10 @@ Say \"Device unlocked\" Time + + Suppress extra speech + + Only announce the selected items when the screen turns on. Ringer mode diff --git a/talkback/src/main/res/xml/screen_announcement_preferences.xml b/talkback/src/main/res/xml/screen_announcement_preferences.xml index 1ed995e7d..9fff7ff7a 100644 --- a/talkback/src/main/res/xml/screen_announcement_preferences.xml +++ b/talkback/src/main/res/xml/screen_announcement_preferences.xml @@ -20,6 +20,11 @@ + () + compositor.setSpeaker(object : Compositor.Speaker { + override fun speak(text: CharSequence, eventId: EventId?, options: SpeakOptions) { + spoken.add(text.toString()) + } + + override fun addSpeech( + text: CharSequence, + eventId: EventId?, + speechParams: Bundle, + statusNotifier: LoadSpeechResultNotifier, + ) { + spoken.add(text.toString()) + } + }) + val filter = EventFilter(service, compositor, TouchMonitor(), globals, EarlyFocusSpeech()) + val extendUnlock = rawEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT).apply { text.add("Extend Unlock") } + val carrier = rawEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT).apply { text.add("Freedom") } + val carrierNode = AccessibilityNodeInfo.obtain().apply { + className = "android.widget.TextView" + text = "Freedom" + isSelected = true + } + val carrierMarquee = rawEvent(AccessibilityEvent.TYPE_VIEW_SELECTED).apply { text.add("Freedom") } + shadowOf(carrierMarquee).setSourceNode(carrierNode) + // SelectionEventInterpreter sends this directly through Mappers, bypassing EventFilter. + val selection = EventInterpretation(Compositor.EVENT_TYPE_VIEW_SELECTED) + compositor.handleEvent(carrierMarquee, null, selection) + assertEquals(listOf("Freedom"), spoken) + spoken.clear() + suppress(true) + service.suppression.onDisplayStateChanged(true) + filter.sendEvent(extendUnlock, null) + filter.sendEvent(carrier, null) + compositor.handleEvent(carrierMarquee, null, selection) + assertTrue(spoken.isEmpty()) + val appAnnouncement = rawEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT, "example.app").apply { + text.add("Download finished") + } + filter.sendEvent(appAnnouncement, null) + assertEquals(listOf("Download finished"), spoken) + service.suppression.onUserInteraction() + filter.sendEvent(extendUnlock, null) + filter.sendEvent(carrier, null) + compositor.handleEvent(carrierMarquee, null, selection) + assertEquals(listOf("Download finished", "Extend Unlock", "Freedom", "Freedom"), spoken) + } + + @Test + fun duplicateScreenOnSignalsDoNotMuteTheFirstNavigationOrItsWindow() { + suppress(true) + val suppression = service.suppression + suppression.onDisplayStateChanged(true) + suppression.onKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_POWER)) + assertTrue(suppression.isSuppressing()) + suppression.onKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT)) + suppression.onScreenChanged(true, null) + suppression.onDisplayStateChanged(true) + assertFalse(suppression.isSuppressing()) + assertFalse(composer.composeFeedback(announcement(false), 0).isEmpty) + suppression.onDisplayStateChanged(false) + suppression.onDisplayStateChanged(true) + assertTrue(suppression.isSuppressing()) + suppression.touchInteractionState(true) + assertFalse(suppression.isSuppressing()) + } + + @Test + fun suppressionPreferenceIsVisibleOnPhonesAndHiddenOnWear() { + for (wear in listOf(false, true)) { + shadowOf(service.packageManager).setSystemFeature(android.content.pm.PackageManager.FEATURE_WATCH, wear) + FormFactorUtils.initialize(service) + val manager = androidx.preference.PreferenceManager(service) + val screen = manager.createPreferenceScreen(service) + val preference = androidx.preference.SwitchPreference(service).apply { + key = ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH + } + screen.addPreference(preference) + com.google.android.accessibility.talkback.preference.TalkBackPreferenceFilter(service) + .filterPreferences(screen) + assertEquals(if (wear) 0 else 1, screen.preferenceCount) + } + } + + @Test + fun startingTheServiceWithTheScreenOnDoesNotSilenceNavigation() { + suppress(true) + val suppression = ScreenOnSpeechSuppression(SharedPreferencesUtils.getSharedPreferences(service), true) + suppression.onDisplayStateChanged(true) + assertFalse(suppression.isSuppressing()) + suppression.onDisplayStateChanged(false) + suppression.onDisplayStateChanged(true) + assertTrue(suppression.isSuppressing()) + } +} diff --git a/talkback/src/test/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncementsTest.kt b/talkback/src/test/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncementsTest.kt index 6a4646226..5c9d98078 100644 --- a/talkback/src/test/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncementsTest.kt +++ b/talkback/src/test/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncementsTest.kt @@ -39,7 +39,13 @@ class ScreenAnnouncementsTest { val onScreen = mutableMapOf() for (i in 0 until switches.length) { val switch = switches.item(i) as Element - val key = switch.getAttribute("android:key") + val resourceKey = switch.getAttribute("android:key") + val key = if (resourceKey == "@string/pref_screen_on_suppress_extra_speech_key") { + val strings = DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(File("src/main/res/values/donottranslate.xml")).getElementsByTagName("string") + (0 until strings.length).map { strings.item(it) as Element } + .first { it.getAttribute("name") == "pref_screen_on_suppress_extra_speech_key" }.textContent + } else resourceKey if (!key.startsWith("@")) { onScreen[key] = switch.getAttribute("android:defaultValue").toBooleanStrict() } @@ -55,6 +61,9 @@ class ScreenAnnouncementsTest { ScreenAnnouncementSettings.isOn(prefs, ScreenAnnouncementSettings.SCREEN_OFF_RINGER) ) assertTrue(ScreenAnnouncementSettings.isOn(prefs, ScreenAnnouncementSettings.SAY_UNLOCKED)) + assertFalse( + ScreenAnnouncementSettings.isOn(prefs, ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH) + ) assertEquals(emptyList(), ScreenAnnouncementSettings.screenOnStatusItems(prefs)) } @@ -84,4 +93,45 @@ class ScreenAnnouncementsTest { // The time has its own switch and is spoken first. assertFalse(StatusItem.TIME in ScreenAnnouncementSettings.SCREEN_ON_STATUS) } + + @Test + fun suppressionIsPersistedAndOnlyAppliesBeforeInteractionAfterWake() { + val prefs = FakeSharedPreferences() + val suppression = ScreenOnSpeechSuppression(prefs, false) + prefs.edit().putBoolean(ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH, true).apply() + assertFalse(suppression.isSuppressing()) + suppression.onDisplayStateChanged(true) + assertTrue(suppression.isSuppressing()) + prefs.edit().putBoolean(ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH, false).apply() + assertFalse(suppression.isSuppressing()) + prefs.edit().putBoolean(ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH, true).apply() + suppression.onUserInteraction() + assertFalse(suppression.isSuppressing()) + } + + @Test + fun suppressionIsInactiveOnWearEvenWithAnImportedEnabledPreference() { + val prefs = FakeSharedPreferences() + prefs.edit().putBoolean(ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH, true).apply() + val suppression = ScreenOnSpeechSuppression(prefs, false, isWear = true) + suppression.onDisplayStateChanged(true) + assertFalse(suppression.isSuppressing()) + } + + @Test + fun suppressionDoesNotChangeSelectedStatusOrUnlockAnnouncements() { + val prefs = FakeSharedPreferences() + prefs.edit() + .putBoolean(ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH, true) + .putBoolean("pref_screen_on_battery", true) + .putBoolean("pref_screen_on_airplane_mode", true) + .apply() + assertEquals( + listOf(StatusItem.BATTERY, StatusItem.AIRPLANE_MODE), + ScreenAnnouncementSettings.screenOnStatusItems(prefs), + ) + assertTrue(ScreenAnnouncementSettings.isOn(prefs, ScreenAnnouncementSettings.SAY_UNLOCKED)) + prefs.edit().putBoolean(ScreenAnnouncementSettings.SAY_UNLOCKED, false).apply() + assertFalse(ScreenAnnouncementSettings.isOn(prefs, ScreenAnnouncementSettings.SAY_UNLOCKED)) + } } From 9da9483801759a6851b82b759ac710c67132d830 Mon Sep 17 00:00:00 2001 From: willbilec Date: Sun, 11 Oct 2026 15:33:07 -0600 Subject: [PATCH 2/4] Limit wake suppression to lock-screen System UI --- differences.md | 4 +- lint-baseline.xml | 8 +- .../talkback/TalkBackService.java | 14 +- .../actor/FocusActorForScreenStateChange.java | 6 +- .../talkback/actor/FocusManagerInternal.java | 3 +- .../actor/helper/FocusActorHelper.java | 10 +- .../feedbackpolicy/ScreenFeedbackManager.java | 5 +- .../monitor/ScreenOnSpeechSuppression.kt | 104 ++++++++++-- .../main/res/values-cs/strings_backtalk.xml | 6 +- talkback/src/main/res/values/strings.xml | 8 +- .../xml/screen_announcement_preferences.xml | 10 +- .../feedbackpolicy/ScreenOnSuppressionTest.kt | 157 +++++++++++++++--- .../monitor/ScreenAnnouncementsTest.kt | 9 +- 13 files changed, 275 insertions(+), 69 deletions(-) diff --git a/differences.md b/differences.md index 6e7343d31..ee65b5275 100644 --- a/differences.md +++ b/differences.md @@ -357,9 +357,9 @@ Backtalk doesn't stop speech when something covers the proximity sensor. To turn In **Verbosity** > **Screen on and off**, you can choose what Backtalk says when the screen turns off, when it turns on, and when you unlock the phone. When the screen turns off, Backtalk says "Screen off" and the ringer mode. When the screen turns on, it says the time, and it can also say the battery, the Wi-Fi network, the mobile network, the ringer and Do Not Disturb, and airplane mode, after the time in the same announcement. When you unlock the phone, it says "Device unlocked". Each of these has its own switch. By default, everything except the extra screen-on items is on. -On phones, under **When the screen turns on**, **Suppress extra speech** limits automatic wake speech to the time and status items you selected. It skips lock-screen information, such as the carrier and Extend Unlock, and the automatically restored focused item. Focus still returns, and swiping or touching items speaks normally. With no screen-on items selected, waking is silent. This option is off by default and is hidden and inactive on Wear OS. The separate **Say "Device unlocked"** switch still controls the unlock announcement. +On phones, at the end of **When the screen turns on**, **Say only the items above** limits automatic System UI lock-screen wake speech to the time and status items you selected. It skips extra lock-screen information, such as the carrier and Extend Unlock, and the automatically restored lock-screen focused item. Focus still returns, and swiping or touching items speaks normally. With no screen-on items selected, the initial lock-screen wake announcement is silent. This option is off by default and is hidden and inactive on Wear OS. The separate **Say "Device unlocked"** switch still controls the unlock announcement. -Some phones send several lock-screen announcements and focus events as they wake. Suppression continues through those automatic events until you start interacting by touch, gesture, a navigation key, or a focus navigation command. Notifications and the selected time and status announcements still speak normally. +Some phones send several lock-screen announcements and focus events as they wake. Suppression ends when the device unlocks, you start interacting, the screen turns off, or three seconds pass. Unlocked wakes, other apps, incoming calls, alarms, timers, emergency alerts, notifications, and unknown window owners keep their normal speech. The selected time and status announcements still speak normally. Actual device testing has been limited to a Samsung Galaxy S23 Plus; behavior on non-Samsung phones, including Pixels, has not been verified. These switches are separate from **Status readout**, so the status gesture can say different things. Backtalk doesn't make the screen-on announcement during calls, and the lock screen no longer cuts off "Screen off". diff --git a/lint-baseline.xml b/lint-baseline.xml index 70b7a8056..3b54fb6d0 100644 --- a/lint-baseline.xml +++ b/lint-baseline.xml @@ -25188,8 +25188,8 @@ { screenOnSpeechSuppression.onFocusAction(info); + FocusActionInfo speechInfo = + screenOnSpeechSuppression.shouldSuppressFocus(node, info) + ? new FocusActionInfo.Builder(info).setForceMuteFeedback(true).build() + : info; eventFilter.onAccessibilityFocusSet( node, - info, + speechInfo, eventId, actionTime, /* continuousReading= */ fullScreenReadActor != null diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusActorForScreenStateChange.java b/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusActorForScreenStateChange.java index 044fe9c5b..d2f8258d9 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusActorForScreenStateChange.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusActorForScreenStateChange.java @@ -209,7 +209,7 @@ public boolean restoreLastFocusedNode(ScreenState screenState, EventId eventId) boolean firstTime = screenState.isInterpretFirstTimeWhenWakeUp(); boolean forceMuteFeedback = - FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, nodeToRestoreFocus, screenState); + FocusActorHelper.shouldMuteFeedbackForFocusedNode(nodeToRestoreFocus, screenState); FocusActionInfo focusActionInfo = FOCUS_ACTION_INFO_RESTORED_BUILDER.setForceMuteFeedback(forceMuteFeedback).build(); @@ -270,7 +270,7 @@ public boolean syncAccessibilityFocusAndInputFocus(ScreenState screenState, Even boolean firstTime = screenState.isInterpretFirstTimeWhenWakeUp(); boolean forceMuteFeedback = - FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, nodeForSync, screenState); + FocusActorHelper.shouldMuteFeedbackForFocusedNode(nodeForSync, screenState); FocusActionInfo focusActionInfo = FOCUS_ACTION_INFO_SYNCED_INPUT_FOCUS_BUILDER .setForceMuteFeedback(forceMuteFeedback) @@ -433,7 +433,7 @@ public boolean accept(AccessibilityNodeInfoCompat node) { boolean firstTime = screenState.isInterpretFirstTimeWhenWakeUp(); boolean forceMuteFeedback = - FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, nodeToFocus, screenState); + FocusActorHelper.shouldMuteFeedbackForFocusedNode(nodeToFocus, screenState); FocusActionInfo focusActionInfo = FOCUS_ACTION_INFO_REQUEST_INITIAL_NODE_BUILDER diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusManagerInternal.java b/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusManagerInternal.java index 8890757ff..5638de8f6 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusManagerInternal.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/actor/FocusManagerInternal.java @@ -332,8 +332,9 @@ private boolean requestA11yFocusFromLastFocusRecord(EventId eventId) { boolean firstTime = stableScreenState != null && stableScreenState.isInterpretFirstTimeWhenWakeUp(); + // Cell broadcast is an emergent announcement, so we don't mute it. boolean forceMuteFeedback = - FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, nodeToFocus, stableScreenState); + FocusActorHelper.shouldMuteFeedbackForFocusedNode(nodeToFocus, stableScreenState); FocusActionInfo focusActionInfo = FocusActionInfo.builder() diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/actor/helper/FocusActorHelper.java b/talkback/src/main/java/com/google/android/accessibility/talkback/actor/helper/FocusActorHelper.java index 29d0598a7..bfde791c2 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/actor/helper/FocusActorHelper.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/actor/helper/FocusActorHelper.java @@ -29,7 +29,6 @@ import com.google.android.accessibility.talkback.focusmanagement.interpreter.ScreenState; import com.google.android.accessibility.talkback.focusmanagement.record.FocusActionInfo; import com.google.android.accessibility.talkback.keyboard.KeyComboManagerHelper; -import com.google.android.accessibility.talkback.monitor.ScreenOnSpeechSuppression; import com.google.android.accessibility.utils.AccessibilityWindowInfoUtils; import com.google.android.accessibility.utils.BuildVersionUtils; import com.google.android.accessibility.utils.FormFactorUtils; @@ -46,18 +45,11 @@ public final class FocusActorHelper { * Checks whether the feedback for the focused node should be muted. * * @param nodeToFocus the node to check - * @param context the context used to read screen announcement preferences * @param screenState the screen state for current active window * @return {@code true} if the focused node should be muted. */ public static boolean shouldMuteFeedbackForFocusedNode( - Context context, AccessibilityNodeInfoCompat nodeToFocus, @Nullable ScreenState screenState) { - if (ScreenOnSpeechSuppression.isSuppressing(context)) { - return true; - } - if (screenState == null) { - return false; - } + AccessibilityNodeInfoCompat nodeToFocus, ScreenState screenState) { // We mute the feedback of focused node only when it wakes up on the Home Screen and not on CB. return FormFactorUtils.isAndroidWear() && screenState.isInterpretFirstTimeWhenWakeUp() diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenFeedbackManager.java b/talkback/src/main/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenFeedbackManager.java index 4c8f8309f..1ce00c823 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenFeedbackManager.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenFeedbackManager.java @@ -333,8 +333,9 @@ public Feedback composeFeedback( Feedback feedback = new Feedback(); - // Keep window interpretation and focus restoration intact; only omit automatic wake speech. - if (ScreenOnSpeechSuppression.isSuppressing(service)) { + // Only verified lock-screen System UI feedback is quiet; mixed/unknown windows still speak. + ScreenOnSpeechSuppression wakeSuppression = ScreenOnSpeechSuppression.from(service); + if (wakeSuppression != null && wakeSuppression.shouldSuppressWindow(interpretation)) { return feedback; } diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt index ef827b4c6..2931e46ec 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt @@ -16,34 +16,72 @@ package com.google.android.accessibility.talkback.monitor +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.SharedPreferences +import android.os.SystemClock import android.view.KeyEvent import android.view.accessibility.AccessibilityEvent +import androidx.core.content.ContextCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.accessibility.talkback.PackageNameProvider import com.google.android.accessibility.talkback.TalkBackService import com.google.android.accessibility.talkback.UserInterface.UserInputEventListener import com.google.android.accessibility.talkback.focusmanagement.record.FocusActionInfo +import com.google.android.accessibility.utils.AccessibilityWindowInfoUtils.WINDOW_ID_NONE import com.google.android.accessibility.utils.Performance.EventId +import com.google.android.accessibility.utils.input.WindowEventInterpreter import com.google.android.accessibility.utils.monitor.DisplayMonitor.DisplayStateChangedListener +import com.google.android.accessibility.utils.monitor.ScreenMonitor /** * A wake can produce several window changes, announcements, and focus events. Keep automatic - * feedback quiet throughout that sequence, until the user starts interacting. This does not mute - * the speech controller: selected status, unlock speech, and notifications use their normal paths. + * lock-screen System UI feedback quiet for at most three seconds. Unlocking or interacting ends + * suppression immediately. Other apps and unknown window owners always keep their normal speech. */ class ScreenOnSpeechSuppression( private val prefs: SharedPreferences, initiallyScreenOn: Boolean, private val isWear: Boolean = false, + private val isLocked: () -> Boolean = { false }, + private val isCallActive: () -> Boolean = { false }, + private val activePackage: () -> CharSequence? = { null }, + private val windowPackage: (Int) -> CharSequence? = { null }, + private val emergencyPackage: () -> String? = { null }, + private val now: () -> Long = { SystemClock.uptimeMillis() }, ) : DisplayStateChangedListener, RingerModeAndScreenMonitor.ScreenChangedListener, UserInputEventListener { private var screenOn = initiallyScreenOn - private var waitingForInteraction = false + private var suppressUntil: Long? = null + private var receiverContext: Context? = null + private val unlockReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == Intent.ACTION_USER_PRESENT) onDeviceUnlocked() + } + } + + fun registerUnlockReceiver(context: Context) { + if (receiverContext != null) return + ContextCompat.registerReceiver(context, unlockReceiver, IntentFilter(Intent.ACTION_USER_PRESENT), + ContextCompat.RECEIVER_NOT_EXPORTED) + receiverContext = context + } + + fun shutdown() { + receiverContext?.unregisterReceiver(unlockReceiver) + receiverContext = null + onUserInteraction() + } override fun onDisplayStateChanged(displayOn: Boolean) { if (displayOn != screenOn) { screenOn = displayOn - waitingForInteraction = displayOn + suppressUntil = if (displayOn && !isWear && isLocked() && !isCallActive() && + ScreenAnnouncementSettings.isOn(prefs, ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH)) { + now() + WAKE_SUPPRESSION_MS + } else null } } @@ -51,12 +89,23 @@ class ScreenOnSpeechSuppression( onDisplayStateChanged(isInteractive) } - fun isSuppressing(): Boolean = - !isWear && waitingForInteraction && - ScreenAnnouncementSettings.isOn(prefs, ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH) + fun isSuppressing(): Boolean { + val deadline = suppressUntil ?: return false + if (isWear || !isLocked() || isCallActive() || now() >= deadline || + !ScreenAnnouncementSettings.isOn(prefs, ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH)) { + suppressUntil = null + return false + } + return isEligiblePackage(activePackage()) + } + + private fun isEligiblePackage(packageName: CharSequence?): Boolean = + packageName?.toString() == SYSTEM_UI && packageName.toString() != emergencyPackage() + + fun onDeviceUnlocked() = onUserInteraction() fun onUserInteraction() { - waitingForInteraction = false + suppressUntil = null } override fun touchInteractionState(active: Boolean) { @@ -88,7 +137,7 @@ class ScreenOnSpeechSuppression( /** Focus can arrive without Backtalk's action history; System UI also posts later live updates. */ fun shouldSuppressEvent(event: AccessibilityEvent): Boolean { - if (!isSuppressing()) return false + if (!isSuppressing() || !isEligiblePackage(event.packageName)) return false return when (event.eventType) { AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, AccessibilityEvent.TYPE_VIEW_FOCUSED -> true @@ -97,18 +146,49 @@ class ScreenOnSpeechSuppression( AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED, AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, - AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> event.packageName?.toString() == "com.android.systemui" + AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> true else -> false } } + /** Only automatic focus in the verified lock-screen package is quiet, never user navigation. */ + fun shouldSuppressFocus(node: AccessibilityNodeInfoCompat, info: FocusActionInfo): Boolean = + isSuppressing() && isEligiblePackage(node.packageName) && info.sourceAction in listOf( + FocusActionInfo.SCREEN_STATE_CHANGE, FocusActionInfo.FOCUS_SYNCHRONIZATION, + FocusActionInfo.ENSURE_ON_SCREEN, + ) + + /** Mixed or unidentified window changes must remain audible, including IME and other apps. */ + fun shouldSuppressWindow(interpretation: WindowEventInterpreter.EventInterpretation): Boolean { + if (!isSuppressing() || interpretation.shouldAnnounceInputMethodChange || + interpretation.isWebDialogOpenedEvent) return false + val announcement = interpretation.announcement + if (announcement != null && !isEligiblePackage(announcement.packageName())) return false + val windows = listOf(interpretation.windowA, interpretation.windowB, + interpretation.otherActiveWindow, interpretation.picInPic, interpretation.eventSourceWindow) + .filter { it.id != WINDOW_ID_NONE } + if (windows.any { !isEligiblePackage(windowPackage(it.id)) }) return false + if (interpretation.mainWindowsChanged && windows.isEmpty()) return false + return announcement != null || windows.isNotEmpty() + } companion object { + const val WAKE_SUPPRESSION_MS = 3_000L + private const val SYSTEM_UI = "com.android.systemui" + + @JvmStatic + fun create(service: TalkBackService, prefs: SharedPreferences, initiallyScreenOn: Boolean, + isWear: Boolean): ScreenOnSpeechSuppression = ScreenOnSpeechSuppression( + prefs, initiallyScreenOn, isWear, + isLocked = { ScreenMonitor.isDeviceLocked(service) }, + isCallActive = { service.isPhoneCallActive }, + activePackage = { service.rootInActiveWindow?.packageName }, + windowPackage = { id -> service.windows.firstOrNull { it.id == id }?.root?.packageName }, + emergencyPackage = { PackageNameProvider.getCellBroadcastReceiverPackageName() }, + ) /** All speech paths share the service's wake state, rather than independent one-shot flags. */ @JvmStatic fun from(context: Context): ScreenOnSpeechSuppression? = (context as? TalkBackService)?.screenOnSpeechSuppression - @JvmStatic - fun isSuppressing(context: Context): Boolean = from(context)?.isSuppressing() == true } } diff --git a/talkback/src/main/res/values-cs/strings_backtalk.xml b/talkback/src/main/res/values-cs/strings_backtalk.xml index e5cf0fcfa..3abe85af4 100644 --- a/talkback/src/main/res/values-cs/strings_backtalk.xml +++ b/talkback/src/main/res/values-cs/strings_backtalk.xml @@ -36,6 +36,10 @@ Říkat „Obrazovka vypnuta“ Říkat „Zařízení odemknuto“ Čas + + Říkat pouze výše vybrané položky + + Při zapnutí zamčené obrazovky oznamovat pouze vybrané položky. Režim vyzvánění Oznamovat, když je vyzvánění nastaveno na vibrace nebo tichý režim @@ -119,4 +123,4 @@ %1$s, %2$s Oznamovat tlačítka navigační lišty Když je aktivace zvednutím zapnutá na navigační liště, oznámit tlačítko, které klepnutí stiskne, například Zpět nebo Plocha. - \ No newline at end of file + diff --git a/talkback/src/main/res/values/strings.xml b/talkback/src/main/res/values/strings.xml index b18294bd5..d76c2b1bd 100644 --- a/talkback/src/main/res/values/strings.xml +++ b/talkback/src/main/res/values/strings.xml @@ -327,10 +327,10 @@ Say \"Device unlocked\" Time - - Suppress extra speech - - Only announce the selected items when the screen turns on. + + Say only the items above + + When the lock screen turns on, announce only the selected items. Ringer mode diff --git a/talkback/src/main/res/xml/screen_announcement_preferences.xml b/talkback/src/main/res/xml/screen_announcement_preferences.xml index 9fff7ff7a..016e8ff87 100644 --- a/talkback/src/main/res/xml/screen_announcement_preferences.xml +++ b/talkback/src/main/res/xml/screen_announcement_preferences.xml @@ -20,11 +20,6 @@ - + () + + private fun policy(initiallyOn: Boolean = false) = ScreenOnSpeechSuppression( + SharedPreferencesUtils.getSharedPreferences(service), initiallyOn, + isLocked = { locked }, isCallActive = { callActive }, activePackage = { foreground }, + windowPackage = { windowPackages[it] }, emergencyPackage = { emergency }, now = { elapsed }, + ) @Before fun setUp() { @@ -70,7 +80,7 @@ class ScreenOnSuppressionTest { FormFactorUtils.initialize(service) SharedPreferencesUtils.getSharedPreferences(service).edit().clear().commit() service.suppression = - ScreenOnSpeechSuppression(SharedPreferencesUtils.getSharedPreferences(service), false) + policy() composer = ScreenFeedbackManager.FeedbackComposer(service, null, null) } @@ -107,30 +117,28 @@ class ScreenOnSuppressionTest { @Test fun automaticFocusRemainsMutedAfterTheFirstWakeFlagIsConsumed() { - val node = AccessibilityNodeInfoCompat.obtain() - val state = ScreenState(null, null, 0, true) - assertFalse(FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, node, state)) + val node = AccessibilityNodeInfoCompat.obtain().apply { packageName = "com.android.systemui" } + val restored = FocusActionInfo.builder().setSourceAction(FocusActionInfo.SCREEN_STATE_CHANGE).build() + assertFalse(service.suppression.shouldSuppressFocus(node, restored)) suppress(true) service.suppression.onDisplayStateChanged(true) - assertTrue(FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, node, state)) - // A failed restoration must allow the next automatic focus strategy to use the same policy. - assertTrue(state.isInterpretFirstTimeWhenWakeUp) - assertTrue(FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, node, state)) - state.consumeInterpretFirstTimeWhenWakeUp() - assertTrue(FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, node, state)) - assertTrue(FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, node, null)) + for (source in listOf(FocusActionInfo.SCREEN_STATE_CHANGE, FocusActionInfo.FOCUS_SYNCHRONIZATION, + FocusActionInfo.ENSURE_ON_SCREEN)) { + val info = FocusActionInfo.builder().setSourceAction(source).build() + assertTrue(service.suppression.shouldSuppressFocus(node, info)) + node.packageName = "example.cellbroadcast" + assertFalse(service.suppression.shouldSuppressFocus(node, info)) + node.packageName = "com.android.systemui" + } // User navigation clears suppression before its feedback is composed, including braille. service.suppression.onFocusAction( FocusActionInfo.builder().setSourceAction(FocusActionInfo.LOGICAL_NAVIGATION).build() ) - assertFalse(FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, node, state)) - assertFalse(FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, node, null)) + assertFalse(service.suppression.shouldSuppressFocus(node, restored)) // A later wake is independently suppressed. service.suppression.onDisplayStateChanged(false) service.suppression.onDisplayStateChanged(true) - assertTrue( - FocusActorHelper.shouldMuteFeedbackForFocusedNode(service, node, ScreenState(null, null, 1, true)) - ) + assertTrue(service.suppression.shouldSuppressFocus(node, restored)) } private fun rawEvent(type: Int, packageName: String = "com.android.systemui") = @@ -152,7 +160,7 @@ class ScreenOnSuppressionTest { } @Test - fun wakeSuppressionDoesNotDependOnCarrierTextOrWindowAnnouncementPackage() { + fun carrierTextDoesNotAffectSuppressionAndVendorAppsKeepTheirAnnouncements() { suppress(true) service.suppression.onDisplayStateChanged(true) for (label in listOf("Example Mobile", "Emergency calls only", "オペレーター", "")) { @@ -165,11 +173,11 @@ class ScreenOnSuppressionTest { val event = rawEvent(type).apply { text.add(label) } assertTrue(service.suppression.shouldSuppressEvent(event)) } - // Window feedback is suppressed even when a manufacturer uses another package. + // An unverified vendor lock-screen app must keep its speech. val window = WindowEventInterpreter.EventInterpretation().apply { setAnnouncement(WindowEventInterpreter.Announcement.create(label, "example.vendor.lockscreen", false, false)) } - assertTrue(composer.composeFeedback(window, 0).isEmpty) + assertFalse(service.suppression.shouldSuppressWindow(window)) } service.suppression.onUserInteraction() val selection = rawEvent(AccessibilityEvent.TYPE_VIEW_SELECTED).apply { text.add("Example Mobile") } @@ -249,6 +257,111 @@ class ScreenOnSuppressionTest { assertFalse(suppression.isSuppressing()) } + @Test + fun timeoutIsBoundedAndDuplicateWakeDoesNotExtendIt() { + suppress(true) + service.suppression.onDisplayStateChanged(true) + elapsed = 2_999L + assertTrue(service.suppression.isSuppressing()) + service.suppression.onDisplayStateChanged(true) + elapsed = 3_000L + assertFalse(service.suppression.isSuppressing()) + assertFalse(composer.composeFeedback(announcement(false), 0).isEmpty) + service.suppression.onDisplayStateChanged(false) + service.suppression.onDisplayStateChanged(true) + assertTrue(service.suppression.isSuppressing()) + } + + @Test + fun unlockWithoutTouchEndsSuppressionAndRelockingDoesNotRearmIt() { + suppress(true) + service.suppression.onDisplayStateChanged(true) + locked = false + assertFalse(service.suppression.isSuppressing()) + locked = true + assertFalse(service.suppression.isSuppressing()) + service.suppression.onDisplayStateChanged(false) + service.suppression.onDisplayStateChanged(true) + service.suppression.registerUnlockReceiver(service) + service.sendBroadcast(android.content.Intent(android.content.Intent.ACTION_USER_PRESENT)) + shadowOf(android.os.Looper.getMainLooper()).idle() + assertFalse(service.suppression.isSuppressing()) + service.suppression.shutdown() + service.suppression.shutdown() + service.suppression.onDisplayStateChanged(false) + locked = false + service.suppression.onDisplayStateChanged(true) + assertFalse(service.suppression.isSuppressing()) + } + + @Test + fun otherAppsAndUnknownOwnersKeepFocusAndWindowSpeech() { + suppress(true) + service.suppression.onDisplayStateChanged(true) + val info = FocusActionInfo.builder().setSourceAction(FocusActionInfo.SCREEN_STATE_CHANGE).build() + for (owner in listOf("example.incall", "example.clock", "example.timer", "example.cellbroadcast", null)) { + val node = AccessibilityNodeInfoCompat.obtain().apply { packageName = owner } + assertFalse(service.suppression.shouldSuppressFocus(node, info)) + val event = rawEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED).apply { packageName = owner } + assertFalse(service.suppression.shouldSuppressEvent(event)) + val window = WindowEventInterpreter.EventInterpretation().apply { + setAnnouncement(WindowEventInterpreter.Announcement.create("Alert", owner, false, false)) + } + assertFalse(service.suppression.shouldSuppressWindow(window)) + assertFalse(composer.composeFeedback(window, 0).isEmpty) + val appWindow = WindowEventInterpreter.EventInterpretation().apply { + setMainWindowsChanged(true) + windowA.setId(10) + windowA.setTitleForFeedback("Alarm or emergency alert") + } + if (owner == null) windowPackages.remove(10) else windowPackages[10] = owner + assertFalse(composer.composeFeedback(appWindow, 0).isEmpty) + foreground = owner + assertFalse(service.suppression.shouldSuppressEvent(rawEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT))) + foreground = "com.android.systemui" + } + // Even a receiver configured to use System UI must never have its emergency speech muted. + emergency = "com.android.systemui" + assertFalse(service.suppression.shouldSuppressEvent(rawEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT))) + } + + @Test + fun incomingCallEndsSuppressionEvenIfSystemUiStaysInFront() { + suppress(true) + service.suppression.onDisplayStateChanged(true) + callActive = true + assertFalse(service.suppression.isSuppressing()) + callActive = false + assertFalse(service.suppression.isSuppressing()) + service.suppression.onDisplayStateChanged(false) + callActive = true + service.suppression.onDisplayStateChanged(true) + assertFalse(service.suppression.isSuppressing()) + } + + @Test + fun mixedWindowsAndKeyboardChangesAreNotSuppressed() { + suppress(true) + service.suppression.onDisplayStateChanged(true) + val window = WindowEventInterpreter.EventInterpretation().apply { + setMainWindowsChanged(true) + windowA.setId(1) + } + assertFalse(service.suppression.shouldSuppressWindow(window)) + windowPackages[1] = "com.android.systemui" + assertTrue(service.suppression.shouldSuppressWindow(window)) + window.windowB.setId(2) + windowPackages[2] = "example.clock" + assertFalse(service.suppression.shouldSuppressWindow(window)) + windowPackages[2] = "com.android.systemui" + window.picInPic.setId(3) + windowPackages[3] = "example.timer" + assertFalse(service.suppression.shouldSuppressWindow(window)) + window.picInPic.setId(-1) + window.setShouldAnnounceInputMethodChange(true) + assertFalse(service.suppression.shouldSuppressWindow(window)) + } + @Test fun suppressionPreferenceIsVisibleOnPhonesAndHiddenOnWear() { for (wear in listOf(false, true)) { @@ -269,7 +382,7 @@ class ScreenOnSuppressionTest { @Test fun startingTheServiceWithTheScreenOnDoesNotSilenceNavigation() { suppress(true) - val suppression = ScreenOnSpeechSuppression(SharedPreferencesUtils.getSharedPreferences(service), true) + val suppression = policy(initiallyOn = true) suppression.onDisplayStateChanged(true) assertFalse(suppression.isSuppressing()) suppression.onDisplayStateChanged(false) diff --git a/talkback/src/test/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncementsTest.kt b/talkback/src/test/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncementsTest.kt index 5c9d98078..f1645aa75 100644 --- a/talkback/src/test/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncementsTest.kt +++ b/talkback/src/test/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncementsTest.kt @@ -97,7 +97,8 @@ class ScreenAnnouncementsTest { @Test fun suppressionIsPersistedAndOnlyAppliesBeforeInteractionAfterWake() { val prefs = FakeSharedPreferences() - val suppression = ScreenOnSpeechSuppression(prefs, false) + val suppression = ScreenOnSpeechSuppression(prefs, false, isLocked = { true }, + activePackage = { "com.android.systemui" }, now = { 0L }) prefs.edit().putBoolean(ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH, true).apply() assertFalse(suppression.isSuppressing()) suppression.onDisplayStateChanged(true) @@ -105,6 +106,9 @@ class ScreenAnnouncementsTest { prefs.edit().putBoolean(ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH, false).apply() assertFalse(suppression.isSuppressing()) prefs.edit().putBoolean(ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH, true).apply() + suppression.onDisplayStateChanged(false) + suppression.onDisplayStateChanged(true) + assertTrue(suppression.isSuppressing()) suppression.onUserInteraction() assertFalse(suppression.isSuppressing()) } @@ -113,7 +117,8 @@ class ScreenAnnouncementsTest { fun suppressionIsInactiveOnWearEvenWithAnImportedEnabledPreference() { val prefs = FakeSharedPreferences() prefs.edit().putBoolean(ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH, true).apply() - val suppression = ScreenOnSpeechSuppression(prefs, false, isWear = true) + val suppression = ScreenOnSpeechSuppression(prefs, false, isWear = true, isLocked = { true }, + activePackage = { "com.android.systemui" }, now = { 0L }) suppression.onDisplayStateChanged(true) assertFalse(suppression.isSuppressing()) } From f57b71c1764a800a3e76de50be2e478166380918 Mon Sep 17 00:00:00 2001 From: willbilec Date: Sun, 11 Oct 2026 15:59:40 -0600 Subject: [PATCH 3/4] Check ownership of the windows actually announced on wake --- .../monitor/ScreenOnSpeechSuppression.kt | 24 ++++++++++++++--- .../feedbackpolicy/ScreenOnSuppressionTest.kt | 27 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt index 2931e46ec..0a4fd2b8b 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt @@ -164,9 +164,27 @@ class ScreenOnSpeechSuppression( interpretation.isWebDialogOpenedEvent) return false val announcement = interpretation.announcement if (announcement != null && !isEligiblePackage(announcement.packageName())) return false - val windows = listOf(interpretation.windowA, interpretation.windowB, - interpretation.otherActiveWindow, interpretation.picInPic, interpretation.eventSourceWindow) - .filter { it.id != WINDOW_ID_NONE } + // Match the composer's spoken roles. A disappearing event-source window is often absent + // during wake; it must not veto suppression of a separately verified System UI title. + val windows = mutableListOf() + if (interpretation.mainWindowsChanged) { + if (interpretation.otherActiveWindow.id != WINDOW_ID_NONE) { + windows.add(interpretation.otherActiveWindow) + } else if (interpretation.windowA.id != WINDOW_ID_NONE) { + windows.add(interpretation.windowA) + if (interpretation.windowB.id != WINDOW_ID_NONE) windows.add(interpretation.windowB) + } + } + if ((interpretation.mainWindowsChanged || interpretation.picInPicChanged) && + interpretation.picInPic.id != WINDOW_ID_NONE && + interpretation.otherActiveWindow.id == WINDOW_ID_NONE) { + windows.add(interpretation.picInPic) + } + if (windows.isEmpty() && interpretation.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && + interpretation.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_APPEARED) { + if (interpretation.eventSourceWindow.id == WINDOW_ID_NONE) return false + windows.add(interpretation.eventSourceWindow) + } if (windows.any { !isEligiblePackage(windowPackage(it.id)) }) return false if (interpretation.mainWindowsChanged && windows.isEmpty()) return false return announcement != null || windows.isNotEmpty() diff --git a/talkback/src/test/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenOnSuppressionTest.kt b/talkback/src/test/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenOnSuppressionTest.kt index 14150af2c..d2a5b776e 100644 --- a/talkback/src/test/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenOnSuppressionTest.kt +++ b/talkback/src/test/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenOnSuppressionTest.kt @@ -350,6 +350,14 @@ class ScreenOnSuppressionTest { assertFalse(service.suppression.shouldSuppressWindow(window)) windowPackages[1] = "com.android.systemui" assertTrue(service.suppression.shouldSuppressWindow(window)) + // Real wake trace: System UI is spoken, but an unrelated removed source has no root. + window.eventSourceWindow.setId(77) + assertTrue(service.suppression.shouldSuppressWindow(window)) + windowPackages[77] = "example.biometrics" + assertTrue(service.suppression.shouldSuppressWindow(window)) + window.otherActiveWindow.setId(77) + assertFalse(service.suppression.shouldSuppressWindow(window)) + window.otherActiveWindow.setId(-1) window.windowB.setId(2) windowPackages[2] = "example.clock" assertFalse(service.suppression.shouldSuppressWindow(window)) @@ -362,6 +370,25 @@ class ScreenOnSuppressionTest { assertFalse(service.suppression.shouldSuppressWindow(window)) } + @Test + fun anUnknownOrEmergencyPaneTitleStillSpeaks() { + suppress(true) + service.suppression.onDisplayStateChanged(true) + val pane = WindowEventInterpreter.EventInterpretation().apply { + setEventType(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) + setContentChangeTypes(AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_APPEARED) + eventSourceWindow.setId(88) + eventSourceWindow.setTitleForFeedback("Emergency alert") + } + assertFalse(service.suppression.shouldSuppressWindow(pane)) + assertFalse(composer.composeFeedback(pane, 0).isEmpty) + windowPackages[88] = "example.cellbroadcast" + assertFalse(service.suppression.shouldSuppressWindow(pane)) + assertFalse(composer.composeFeedback(pane, 0).isEmpty) + windowPackages[88] = "com.android.systemui" + assertTrue(service.suppression.shouldSuppressWindow(pane)) + } + @Test fun suppressionPreferenceIsVisibleOnPhonesAndHiddenOnWear() { for (wear in listOf(false, true)) { From cf40551e96eb8493fc181257c95e9f7524ca7899 Mon Sep 17 00:00:00 2001 From: willbilec Date: Sun, 11 Oct 2026 16:16:28 -0600 Subject: [PATCH 4/4] Resolve lock-screen owners from Android configuration --- differences.md | 2 +- .../monitor/ScreenOnSpeechSuppression.kt | 37 ++++++++++- .../feedbackpolicy/ScreenOnSuppressionTest.kt | 63 +++++++++++++++++++ .../monitor/ScreenAnnouncementsTest.kt | 2 + 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/differences.md b/differences.md index ee65b5275..2d9a24b9f 100644 --- a/differences.md +++ b/differences.md @@ -359,7 +359,7 @@ In **Verbosity** > **Screen on and off**, you can choose what Backtalk says when On phones, at the end of **When the screen turns on**, **Say only the items above** limits automatic System UI lock-screen wake speech to the time and status items you selected. It skips extra lock-screen information, such as the carrier and Extend Unlock, and the automatically restored lock-screen focused item. Focus still returns, and swiping or touching items speaks normally. With no screen-on items selected, the initial lock-screen wake announcement is silent. This option is off by default and is hidden and inactive on Wear OS. The separate **Say "Device unlocked"** switch still controls the unlock announcement. -Some phones send several lock-screen announcements and focus events as they wake. Suppression ends when the device unlocks, you start interacting, the screen turns off, or three seconds pass. Unlocked wakes, other apps, incoming calls, alarms, timers, emergency alerts, notifications, and unknown window owners keep their normal speech. The selected time and status announcements still speak normally. Actual device testing has been limited to a Samsung Galaxy S23 Plus; behavior on non-Samsung phones, including Pixels, has not been verified. +Some phones send several lock-screen announcements and focus events as they wake. Suppression ends when the device unlocks, you start interacting, the screen turns off, or three seconds pass. System UI and keyguard are identified using Android's configuration, rather than device-specific package names. Unlocked wakes, other apps, incoming calls, alarms, timers, emergency alerts, notifications, and unknown window owners keep their normal speech. The selected time and status announcements still speak normally. Actual device testing has been limited to a Samsung Galaxy S23 Plus; behavior on non-Samsung phones, including Pixels, has not been verified. These switches are separate from **Status readout**, so the status gesture can say different things. Backtalk doesn't make the screen-on announcement during calls, and the lock screen no longer cuts off "Screen off". diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt index 0a4fd2b8b..c6363c1d0 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt @@ -18,9 +18,11 @@ package com.google.android.accessibility.talkback.monitor import android.content.BroadcastReceiver import android.content.Context +import android.content.ComponentName import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences +import android.content.res.Resources import android.os.SystemClock import android.view.KeyEvent import android.view.accessibility.AccessibilityEvent @@ -45,6 +47,7 @@ class ScreenOnSpeechSuppression( private val prefs: SharedPreferences, initiallyScreenOn: Boolean, private val isWear: Boolean = false, + private val systemUiPackages: Set = emptySet(), private val isLocked: () -> Boolean = { false }, private val isCallActive: () -> Boolean = { false }, private val activePackage: () -> CharSequence? = { null }, @@ -100,7 +103,7 @@ class ScreenOnSpeechSuppression( } private fun isEligiblePackage(packageName: CharSequence?): Boolean = - packageName?.toString() == SYSTEM_UI && packageName.toString() != emergencyPackage() + packageName?.toString() in systemUiPackages && packageName.toString() != emergencyPackage() fun onDeviceUnlocked() = onUserInteraction() @@ -137,7 +140,21 @@ class ScreenOnSpeechSuppression( /** Focus can arrive without Backtalk's action history; System UI also posts later live updates. */ fun shouldSuppressEvent(event: AccessibilityEvent): Boolean { - if (!isSuppressing() || !isEligiblePackage(event.packageName)) return false + if (!isSuppressing()) return false + val emergency = emergencyPackage() + if (emergency != null && event.packageName?.toString() == emergency) return false + if (!isEligiblePackage(event.packageName)) { + // OEM clock renderers may send focus/content events under their own package, while the + // actual node and its window belong to System UI. Verify both instead of naming an OEM. + if (event.eventType !in listOf(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, + AccessibilityEvent.TYPE_VIEW_FOCUSED, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED)) { + return false + } + val node = event.source ?: return false + if (!isEligiblePackage(node.packageName) || !isEligiblePackage(node.window?.root?.packageName)) { + return false + } + } return when (event.eventType) { AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, AccessibilityEvent.TYPE_VIEW_FOCUSED -> true @@ -191,12 +208,26 @@ class ScreenOnSpeechSuppression( } companion object { const val WAKE_SUPPRESSION_MS = 3_000L - private const val SYSTEM_UI = "com.android.systemui" + /** Android's OEM-overridable configuration identifies the UI/keyguard, not package guesses. */ + internal fun systemUiPackagesFromConfig(read: (String) -> String?): Set = buildSet { + read("config_systemUi")?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) } + for (name in listOf("config_systemUIServiceComponent", "config_keyguardComponent")) { + read(name)?.trim()?.let { ComponentName.unflattenFromString(it)?.packageName }?.let { add(it) } + } + } @JvmStatic fun create(service: TalkBackService, prefs: SharedPreferences, initiallyScreenOn: Boolean, isWear: Boolean): ScreenOnSpeechSuppression = ScreenOnSpeechSuppression( prefs, initiallyScreenOn, isWear, + systemUiPackages = systemUiPackagesFromConfig { name -> + val id = service.resources.getIdentifier(name, "string", "android") + if (id == 0) null else try { + service.resources.getString(id) + } catch (_: Resources.NotFoundException) { + null + } + }, isLocked = { ScreenMonitor.isDeviceLocked(service) }, isCallActive = { service.isPhoneCallActive }, activePackage = { service.rootInActiveWindow?.packageName }, diff --git a/talkback/src/test/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenOnSuppressionTest.kt b/talkback/src/test/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenOnSuppressionTest.kt index d2a5b776e..6111c4c54 100644 --- a/talkback/src/test/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenOnSuppressionTest.kt +++ b/talkback/src/test/java/com/google/android/accessibility/talkback/feedbackpolicy/ScreenOnSuppressionTest.kt @@ -20,6 +20,7 @@ import android.os.Bundle import android.view.KeyEvent import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import com.google.android.accessibility.talkback.TalkBackService import com.google.android.accessibility.talkback.compositor.Compositor @@ -66,10 +67,12 @@ class ScreenOnSuppressionTest { private var foreground: String? = "com.android.systemui" private var elapsed = 0L private var emergency: String? = "example.cellbroadcast" + private var uiPackages = setOf("com.android.systemui") private val windowPackages = mutableMapOf() private fun policy(initiallyOn: Boolean = false) = ScreenOnSpeechSuppression( SharedPreferencesUtils.getSharedPreferences(service), initiallyOn, + systemUiPackages = uiPackages, isLocked = { locked }, isCallActive = { callActive }, activePackage = { foreground }, windowPackage = { windowPackages[it] }, emergencyPackage = { emergency }, now = { elapsed }, ) @@ -184,6 +187,66 @@ class ScreenOnSuppressionTest { assertFalse(service.suppression.shouldSuppressEvent(selection)) } + @Test + fun oemUiAndKeyguardPackagesComeFromConfigurationAndMissingConfigAllowsSpeech() { + val config = mapOf("config_systemUi" to "vendor.ui", + "config_systemUIServiceComponent" to "vendor.ui/.SystemService", + "config_keyguardComponent" to "vendor.lock/.KeyguardService") + uiPackages = ScreenOnSpeechSuppression.systemUiPackagesFromConfig { config[it] } + assertEquals(setOf("vendor.ui", "vendor.lock"), uiPackages) + service.suppression = policy() + foreground = "vendor.lock" + suppress(true) + service.suppression.onDisplayStateChanged(true) + assertTrue(service.suppression.shouldSuppressEvent(rawEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, "vendor.lock"))) + assertFalse(service.suppression.shouldSuppressEvent(rawEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, "com.android.systemui"))) + uiPackages = ScreenOnSpeechSuppression.systemUiPackagesFromConfig { if (it == "config_keyguardComponent") "invalid" else null } + assertTrue(uiPackages.isEmpty()) + service.suppression = policy() + service.suppression.onDisplayStateChanged(true) + assertFalse(service.suppression.isSuppressing()) + } + + @Test + fun aRendererPackageCannotMakeSystemUiClockFocusSpeakTwice() { + val root = AccessibilityNodeInfo.obtain().apply { packageName = "com.android.systemui" } + val clock = AccessibilityNodeInfo.obtain().apply { + packageName = "com.android.systemui" + className = "android.widget.TextView" + text = "Clock sample" + isVisibleToUser = true + isAccessibilityFocused = true + } + val window = AccessibilityWindowInfo.obtain() + shadowOf(window).setRoot(root) + shadowOf(window).setType(AccessibilityWindowInfo.TYPE_SYSTEM) + shadowOf(clock).setAccessibilityWindowInfo(window) + // The sender is a separate renderer; neither its name nor the clock's text drives policy. + val event = rawEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, "example.clock.renderer") + shadowOf(event).setSourceNode(clock) + suppress(true) + service.suppression.onDisplayStateChanged(true) + assertTrue(service.suppression.shouldSuppressEvent(event)) + event.eventType = AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED + assertTrue(service.suppression.shouldSuppressEvent(event)) + event.eventType = AccessibilityEvent.TYPE_ANNOUNCEMENT + assertFalse(service.suppression.shouldSuppressEvent(event)) + event.eventType = AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED + assertFalse(service.suppression.shouldSuppressEvent(event)) + event.eventType = AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED + event.packageName = "example.cellbroadcast" + assertFalse(service.suppression.shouldSuppressEvent(event)) + event.packageName = "example.timer" + clock.packageName = "example.timer" + root.packageName = "example.timer" + assertFalse(service.suppression.shouldSuppressEvent(event)) + clock.packageName = "com.android.systemui" + assertFalse(service.suppression.shouldSuppressEvent(event)) + root.packageName = "com.android.systemui" + service.suppression.onUserInteraction() + assertFalse(service.suppression.shouldSuppressEvent(event)) + } + @Test fun delayedCarrierAndUnlockAnnouncementsDoNotReachTheSpeechOutput() { val globals = GlobalVariables(service, InputModeTracker(), CollectionState(), null) diff --git a/talkback/src/test/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncementsTest.kt b/talkback/src/test/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncementsTest.kt index f1645aa75..e796cfe66 100644 --- a/talkback/src/test/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncementsTest.kt +++ b/talkback/src/test/java/com/google/android/accessibility/talkback/monitor/ScreenAnnouncementsTest.kt @@ -98,6 +98,7 @@ class ScreenAnnouncementsTest { fun suppressionIsPersistedAndOnlyAppliesBeforeInteractionAfterWake() { val prefs = FakeSharedPreferences() val suppression = ScreenOnSpeechSuppression(prefs, false, isLocked = { true }, + systemUiPackages = setOf("com.android.systemui"), activePackage = { "com.android.systemui" }, now = { 0L }) prefs.edit().putBoolean(ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH, true).apply() assertFalse(suppression.isSuppressing()) @@ -118,6 +119,7 @@ class ScreenAnnouncementsTest { val prefs = FakeSharedPreferences() prefs.edit().putBoolean(ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH, true).apply() val suppression = ScreenOnSpeechSuppression(prefs, false, isWear = true, isLocked = { true }, + systemUiPackages = setOf("com.android.systemui"), activePackage = { "com.android.systemui" }, now = { 0L }) suppression.onDisplayStateChanged(true) assertFalse(suppression.isSuppressing())