diff --git a/differences.md b/differences.md index 92de7d536..d664095b8 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, 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. 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. Device testing has covered a Samsung Galaxy S23 Plus and a Galaxy S5 running Lineage OS 20; 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". ## Pausing Backtalk diff --git a/lint-baseline.xml b/lint-baseline.xml index 999ca12d0..59bff826d 100644 --- a/lint-baseline.xml +++ b/lint-baseline.xml @@ -25519,5 +25519,27 @@ + + + + + + + + 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 12aa46c7d..f1cbb2218 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 @@ -177,6 +177,7 @@ import com.google.android.accessibility.talkback.focusmanagement.interpreter.ScreenStateMonitor; import com.google.android.accessibility.talkback.focusmanagement.interpreter.TouchExplorationInterpreter; import com.google.android.accessibility.talkback.focusmanagement.record.AccessibilityFocusActionHistory; +import com.google.android.accessibility.talkback.focusmanagement.record.FocusActionInfo; import com.google.android.accessibility.talkback.gesture.GestureController; import com.google.android.accessibility.talkback.gesture.GestureHistory; import com.google.android.accessibility.talkback.gesture.GestureShortcutMapping; @@ -217,6 +218,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; @@ -821,6 +823,8 @@ public interface GestureDetectionState { private EventLatencyLogger eventLatencyLogger; private UserInterface userInterface; + private @Nullable ScreenOnSpeechSuppression screenOnSpeechSuppression; + private DisableTalkBackDialog disableTalkbackDialog; private Configuration lastConfiguration; @@ -936,6 +940,9 @@ public void onDestroy() { if (userInterface != null) { userInterface.unregisterAllListeners(); } + if (screenOnSpeechSuppression != null) { + screenOnSpeechSuppression.shutdown(); + } if (eventLatencyLogger != null) { eventLatencyLogger.destroy(); @@ -1085,6 +1092,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() @@ -1314,6 +1324,9 @@ && switch (physicalKeyboardEcho) { */ @Override protected final boolean onKeyEvent(KeyEvent keyEvent) { + if (screenOnSpeechSuppression != null) { + screenOnSpeechSuppression.onKeyEvent(keyEvent); + } boolean result = onKeyEventInternal(keyEvent); if (primesController != null) { @@ -1498,6 +1511,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); } @@ -1507,6 +1523,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( @@ -1938,6 +1957,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); @@ -1976,6 +1998,17 @@ private void initializeInfrastructure() { // not unpaired // supportsTouchScreen = packageManager.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); displayMonitor = new DisplayMonitor(this); + screenOnSpeechSuppression = + ScreenOnSpeechSuppression.create( + this, + prefs, + getSystemService(android.hardware.display.DisplayManager.class) + .getDisplay(Display.DEFAULT_DISPLAY).getState() + == Display.STATE_ON, + FormFactorUtils.isAndroidWear()); + screenOnSpeechSuppression.registerUnlockReceiver(this); + // 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)); @@ -2099,6 +2132,7 @@ public long getSpeechCacheWindowCounts() { globalVariables); userInterface = new UserInterface(); + userInterface.registerListener(screenOnSpeechSuppression); userInterface.registerListener(focuser); inputDeviceMonitor = new InputDeviceMonitor(this); @@ -2399,6 +2433,7 @@ public long getSpeechCacheWindowCounts() { displayMonitor, statusReader, this); + ringerModeAndScreenMonitor.addScreenChangedListener(screenOnSpeechSuppression); if (speechCacheController != null) { ringerModeAndScreenMonitor.addScreenChangedListener(speechCacheController); } @@ -2462,14 +2497,20 @@ 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); + FocusActionInfo speechInfo = + screenOnSpeechSuppression.shouldSuppressFocus(node, info) + ? new FocusActionInfo.Builder(info).setForceMuteFeedback(true).build() + : info; + eventFilter.onAccessibilityFocusSet( + node, + speechInfo, + eventId, + actionTime, + /* continuousReading= */ fullScreenReadActor != null + && fullScreenReadActor.isActive()); + }); eventFilter.setTargetPredictor(directionNavigationActor::predictTarget); eventFilter.setFingerDownSupplier( () -> @@ -2683,6 +2724,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/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..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 @@ -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,12 @@ public Feedback composeFeedback( Feedback feedback = new Feedback(); + // 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; + } + 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..c6363c1d0 --- /dev/null +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/monitor/ScreenOnSpeechSuppression.kt @@ -0,0 +1,243 @@ +/* + * 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.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 +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 + * 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 systemUiPackages: Set = emptySet(), + 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 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 + suppressUntil = if (displayOn && !isWear && isLocked() && !isCallActive() && + ScreenAnnouncementSettings.isOn(prefs, ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH)) { + now() + WAKE_SUPPRESSION_MS + } else null + } + } + + override fun onScreenChanged(isInteractive: Boolean, eventId: EventId?) { + onDisplayStateChanged(isInteractive) + } + + 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() in systemUiPackages && packageName.toString() != emergencyPackage() + + fun onDeviceUnlocked() = onUserInteraction() + + fun onUserInteraction() { + suppressUntil = null + } + + 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 + 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 + 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 -> 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 + // 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() + } + companion object { + const val WAKE_SUPPRESSION_MS = 3_000L + /** 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 }, + 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 + + } +} 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-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/donottranslate.xml b/talkback/src/main/res/values/donottranslate.xml index 9ee93b430..daf031644 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 6770c666a..96672e5fa 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 + + 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 1ed995e7d..016e8ff87 100644 --- a/talkback/src/main/res/xml/screen_announcement_preferences.xml +++ b/talkback/src/main/res/xml/screen_announcement_preferences.xml @@ -49,6 +49,11 @@ android:key="pref_screen_on_airplane_mode" android:summary="@string/status_item_airplane_mode_summary" android:title="@string/status_item_airplane_mode" /> + () + + 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 }, + ) + + @Before + fun setUp() { + service = Robolectric.buildService(TestService::class.java).get() + FormFactorUtils.initialize(service) + SharedPreferencesUtils.getSharedPreferences(service).edit().clear().commit() + service.suppression = + policy() + composer = ScreenFeedbackManager.FeedbackComposer(service, null, null) + } + + private fun suppress(enabled: Boolean) { + SharedPreferencesUtils.getSharedPreferences(service).edit() + .putBoolean(ScreenAnnouncementSettings.SUPPRESS_EXTRA_SCREEN_ON_SPEECH, enabled).commit() + } + + private fun announcement(wake: Boolean): WindowEventInterpreter.EventInterpretation = + WindowEventInterpreter.EventInterpretation().apply { + setInterpretFirstTimeWhenWakeUp(wake) + setAnnouncement( + WindowEventInterpreter.Announcement.create( + "Carrier, Extend Unlock", "com.android.systemui", false, false + ) + ) + } + + @Test + fun laterWakeAnnouncementsAreAlsoSuppressedUntilInteraction() { + assertFalse(composer.composeFeedback(announcement(true), 0).isEmpty) + suppress(true) + service.suppression.onDisplayStateChanged(true) + assertTrue(composer.composeFeedback(announcement(true), 0).isEmpty) + // Samsung sends more window announcements after the first interpretation is consumed. + assertTrue(composer.composeFeedback(announcement(false), 0).isEmpty) + service.suppression.onUserInteraction() + assertFalse(composer.composeFeedback(announcement(false), 0).isEmpty) + service.suppression.onDisplayStateChanged(false) + service.suppression.onDisplayStateChanged(true) + suppress(false) + assertFalse(composer.composeFeedback(announcement(true), 0).isEmpty) + } + + @Test + fun automaticFocusRemainsMutedAfterTheFirstWakeFlagIsConsumed() { + 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) + 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(service.suppression.shouldSuppressFocus(node, restored)) + // A later wake is independently suppressed. + service.suppression.onDisplayStateChanged(false) + service.suppression.onDisplayStateChanged(true) + assertTrue(service.suppression.shouldSuppressFocus(node, restored)) + } + + private fun rawEvent(type: Int, packageName: String = "com.android.systemui") = + AccessibilityEvent.obtain(type).apply { this.packageName = packageName } + + @Test + fun rawLockScreenAnnouncementsAndUnrecordedFocusAreSuppressed() { + suppress(true) + service.suppression.onDisplayStateChanged(true) + val suppression = service.suppression + assertTrue(suppression.shouldSuppressEvent(rawEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT))) + assertTrue(suppression.shouldSuppressEvent(rawEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED))) + assertTrue(suppression.shouldSuppressEvent(rawEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED))) + assertFalse(suppression.shouldSuppressEvent(rawEvent(AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED))) + assertFalse(suppression.shouldSuppressEvent(rawEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT, "example.app"))) + suppression.onAccessibilityEvent(rawEvent(AccessibilityEvent.TYPE_TOUCH_INTERACTION_START)) + assertFalse(suppression.shouldSuppressEvent(rawEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED))) + assertFalse(composer.composeFeedback(announcement(false), 0).isEmpty) + } + + @Test + fun carrierTextDoesNotAffectSuppressionAndVendorAppsKeepTheirAnnouncements() { + suppress(true) + service.suppression.onDisplayStateChanged(true) + for (label in listOf("Example Mobile", "Emergency calls only", "オペレーター", "")) { + for (type in listOf( + AccessibilityEvent.TYPE_ANNOUNCEMENT, + AccessibilityEvent.TYPE_VIEW_SELECTED, + AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED, + AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED, + )) { + val event = rawEvent(type).apply { text.add(label) } + assertTrue(service.suppression.shouldSuppressEvent(event)) + } + // 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)) + } + assertFalse(service.suppression.shouldSuppressWindow(window)) + } + service.suppression.onUserInteraction() + val selection = rawEvent(AccessibilityEvent.TYPE_VIEW_SELECTED).apply { text.add("Example Mobile") } + 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) + val compositor = Compositor( + service, null, null, globals, ProcessorPhoneticLetters(service, globals), Compositor.FLAVOR_NONE + ) + val spoken = mutableListOf() + 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 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)) + // 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)) + 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 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)) { + 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 = policy(initiallyOn = 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..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 @@ -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,52 @@ 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, 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()) + 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.onDisplayStateChanged(false) + suppression.onDisplayStateChanged(true) + assertTrue(suppression.isSuppressing()) + 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, isLocked = { true }, + systemUiPackages = setOf("com.android.systemui"), + activePackage = { "com.android.systemui" }, now = { 0L }) + 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)) + } }