fix(profiles): don't re-prompt for profile when a system overlay closes

On Fire TV, invoking Alexa and dismissing its overlay popped the profile
picker over a still-playing session: MainScreen treated every resumed
lifecycle event as an app open and re-applied "ask for profile on open".
The Alexa overlay only produces inactive -> resumed, which the handler
could not tell apart from a real return to the foreground.

Track the deepest lifecycle state since the last resume in a small gate
(ProfileSelectionResumeGate): only a genuine backgrounding (hidden,
paused, or detached) arms the prompt for the next resume. This also
keeps iOS working, where returning from the background arrives as
hidden -> inactive -> resumed.

close #1990
This commit is contained in:
edde746
2026-08-18 02:09:28 +02:00
parent c5e7ce648b
commit b4a84c6a28
2 changed files with 83 additions and 1 deletions
+45 -1
View File
@@ -88,6 +88,44 @@ bool shouldHandleDesktopRootEscape({
return isDesktop && isPhysicalKeyboardEvent && logicalKey == LogicalKeyboardKey.escape && isCurrentRoute && isHomeTab;
}
/// Latches whether the app has genuinely left the foreground since the last
/// `resumed`, and consumes that fact on the next resume so the "ask for a
/// profile on open" rule can re-apply exactly once per backgrounding.
///
/// System overlays (Fire TV Alexa, notification shade, Control Center) only
/// produce `inactive -> resumed` — the app never left the foreground — so
/// they must not prompt (#1990). iOS returns from the background as
/// `hidden -> inactive -> resumed`, so inspecting only the immediately
/// previous state would miss a real return; latching the deepest state seen
/// handles both. The startup flow owns the cold open, so a first `resumed`
/// with no prior backgrounding does not prompt.
@visibleForTesting
class ProfileSelectionResumeGate {
bool _wasBackgrounded = false;
/// Whether a genuine backgrounding has been observed since the last resume.
bool get wasBackgrounded => _wasBackgrounded;
/// Feeds one lifecycle transition through the gate. Returns true exactly
/// once per backgrounding: on the first `resumed` after the sequence
/// reached `hidden`/`paused`/`detached`. Consuming resets the latch.
bool consumePromptOn(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
final shouldPrompt = _wasBackgrounded;
_wasBackgrounded = false;
return shouldPrompt;
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
_wasBackgrounded = true;
return false;
case AppLifecycleState.inactive:
return false;
}
}
}
@visibleForTesting
({double left, double width}) mainScreenSideNavigationContentLayout({
required double viewportWidth,
@@ -236,6 +274,11 @@ class _MainScreenState extends State<MainScreen>
/// Prevents double-pushing the profile selection screen
bool _isShowingProfileSelection = false;
/// Latches a genuine backgrounding so "ask for a profile on open" fires
/// exactly once on the next resume, while transient focus losses
/// (`inactive`, e.g. the Fire TV Alexa overlay) never prompt.
final _profileSelectionResumeGate = ProfileSelectionResumeGate();
late List<Widget> _screens;
/// One [GlobalKey] per tab, so a tab's live [State] can be reached from
@@ -923,7 +966,8 @@ class _MainScreenState extends State<MainScreen>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && !_isOffline && !_isShowingProfileSelection) {
final shouldPrompt = _profileSelectionResumeGate.consumePromptOn(state);
if (shouldPrompt && !_isOffline && !_isShowingProfileSelection) {
// Only show profile selection on resume for mobile platforms.
// On desktop, "resumed" fires on every window focus gain (alt-tab, click),
// which is too frequent — the initial prompt on startup is sufficient.
+38
View File
@@ -134,6 +134,44 @@ void main() {
);
});
group('ProfileSelectionResumeGate', () {
test('does not prompt for overlay-style focus loss and regain (#1990)', () {
final gate = ProfileSelectionResumeGate();
expect(gate.consumePromptOn(AppLifecycleState.inactive), isFalse);
expect(gate.consumePromptOn(AppLifecycleState.resumed), isFalse);
});
test('prompts exactly once after a genuine backgrounding', () {
final gate = ProfileSelectionResumeGate();
expect(gate.consumePromptOn(AppLifecycleState.inactive), isFalse);
expect(gate.consumePromptOn(AppLifecycleState.paused), isFalse);
expect(gate.wasBackgrounded, isTrue);
expect(gate.consumePromptOn(AppLifecycleState.resumed), isTrue);
expect(gate.consumePromptOn(AppLifecycleState.resumed), isFalse);
expect(gate.wasBackgrounded, isFalse);
});
test('prompts after an iOS-style hidden -> inactive -> resumed return', () {
final gate = ProfileSelectionResumeGate();
expect(gate.consumePromptOn(AppLifecycleState.hidden), isFalse);
expect(gate.consumePromptOn(AppLifecycleState.inactive), isFalse);
expect(gate.consumePromptOn(AppLifecycleState.resumed), isTrue);
});
test('latches every backgrounding state', () {
for (final state in [AppLifecycleState.hidden, AppLifecycleState.paused, AppLifecycleState.detached]) {
final gate = ProfileSelectionResumeGate();
expect(gate.consumePromptOn(state), isFalse);
expect(gate.consumePromptOn(AppLifecycleState.resumed), isTrue, reason: '$state');
}
});
test('does not prompt without a prior backgrounding (cold open)', () {
final gate = ProfileSelectionResumeGate();
expect(gate.consumePromptOn(AppLifecycleState.resumed), isFalse);
});
});
testWidgets('side navigation bleed animates from the previous value', (tester) async {
Widget build(double targetBleed) {
return Directionality(