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
+89 -40
View File
@@ -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<void> _finishDeferredStartup() async {
class ChanoraApp extends StatelessWidget {
const ChanoraApp({super.key});
static final GlobalKey<NavigatorState> navigatorKey =
GlobalKey<NavigatorState>();
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
@@ -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<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 {
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<void>(
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<void> _onConfirmDisconnect() async {
final l10n = AppL10n.of(context);
final confirmed = await showDialog<bool>(
context: context,
final confirmed = await _showTrackedDialog<bool>(
builder: (ctx) => AlertDialog(
title: Text(l10n.disconnectConfirmTitle),
content: Text(l10n.disconnectConfirmBody),