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