CVE-2026-84356
Overview
Background
- Exclusive Access Bubble
- the on-screen notice (“Press Esc to exit fullscreen”, etc.) that Chrome shows when a page enters fullscreen/pointer-lock so the user knows an untrusted page controls the whole screen.
- Snackbar / `SnackbarManager` / `SnackbarCollection`
- Android’s transient bottom-of-screen notification widget, its queue manager, and the data structure that holds queued snackbars; on Android the exclusive-access notice is rendered as a high-priority snackbar.
- `setHighPriority` / `TYPE_ACTION`
- a snackbar flag that shields security-critical notices from being discarded by other snackbars’ timeouts;
TYPE_ACTIONsnackbars are the short-lived, user-action snackbars that share a queue. - Snooze / `OnUserInput`
- the mechanism whereby user interaction resets (“snoozes”) the bubble’s hide timer instead of re-showing it, so the notice is not spammed on every keystroke.
Root Cause Analysis
On Android the exclusive-access notice’s lifetime was governed by a native timer whose expiry called ExclusiveAccessBubbleAndroid::Hide(), which unconditionally set was_shown_ = false and told the bridge to hide the snackbar even when the notice had been queued behind, or was not yet visible under, other UI — so the security notice could be dismissed before the user ever saw it. Compounding this, OnUserInput re-snoozed the bubble’s hide timer on every interaction with no bound, letting a page that keeps generating user-input-like activity indefinitely suppress the notice, and ordinary (non-high-priority) snackbars could crowd out or time out the notice in the shared SnackbarCollection queue.
The patch inverts the timer model: Hide() becomes a deliberate no-op that ignores the native timer dismissal, HideImmediately() (now also called from the destructor) performs the real teardown, and the notice’s timeout is moved Java-side (EXCLUSIVE_ACCESS_SNACKBAR_DURATION_MS = 3800) so the countdown only starts once the snackbar is actually visible. To defeat suppression-by-interaction, OnExclusiveAccessUserInput now counts snooze resets and, once snooze_reset_count_ reaches kMaxSnoozeResets (10), forces a re-show by setting force_params.force_update = true to bypass the was_shown_ latch. Finally SnackbarCollection gives high-priority security snackbars an isolated “VIP” slot (mActiveHighPriority) that always wins getCurrent(), dedupes rapid duplicates, and enforces a queue cap so normal snackbars cannot starve or evict the notice, and the disclosure/privacy-sandbox notices are marked setHighPriority(true).
Hide() a no-op, times the notice Java-side only while visible, caps snooze resets to force a periodic re-show, and isolates high-priority notices in the snackbar queue.Attack Path
- Enter fullscreen
A malicious page calls
requestFullscreen(or acquires pointer lock), causing Chrome to queue the exclusive-access notice snackbar. - Suppress the notice
The page either spams snackbar-generating actions to crowd the shared queue, rapidly oscillates fullscreen requests, or drives repeated interaction so the pre-patch
Hide()/native timer dismisses the notice orOnUserInputkeeps re-snoozing it, so the “press Esc to exit” warning never becomes visible. - Spoof trusted UI With no visible fullscreen notice, the page paints fake browser chrome/address bar or a spoofed OS/security prompt over the full screen, and the user cannot tell they are in an untrusted fullscreen surface (UI misrepresentation).
- Mitigation forces the notice
After the patch the Java-side duration only counts while the notice is visible, the high-priority snackbar cannot be evicted by normal snackbars, and after
kMaxSnoozeResetsinteractionsforce_updateforces a re-show, so the warning re-appears despite suppression attempts.
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.java |
modified | |
ifchrome/browser/ui/android/exclusive_access/exclusive_access_context_android.cc |
modified | |
SnackbarCollectionchrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.java |
modified | |
ifchrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.java |
modified |
Files Changed
components/browser_ui/widget/android/java/src/org/chromium/components/browser_ui/widget/text/TemplatePreservingTextView.javachrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.javachrome/browser/ui/android/exclusive_access/exclusive_access_context_android.hchrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarManager.javachrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.ccchrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureSnackbar.javatools/metrics/histograms/metadata/ui/enums.xmltools/metrics/histograms/metadata/ui/histograms.xmlchrome/browser/ui/android/exclusive_access/exclusive_access_context_android.ccchrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollectionUnitTest.javachrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureInfobar.javachrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.ccchrome/browser/privacy_sandbox/android/java/src/org/chromium/chrome/browser/privacy_sandbox/PrivacySandboxSnackbarController.javachrome/browser/ui/exclusive_access/exclusive_access_bubble.hchrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/Snackbar.javachrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.javachrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarView.java
Audit Directions
- Security notice lifetime tied to timers that run while hiddenReview any warning/notice whose auto-dismiss timer starts before the notice is confirmed visible, or that can be dismissed while occluded/queued.
- Unbounded snooze/reset on user inputLook for handlers that reset a hide timer on every interaction with no cap, allowing a hostile page to suppress a mandatory notice indefinitely; ensure a forced re-show bound exists.
- Shared notification queues mixing security and ordinary itemsAudit queue managers where high-priority/security items share ordering, timeout, or capacity with normal items and can be evicted, timed out, or crowded out.
Patch
From 3e4d359b7a4de442ecd9a5ecb2e8af37639d7568 Mon Sep 17 00:00:00 2001 From: Vikram Pasupathy <[email protected]> Date: Thu, 11 Jun 2026 14:22:00 -0700 Subject: [PATCH] Android: Harden Snackbar system against security notice suppression Implement structural improvements to Android's Snackbar and Exclusive Access systems to prevent security notice bypasses and UI spoofing. Design: http://shortn/_FiA9a66mBh Bug: 517091927, 503787232, 514072194 Change-Id: Ie73529d96023e60fcaa9fb9ea28fafd73a8adae1 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7896882 Reviewed-by: Jinsuk Kim <[email protected]> Reviewed-by: Foromo Daniel Soromou <[email protected]> Commit-Queue: Vikram Pasupathy <[email protected]> Reviewed-by: Avi Drissman <[email protected]> Reviewed-by: Muyao Xu <[email protected]> Reviewed-by: Sirisha Kavuluru <[email protected]> Cr-Commit-Position: refs/heads/main@{#1645607} --- diff --git a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureInfobar.java b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureInfobar.java index d16bae42..d308fbca 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureInfobar.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureInfobar.java @@ -102,6 +102,7 @@ String action = mResources.getString(R.string.ok); return Snackbar.make(title, mSnackbarController, type, code) .setAction(action, null) + .setHighPriority(true) .setDefaultLines(false); } diff --git a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureSnackbar.java b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureSnackbar.java index 4e7f159..37cf26c 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureSnackbar.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/view/DisclosureSnackbar.java @@ -61,6 +61,7 @@ return Snackbar.make(title, controller, type, code) .setAction(action, null) + .setHighPriority(true) .setDuration(DURATION_MS) .setDefaultLines(false); } diff --git a/chrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.java b/chrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.java index bc2646bf..5509167 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/ui/ExclusiveAccessBubble.java @@ -18,6 +18,8 @@ */ @NullMarked public class ExclusiveAccessBubble { + private static final int EXCLUSIVE_ACCESS_SNACKBAR_DURATION_MS = 3800; + private final ExclusiveAccessContext mParentContext; private @Nullable Snackbar mSnackbar; private final SnackbarManager.SnackbarController mSnackbarController = @@ -52,7 +54,10 @@ Snackbar.UMA_EXCLUSIVE_ACCESS_BUBBLE) // The exclusive access notice is security-critical and should not // be discarded by the timeout of other action snackbars in the queue. - .setHighPriority(true); + .setHighPriority(true) + // Use a Java-side timeout so that the timer only starts when + // the notice is actually visible to the user. + .setDuration(EXCLUSIVE_ACCESS_SNACKBAR_DURATION_MS); snackbarManager.showSnackbar(mSnackbar); } } @@ -60,7 +65,23 @@ @CalledByNative public void update(String text) { if (mText != null && mText.equals(text) && mSnackbar != null) return; + SnackbarManager snackbarManager = mParentContext.getSnackbarManager(); + if (snackbarManager == null) return; + mText = text; + if (mSnackbar != null) { + mSnackbar = + Snackbar.make( + text, + mSnackbarController, + Snackbar.TYPE_ACTION, + Snackbar.UMA_EXCLUSIVE_ACCESS_BUBBLE) + .setHighPriority(true) + .setDuration(EXCLUSIVE_ACCESS_SNACKBAR_DURATION_MS); + // This will trigger SnackbarManager.updateView() and update the existing view. + snackbarManager.showSnackbar(mSnackbar); + return; + } hide(); show(); } diff --git a/chrome/browser/privacy_sandbox/android/java/src/org/chromium/chrome/browser/privacy_sandbox/PrivacySandboxSnackbarController.java b/chrome/browser/privacy_sandbox/android/java/src/org/chromium/chrome/browser/privacy_sandbox/PrivacySandboxSnackbarController.java index 05d29df..ca887f1 100644 --- a/chrome/browser/privacy_sandbox/android/java/src/org/chromium/chrome/browser/privacy_sandbox/PrivacySandboxSnackbarController.java +++ b/chrome/browser/privacy_sandbox/android/java/src/org/chromium/chrome/browser/privacy_sandbox/PrivacySandboxSnackbarController.java @@ -41,6 +41,7 @@ Snackbar.TYPE_PERSISTENT, Snackbar.UMA_PRIVACY_SANDBOX_PAGE_OPEN) .setAction(mContext.getString(R.string.more), null) + .setHighPriority(true) .setDefaultLines(false)); } diff --git a/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.cc b/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.cc index 3ffe446..dccce5e6 100644 --- a/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.cc +++ b/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.cc @@ -96,12 +96,14 @@ } ExclusiveAccessBubbleAndroid::~ExclusiveAccessBubbleAndroid() { - Hide(); + HideImmediately(); } void ExclusiveAccessBubbleAndroid::Hide() { - was_shown_ = false; - bridge_->Hide(); + // On Android, the exclusive access notice is managed by a Java-side timer + // that only starts once the snackbar is visible. We ignore the native + // timer dismissal here to prevent the notice from being hidden while + // it is covered by other UI elements. } void ExclusiveAccessBubbleAndroid::Show() { @@ -110,7 +112,8 @@ } void ExclusiveAccessBubbleAndroid::HideImmediately() { - Hide(); + was_shown_ = false; + bridge_->Hide(); } void ExclusiveAccessBubbleAndroid::Update( diff --git a/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.cc b/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.cc index 61b736fa..312462a 100644 --- a/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.cc +++ b/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.cc @@ -22,8 +22,15 @@ j_activity_tab_provider)); } +ExclusiveAccessContextAndroid::ExclusiveAccessContextAndroid() = default; + ExclusiveAccessContextAndroid::~ExclusiveAccessContextAndroid() = default; +void ExclusiveAccessContextAndroid::SetBubbleForTesting( // IN-TEST + std::unique_ptr<ExclusiveAccessBubbleAndroid> bubble) { + exclusive_access_bubble_ = std::move(bubble); +} + void ExclusiveAccessContextAndroid::Destroy(JNIEnv* env) { Java_ExclusiveAccessContext_destroy(env, java_context_); } @@ -119,6 +126,7 @@ void ExclusiveAccessContextAndroid::DestroyAnyExclusiveAccessBubble() { exclusive_access_bubble_.reset(); exclusive_access_bubble_destruction_task_id_.reset(); + snooze_reset_count_ = 0; } bool ExclusiveAccessContextAndroid::IsExclusiveAccessBubbleDisplayed() const { @@ -130,7 +138,20 @@ // re-snooze the bubble's hide timer or re-show it if the snooze period has // elapsed. if (exclusive_access_bubble_) { - exclusive_access_bubble_->OnUserInput(); + // Prevent a malicious site from indefinitely suppressing the security + // notice by repeatedly resetting the snooze timer via interaction. + // If the user has interacted many times, we force a re-show regardless. + if (++snooze_reset_count_ >= kMaxSnoozeResets) { + snooze_reset_count_ = 0; + ExclusiveAccessBubbleParams force_params = + exclusive_access_bubble_->params(); + // Set force_update to true to bypass the was_shown_ check and force + // the notice to re-show. + force_params.force_update = true; + exclusive_access_bubble_->Update(force_params, base::NullCallback()); + } else { + exclusive_access_bubble_->OnUserInput(); + } } } diff --git a/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.h b/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.h index 52e5dc1..c9dc89d 100644 --- a/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.h +++ b/chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.h @@ -22,8 +22,14 @@ const jni_zero::JavaRef<jobject>& j_context, const jni_zero::JavaRef<jobject>& j_fullscreen_manager, const jni_zero::JavaRef<jobject>& j_activity_tab_provider); + // Constructor for testing, bypasses JNI creation. + ExclusiveAccessContextAndroid(); ~ExclusiveAccessContextAndroid() override; + // Injects an exclusive access bubble instance for testing. + void SetBubbleForTesting( + std::unique_ptr<ExclusiveAccessBubbleAndroid> bubble); + void Destroy(JNIEnv* env); bool IsFullscreen() const override; @@ -70,11 +76,14 @@ void ForceActiveTab(JNIEnv* env, const jni_zero::JavaRef<jobject>& j_tab); + private: + int snooze_reset_count_ = 0; + static constexpr int kMaxSnoozeResets = 10; + base::WeakPtr<ExclusiveAccessContextAndroid> GetAsWeakPtr() { return weak_ptr_factory_.GetWeakPtr(); } - private: void DestroyAnyExclusiveAccessBubble(); base::android::ScopedJavaGlobalRef<jobject> java_context_; diff --git a/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h b/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h index 376bae87..6325a826 100644 --- a/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h +++ b/chrome/browser/ui/exclusive_access/exclusive_access_bubble.h @@ -24,6 +24,9 @@ // Called on user input to update timers and/or re-show the bubble. void OnUserInput(); + // Returns the parameters used to configure the bubble. + const ExclusiveAccessBubbleParams& params() const { return params_; } + // Time the bubble is shown before hiding automatically. static constexpr base::TimeDelta kShowTime = base::Milliseconds(3800); // Time without user input that must elapse before the bubble is re-shown. diff --git a/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/Snackbar.java b/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/Snackbar.java index 9099770..72c7d65 100644 --- a/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/Snackbar.java +++ b/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/Snackbar.java @@ -31,7 +31,7 @@ public class Snackbar { /** * Snackbars that are created as an immediate response to user's action. These snackbars are - * managed in a stack and will be swiped away altogether after timeout. + * managed in a stack and will be dismissed one by one. */ public static final int TYPE_ACTION = 0; @@ -205,7 +205,7 @@ /** * Sets whether the snackbar is high priority. High priority snackbars are shielded from being * discarded by the timeout of other action-type snackbars in the queue (e.g. for security- - * critical notices). + * critical notices), and they stay at the front of the queue. */ public Snackbar setHighPriority(boolean highPriority) { mIsHighPriority = highPriority; diff --git a/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.java b/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.java index bac0e5bb..bb05f57e 100644 --- a/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.java +++ b/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarCollection.java @@ -15,87 +15,148 @@ import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager.SnackbarController; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; import java.util.Iterator; +import java.util.List; /** A data structure that holds all the {@link Snackbar}s managed by {@link SnackbarManager}. */ @NullMarked class SnackbarCollection { + // Standard FIFO queue for normal notifications (e.g., "Copied to clipboard"). private final Deque<Snackbar> mSnackbars = new ArrayDeque<>(); + // Queue for persistent notifications (e.g., "Offline mode"). private final Deque<Snackbar> mPersistentSnackbars = new ArrayDeque<>(); + // Holds a single High Priority security warning (e.g., Fullscreen). + // This isolates HP snackbars from the standard queues, allowing O(1) interruption. + private @Nullable Snackbar mActiveHighPriority; - /** - * Adds a new snackbar to the collection. If the new snackbar is of - * {@link Snackbar#TYPE_ACTION} and current snackbar is of - * {@link Snackbar#TYPE_NOTIFICATION}, the current snackbar will be removed from the - * collection immediately. Snackbars of {@link Snackbar#TYPE_PERSISTENT} will appear after all - * action and notification type snackbars are dismissed and will be hidden if an action or - * notifications are added after it. - */ + private static final int MAX_SNACKBARS = 10; + void add(Snackbar snackbar) { - if (snackbar.isTypeAction()) { + RecordHistogram.recordExactLinearHistogram( + "Snackbar.QueueDepthAtInsertion", + mSnackbars.size() + + mPersistentSnackbars.size() + + (mActiveHighPriority != null ? 1 : 0), + MAX_SNACKBARS + 1); + + if (snackbar.isHighPriority()) { + // High Priority (HP) Security Warnings bypass the regular queues entirely. + if (mActiveHighPriority != null) { + // State Deduplication: Rapid oscillation attacks (e.g., spamming requestFullscreen) + // are collapsed here. If it's the exact same warning, we ignore the duplicate. + if (mActiveHighPriority.getController() == snackbar.getController() + && objectsAreEqual( + mActiveHighPriority.getActionData(), snackbar.getActionData())) { + return; // Deduplication: already have this HP snackbar. + } + // LIFO Immediate Interruption: The new HP warning instantly overwrites the old one. + RecordHistogram.recordEnumeratedHistogram( + "Snackbar.DismissalReason", + DismissalReason.REPLACED_BY_HIGH_PRIORITY, + DismissalReason.NUM_ENTRIES); + assert mActiveHighPriority.getController() != null; + if (mActiveHighPriority.getController() != null) { + mActiveHighPriority + .getController() + .onDismissNoAction(mActiveHighPriority.getActionData()); + } + } + // Assign to the VIP slot. getCurrent() will now prioritize this above all else. + mActiveHighPriority = snackbar; + return; + } + + if (snackbar.isTypePersistent()) { + assert !TextUtils.isEmpty(snackbar.getActionText()) + : "Persistent snackbars require action text."; + mPersistentSnackbars.addFirst(snackbar); + } else if (snackbar.isTypeAction()) { if (getCurrent() != null && !getCurrent().isTypeAction()) { removeCurrent(DismissalReason.REPLACED_BY_ACTION_SNACKBAR); } mSnackbars.addFirst(snackbar); - } else if (snackbar.isTypePersistent()) { - // Although persistent snackbars set their action text by default, it is possible that - // the developer overrides it. This is a safeguard to ensure all persistent snackbars - // have a method of dismissal. - assert !TextUtils.isEmpty(snackbar.getActionText()) - : "Persistent snackbars require action text."; - mPersistentSnackbars.addFirst(snackbar); } else { mSnackbars.addLast(snackbar); } + + // Enforce a hard capacity limit to prevent queue exhaustion. + // Drop the oldest unseen standard notifications from the front of the queue. + while (mSnackbars.size() > MAX_SNACKBARS) { + removeOldestUnseenSnackbar(mSnackbars); + } + while (mPersistentSnackbars.size() > MAX_SNACKBARS) { + removeOldestUnseenSnackbar(mPersistentSnackbars); + } } - /** - * Removes the current snackbar from the collection after the user has clicked on the action - * button. - */ - Snackbar removeCurrentDueToAction() { + private void removeOldestUnseenSnackbar(Deque<Snackbar> list) { + Iterator<Snackbar> iter = list.iterator(); + if (iter.hasNext() && list.peekFirst() == getCurrent()) { + iter.next(); // Skip the currently showing one + } + if (!iter.hasNext()) return; + Snackbar toRemove = iter.next(); + iter.remove(); + RecordHistogram.recordEnumeratedHistogram( + "Snackbar.DismissalReason", DismissalReason.OTHERS, DismissalReason.NUM_ENTRIES); + SnackbarController controller = toRemove.getController(); + if (controller != null) { + controller.onDismissNoAction(toRemove.getActionData()); + } + } + + boolean contains(Snackbar snackbar) { + return snackbar == mActiveHighPriority + || mSnackbars.contains(snackbar) + || mPersistentSnackbars.contains(snackbar); + } + + @Nullable Snackbar getCurrent() { + if (mActiveHighPriority != null) return mActiveHighPriority; + Snackbar actionCurrent = mSnackbars.peekFirst(); + Snackbar persistentCurrent = mPersistentSnackbars.peekFirst(); + return actionCurrent != null ? actionCurrent : persistentCurrent; + } + + @Nullable Snackbar removeCurrentDueToAction() { return removeCurrent(DismissalReason.ACTION_BUTTON); } - /** Removes the current snackbar from the collection after the user has swiped it away. */ @Nullable Snackbar removeCurrentDueToSwipe() { return removeCurrent(DismissalReason.SWIPE); } ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.cc b/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.cc
index c29f21816c..976f848 100644
--- a/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.cc
+++ b/chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android_unittest.cc
@@ -4,8 +4,13 @@
#include "chrome/browser/ui/android/exclusive_access/exclusive_access_bubble_android.h"
+#include <memory>
+#include <string>
+#include <utility>
+
#include "base/android/jni_string.h"
#include "base/functional/callback_helpers.h"
+#include "chrome/browser/ui/android/exclusive_access/exclusive_access_context_android.h"
#include "chrome/test/base/chrome_render_view_host_test_harness.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -106,4 +111,45 @@
EXPECT_CALL(*mock_bridge_ptr, Hide()).Times(1);
}
+TEST_F(ExclusiveAccessBubbleAndroidTest, SnoozeResetForciblyReshowsNotice) {
+ ExclusiveAccessBubbleParams params;
+ params.type = EXCLUSIVE_ACCESS_BUBBLE_TYPE_FULLSCREEN_EXIT_INSTRUCTION;
+
+ auto mock_bridge = std::make_unique<MockBridge>();
+ auto* mock_bridge_ptr = mock_bridge.get();
+
+ // Initial show on creation.
+ EXPECT_CALL(*mock_bridge_ptr, IsVisible()).WillOnce(Return(false));
+ EXPECT_CALL(*mock_bridge_ptr, IsKeyboardConnected()).WillOnce(Return(false));
+ EXPECT_CALL(*mock_bridge_ptr, Update(_)).Times(1);
+ EXPECT_CALL(*mock_bridge_ptr, Show()).Times(1);
+
+ auto bubble = std::make_unique<ExclusiveAccessBubbleAndroid>(
+ params, base::DoNothing(), std::move(mock_bridge));
+
+ testing::Mock::VerifyAndClearExpectations(mock_bridge_ptr);
+
+ ExclusiveAccessContextAndroid context;
+ context.SetBubbleForTesting(std::move(bubble));
+
+ // Verify that the first 9 user inputs don't trigger any show or update on the
+ // bridge.
+ for (int i = 1; i <= 9; ++i) {
+ context.OnExclusiveAccessUserInput();
+ }
+
+ // The 10th user input exceeds the snooze interaction threshold and must
+ // forcibly re-show the security notice regardless of whether it was
+ // previously shown in this session (i.e. force_update is set to true to
+ // override the was_shown_ latch).
+ EXPECT_CALL(*mock_bridge_ptr, IsVisible()).WillOnce(Return(false));
+ EXPECT_CALL(*mock_bridge_ptr, IsKeyboardConnected()).WillOnce(Return(false));
+ EXPECT_CALL(*mock_bridge_ptr, Update(_)).Times(1);
+ EXPECT_CALL(*mock_bridge_ptr, Show()).Times(1);
+
+ context.OnExclusiveAccessUserInput();
+
+ testing::Mock::VerifyAndClearExpectations(mock_bridge_ptr);
+}
+
} // namespace