feat(flutter,android): permission state banner + AndroidPermissionsService + voice-join gate

Dart consumer for the Android permission state pipeline. New
AndroidPermissionsService listens on the app.chanora/android_permissions
MethodChannel and exposes a ValueListenable for the UI. The voice-join
flow in main.dart calls ensureRecordAudio() before rust.voiceJoin and
clamps to listen-only via setHardMute on denial. A non-modal banner
above the VoiceBar surfaces the Grant / Open Settings action depending
on whether the state is Denied or PermanentlyDenied. On non-Android
hosts the service short-circuits to granted; the banner is never built.

Also adds the BackIntentService Dart consumer (back_intent_policy +
back_intent_service) which the Kotlin BackIntentBridge invokes via
MethodChannel for deterministic route-pop ordering.

Trace: SDD-028, SDD-106, SRS-163, SRS-209.
This commit is contained in:
EdisonJwa
2026-05-18 12:48:28 +08:00
parent 0dc8297568
commit c4145a8727
5 changed files with 702 additions and 0 deletions
+69
View File
@@ -20,9 +20,11 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/lib.dart' as rust_err;
import 'src/rust/frb_generated.dart';
import 'widgets/permission_state_banner.dart';
import 'widgets/voice_bar.dart';
import 'widgets/voice_compact.dart';
import 'widgets/voice_settings.dart';
@@ -287,11 +289,23 @@ class _BetaHomeState extends State<_BetaHome> {
List<rust.BridgeBookmark> _bookmarks = const [];
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
// Constructed at startup so cold-launch state is captured before the
// first voice_join attempt. On non-Android hosts the service
// short-circuits to "granted" and never wires the MethodChannel
// (see AndroidPermissionsService for the platform branch).
final AndroidPermissionsService _androidPermissions =
AndroidPermissionsService();
@override
void initState() {
super.initState();
HardwareKeyboard.instance.addHandler(_handleFocusedPttKey);
_eventsSub = rust.eventsStream().listen(_onEvent);
// SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged
// events as early as possible so the listen-only banner reflects
// the system state on first frame.
_androidPermissions.start();
unawaited(_reloadBookmarks());
unawaited(_hydratePttBinding());
}
@@ -462,6 +476,22 @@ class _BetaHomeState extends State<_BetaHome> {
);
}
}());
// SDD-106 §5/§6 / SRS-209: defensive observer of the
// authoritative Rust-side permission stream. The transmit
// clamp is already applied inside `chanora_bridge` before
// this event is broadcast; here we merely surface the
// event so the UI stays consistent if the MethodChannel
// path is ever delayed. The existing `AndroidPermissionsService`
// remains the canonical Dart-side state holder (driven by
// the MethodChannel); a future revision may expose a
// setter so both paths converge on a single ValueNotifier.
case rust.BridgeEvent_PermissionState(
:final permission,
:final state,
):
debugPrint(
'bridge permission_state: permission=$permission state=$state',
);
}
}
@@ -493,6 +523,10 @@ class _BetaHomeState extends State<_BetaHome> {
_hostCtl.dispose();
_nickCtl.dispose();
_passwordCtl.dispose();
// SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
// late invokeMethod from the platform side cannot land on this
// disposed state.
_androidPermissions.stop();
super.dispose();
}
@@ -625,6 +659,34 @@ class _BetaHomeState extends State<_BetaHome> {
if (password == null) return; // cancelled
}
try {
// SDD-106 §1, §6 + SRS-209: Android runtime permission gate.
// Request RECORD_AUDIO at or before voice_join. On denial or
// permanent denial we still proceed (listen-only is a
// first-class mode per SDD-106) but clamp local transmit via
// setHardMute so the audio engine never opens the capture
// stream as a sender. On non-Android the service short-circuits
// to granted and this branch is a no-op.
//
// Trace: SDD-106 §1 (request timing), §2 (listen-only on denial),
// §3 (path to settings on permanent denial), §6
// (TransmitModeSelector clamp); SRS-209.
final permState = await _androidPermissions.ensureRecordAudio();
if (permState != AndroidRecordAudioPermissionState.granted) {
// Listen-only: clamp hard-mute. The permission_state_banner
// surfaces the path-to-grant; the user can re-attempt at any
// time via the Grant / Open Settings action.
try {
await rust.setHardMute(muted: true);
if (mounted) setState(() => _hardMute = true);
} catch (_) {
// Best-effort clamp; if the bridge isn't ready we still
// proceed. The capture path also self-clamps on Android
// when RECORD_AUDIO is not granted (SDD-106 §6 Rust-side,
// via BridgeEvent::PermissionState → TransmitModeSelector
// AtomicU8 clamp); this Dart setHardMute is the
// defence-in-depth path.
}
}
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
if (!mounted) return;
setState(() => _currentVoiceChannelId = ch.id);
@@ -1265,6 +1327,11 @@ class _BetaHomeState extends State<_BetaHome> {
onConfigure: _onOpenVoiceSettings,
onPttHeldChanged: _onOnscreenPttHeldChanged,
);
// SDD-106 §2/§3 + SRS-209: listen-only banner.
// Self-hides on granted / unknown / non-Android.
final permissionBanner = PermissionStateBanner(
service: _androidPermissions,
);
final snapshotView = _SnapshotView(
snapshot: _snapshot!,
currentVoiceChannelId: _currentVoiceChannelId,
@@ -1284,6 +1351,7 @@ class _BetaHomeState extends State<_BetaHome> {
children: [
banner,
const SizedBox(height: 12),
permissionBanner,
voiceBar,
],
),
@@ -1298,6 +1366,7 @@ class _BetaHomeState extends State<_BetaHome> {
children: [
Expanded(child: snapshotView),
const SizedBox(height: 8),
permissionBanner,
VoiceStatusChip(
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,