From 1709ae2ac2481971f6de708879d22920b21c2e1d Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 25 May 2026 03:21:02 +0900 Subject: [PATCH] Route Android system back through the Flutter shell Constraint: SRS-163 requires Android back to be handled by the shell/platform layer, and the Dart service existed but was not wired into the live app. Rejected: Leave BackIntentService test-only and unwired | System back would bypass app policy on Android. Confidence: medium Scope-risk: narrow Directive: Keep back-intent probe state in sync with every new dialog or pushed route added to the Flutter shell. Tested: dart format apps/chanora_flutter/lib/main.dart; flutter analyze lib/main.dart test/services/back_intent_service_test.dart; flutter test test/services/back_intent_service_test.dart Not-tested: Manual Android device back-navigation smoke test --- apps/chanora_flutter/lib/main.dart | 129 ++++++++++++++++++++--------- 1 file changed, 89 insertions(+), 40 deletions(-) diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 81ecdcf..af55d30 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -20,6 +20,7 @@ import 'l10n/generated/app_localizations.dart'; import 'services/android_permissions_service.dart'; import 'services/app_bootstrap.dart'; import 'services/audio_lifecycle_service.dart'; +import 'services/back_intent_service.dart'; import 'services/channel_join_error_mapper.dart'; import 'services/connection_phase_state.dart'; import 'services/ios_permissions_service.dart'; @@ -93,6 +94,9 @@ Future _finishDeferredStartup() async { class ChanoraApp extends StatelessWidget { const ChanoraApp({super.key}); + static final GlobalKey navigatorKey = + GlobalKey(); + @override Widget build(BuildContext context) { return AnnotatedRegion( @@ -114,6 +118,7 @@ class ChanoraApp extends StatelessWidget { colorSchemeSeed: const Color(0xFF3F51B5), scaffoldBackgroundColor: _appSurfaceColor, ), + navigatorKey: navigatorKey, localizationsDelegates: AppL10n.localizationsDelegates, supportedLocales: AppL10n.supportedLocales, home: const _BetaHome(), @@ -220,12 +225,24 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { AndroidPermissionsService(); final IosPermissionsService _iosPermissions = IosPermissionsService(); final UiPreferencesService _uiPreferences = const UiPreferencesService(); + late final BackIntentService _backIntentService; + int _modalRouteDepth = 0; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); HardwareKeyboard.instance.addHandler(_handleFocusedPttKey); + _backIntentService = !kIsWeb && Platform.isAndroid + ? BackIntentService( + pttActiveProbe: () => _audioStats?.pttActive ?? false, + modalOpenProbe: () => _modalRouteDepth > 0, + atRootProbe: () => !_chatOpen, + closeTopModal: _closeTopModalRoute, + popRoute: _popTopRoute, + ) + : BackIntentService.noOp(); + _backIntentService.start(); _eventsSub = rust.eventsStream().listen(_onEvent); // SDD-106 ยง5: subscribe to Kotlin -> Dart permissionStateChanged // events as early as possible so the listen-only banner reflects @@ -246,6 +263,38 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { unawaited(_loadUiSettings()); } + Future _showTrackedDialog({ + required WidgetBuilder builder, + bool barrierDismissible = true, + }) async { + _modalRouteDepth += 1; + try { + return await showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: builder, + ); + } finally { + if (_modalRouteDepth > 0) { + _modalRouteDepth -= 1; + } + } + } + + void _closeTopModalRoute() { + final navigator = ChanoraApp.navigatorKey.currentState; + if (navigator == null || _modalRouteDepth == 0 || !navigator.canPop()) { + return; + } + navigator.pop(); + } + + void _popTopRoute() { + final navigator = ChanoraApp.navigatorKey.currentState; + if (navigator == null) return; + unawaited(navigator.maybePop()); + } + Future _loadUiSettings() async { try { final settings = await _uiPreferences.loadSettings(); @@ -270,8 +319,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final permsExplained = await _uiPreferences.hasExplainedPermissions(); if (!permsExplained) { if (!mounted) return; - await showDialog( - context: context, + await _showTrackedDialog( builder: (ctx) => AlertDialog( title: const Text('Permissions'), content: const Text( @@ -675,6 +723,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { void dispose() { WidgetsBinding.instance.removeObserver(this); HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey); + _backIntentService.stop(); _eventsSub?.cancel(); _statsTimer?.cancel(); _snapshotRefreshInFlight = false; @@ -754,43 +803,44 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { void _showPermissionDeniedDialog(String rawError) { final l10n = AppL10n.of(context); final isNetwork = rawError.contains('9987') || rawError.contains('connect'); - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text( - isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied, - ), - content: Text( - isNetwork - ? l10n.networkPermissionBody - : l10n.microphonePermissionBody, - ), - actions: [ - if (Platform.isIOS && !isNetwork) - TextButton( - onPressed: () { - Navigator.pop(ctx); - unawaited(_openIosAppSettings()); - }, - child: Text(l10n.networkPermissionOpenSettings), - ), - if (Platform.isMacOS) - TextButton( - onPressed: () { - Navigator.pop(ctx); - try { - Process.run('open', [ - 'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork', - ]); - } catch (_) {} - }, - child: Text(l10n.networkPermissionOpenSettings), - ), - TextButton( - onPressed: () => Navigator.pop(ctx), - child: Text(MaterialLocalizations.of(context).okButtonLabel), + unawaited( + _showTrackedDialog( + builder: (ctx) => AlertDialog( + title: Text( + isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied, ), - ], + content: Text( + isNetwork + ? l10n.networkPermissionBody + : l10n.microphonePermissionBody, + ), + actions: [ + if (Platform.isIOS && !isNetwork) + TextButton( + onPressed: () { + Navigator.pop(ctx); + unawaited(_openIosAppSettings()); + }, + child: Text(l10n.networkPermissionOpenSettings), + ), + if (Platform.isMacOS) + TextButton( + onPressed: () { + Navigator.pop(ctx); + try { + Process.run('open', [ + 'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork', + ]); + } catch (_) {} + }, + child: Text(l10n.networkPermissionOpenSettings), + ), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + ), ), ); } @@ -1168,8 +1218,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { Future _onConfirmDisconnect() async { final l10n = AppL10n.of(context); - final confirmed = await showDialog( - context: context, + final confirmed = await _showTrackedDialog( builder: (ctx) => AlertDialog( title: Text(l10n.disconnectConfirmTitle), content: Text(l10n.disconnectConfirmBody),