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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user