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
This commit is contained in:
Edison Jwa
2026-05-25 03:21:02 +09:00
parent 0648203100
commit 1709ae2ac2
+55 -6
View File
@@ -20,6 +20,7 @@ import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart'; import 'services/android_permissions_service.dart';
import 'services/app_bootstrap.dart'; import 'services/app_bootstrap.dart';
import 'services/audio_lifecycle_service.dart'; import 'services/audio_lifecycle_service.dart';
import 'services/back_intent_service.dart';
import 'services/channel_join_error_mapper.dart'; import 'services/channel_join_error_mapper.dart';
import 'services/connection_phase_state.dart'; import 'services/connection_phase_state.dart';
import 'services/ios_permissions_service.dart'; import 'services/ios_permissions_service.dart';
@@ -93,6 +94,9 @@ Future<void> _finishDeferredStartup() async {
class ChanoraApp extends StatelessWidget { class ChanoraApp extends StatelessWidget {
const ChanoraApp({super.key}); const ChanoraApp({super.key});
static final GlobalKey<NavigatorState> navigatorKey =
GlobalKey<NavigatorState>();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>( return AnnotatedRegion<SystemUiOverlayStyle>(
@@ -114,6 +118,7 @@ class ChanoraApp extends StatelessWidget {
colorSchemeSeed: const Color(0xFF3F51B5), colorSchemeSeed: const Color(0xFF3F51B5),
scaffoldBackgroundColor: _appSurfaceColor, scaffoldBackgroundColor: _appSurfaceColor,
), ),
navigatorKey: navigatorKey,
localizationsDelegates: AppL10n.localizationsDelegates, localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales, supportedLocales: AppL10n.supportedLocales,
home: const _BetaHome(), home: const _BetaHome(),
@@ -220,12 +225,24 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
AndroidPermissionsService(); AndroidPermissionsService();
final IosPermissionsService _iosPermissions = IosPermissionsService(); final IosPermissionsService _iosPermissions = IosPermissionsService();
final UiPreferencesService _uiPreferences = const UiPreferencesService(); final UiPreferencesService _uiPreferences = const UiPreferencesService();
late final BackIntentService _backIntentService;
int _modalRouteDepth = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
HardwareKeyboard.instance.addHandler(_handleFocusedPttKey); 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); _eventsSub = rust.eventsStream().listen(_onEvent);
// SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged // SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged
// events as early as possible so the listen-only banner reflects // events as early as possible so the listen-only banner reflects
@@ -246,6 +263,38 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
unawaited(_loadUiSettings()); unawaited(_loadUiSettings());
} }
Future<T?> _showTrackedDialog<T>({
required WidgetBuilder builder,
bool barrierDismissible = true,
}) async {
_modalRouteDepth += 1;
try {
return await showDialog<T>(
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<void> _loadUiSettings() async { Future<void> _loadUiSettings() async {
try { try {
final settings = await _uiPreferences.loadSettings(); final settings = await _uiPreferences.loadSettings();
@@ -270,8 +319,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final permsExplained = await _uiPreferences.hasExplainedPermissions(); final permsExplained = await _uiPreferences.hasExplainedPermissions();
if (!permsExplained) { if (!permsExplained) {
if (!mounted) return; if (!mounted) return;
await showDialog( await _showTrackedDialog(
context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Permissions'), title: const Text('Permissions'),
content: const Text( content: const Text(
@@ -675,6 +723,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey); HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey);
_backIntentService.stop();
_eventsSub?.cancel(); _eventsSub?.cancel();
_statsTimer?.cancel(); _statsTimer?.cancel();
_snapshotRefreshInFlight = false; _snapshotRefreshInFlight = false;
@@ -754,8 +803,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
void _showPermissionDeniedDialog(String rawError) { void _showPermissionDeniedDialog(String rawError) {
final l10n = AppL10n.of(context); final l10n = AppL10n.of(context);
final isNetwork = rawError.contains('9987') || rawError.contains('connect'); final isNetwork = rawError.contains('9987') || rawError.contains('connect');
showDialog( unawaited(
context: context, _showTrackedDialog<void>(
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: Text( title: Text(
isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied, isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied,
@@ -792,6 +841,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
), ),
], ],
), ),
),
); );
} }
@@ -1168,8 +1218,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Future<void> _onConfirmDisconnect() async { Future<void> _onConfirmDisconnect() async {
final l10n = AppL10n.of(context); final l10n = AppL10n.of(context);
final confirmed = await showDialog<bool>( final confirmed = await _showTrackedDialog<bool>(
context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: Text(l10n.disconnectConfirmTitle), title: Text(l10n.disconnectConfirmTitle),
content: Text(l10n.disconnectConfirmBody), content: Text(l10n.disconnectConfirmBody),