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 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import 'l10n/generated/app_localizations.dart';
|
import 'l10n/generated/app_localizations.dart';
|
||||||
|
import 'services/android_permissions_service.dart';
|
||||||
import 'src/rust/api.dart' as rust;
|
import 'src/rust/api.dart' as rust;
|
||||||
import 'src/rust/lib.dart' as rust_err;
|
import 'src/rust/lib.dart' as rust_err;
|
||||||
import 'src/rust/frb_generated.dart';
|
import 'src/rust/frb_generated.dart';
|
||||||
|
import 'widgets/permission_state_banner.dart';
|
||||||
import 'widgets/voice_bar.dart';
|
import 'widgets/voice_bar.dart';
|
||||||
import 'widgets/voice_compact.dart';
|
import 'widgets/voice_compact.dart';
|
||||||
import 'widgets/voice_settings.dart';
|
import 'widgets/voice_settings.dart';
|
||||||
@@ -287,11 +289,23 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
|
|
||||||
List<rust.BridgeBookmark> _bookmarks = const [];
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
HardwareKeyboard.instance.addHandler(_handleFocusedPttKey);
|
HardwareKeyboard.instance.addHandler(_handleFocusedPttKey);
|
||||||
_eventsSub = rust.eventsStream().listen(_onEvent);
|
_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(_reloadBookmarks());
|
||||||
unawaited(_hydratePttBinding());
|
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();
|
_hostCtl.dispose();
|
||||||
_nickCtl.dispose();
|
_nickCtl.dispose();
|
||||||
_passwordCtl.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();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,6 +659,34 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
if (password == null) return; // cancelled
|
if (password == null) return; // cancelled
|
||||||
}
|
}
|
||||||
try {
|
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 ?? '');
|
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _currentVoiceChannelId = ch.id);
|
setState(() => _currentVoiceChannelId = ch.id);
|
||||||
@@ -1265,6 +1327,11 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
onConfigure: _onOpenVoiceSettings,
|
onConfigure: _onOpenVoiceSettings,
|
||||||
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
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(
|
final snapshotView = _SnapshotView(
|
||||||
snapshot: _snapshot!,
|
snapshot: _snapshot!,
|
||||||
currentVoiceChannelId: _currentVoiceChannelId,
|
currentVoiceChannelId: _currentVoiceChannelId,
|
||||||
@@ -1284,6 +1351,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
children: [
|
children: [
|
||||||
banner,
|
banner,
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
permissionBanner,
|
||||||
voiceBar,
|
voiceBar,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1298,6 +1366,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(child: snapshotView),
|
Expanded(child: snapshotView),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
permissionBanner,
|
||||||
VoiceStatusChip(
|
VoiceStatusChip(
|
||||||
transmitMode: _transmitMode,
|
transmitMode: _transmitMode,
|
||||||
releaseTailMs: _releaseTailMs,
|
releaseTailMs: _releaseTailMs,
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/// SDD-106 `AndroidPermissionRequester` — Dart integration layer.
|
||||||
|
///
|
||||||
|
/// Trace:
|
||||||
|
/// - SDD-106 (Android runtime acquisition of `RECORD_AUDIO` with
|
||||||
|
/// listen-only fallback; bridge event surface §5).
|
||||||
|
/// - SRS-209 (runtime microphone permission acquisition; fail-safe
|
||||||
|
/// to listen-only on denial; user-visible path to grant).
|
||||||
|
///
|
||||||
|
/// Responsibilities:
|
||||||
|
/// * Subscribe to the Kotlin-side `MethodChannel`
|
||||||
|
/// `app.chanora/android_permissions` for `permissionStateChanged`
|
||||||
|
/// invocations emitted by `AndroidPermissionRequester.kt`
|
||||||
|
/// (Kotlin → Dart).
|
||||||
|
/// * Provide imperative Dart → Kotlin entry points
|
||||||
|
/// `requestRecordAudio` and `openAppSettings` so the UI can drive
|
||||||
|
/// the runtime request flow per SDD-106 §1–§3.
|
||||||
|
/// * Expose the latest resolved state as a [ValueListenable] so UI
|
||||||
|
/// surfaces (the listen-only banner per SRS-209; the pre-`voice_join`
|
||||||
|
/// gate per SDD-106 §1) can react without polling.
|
||||||
|
///
|
||||||
|
/// ## Non-Android short-circuit
|
||||||
|
///
|
||||||
|
/// On Linux / macOS / Windows / iOS / web, RECORD_AUDIO is not gated
|
||||||
|
/// by this channel (desktop has no Android runtime-permission concept;
|
||||||
|
/// iOS uses AVAudioSession authorisation which is owned by the audio
|
||||||
|
/// engine itself per SDD-101). The MethodChannel is therefore never
|
||||||
|
/// constructed off-Android. The state listenable stays at
|
||||||
|
/// [AndroidRecordAudioPermissionState.granted] so the voice-join gate
|
||||||
|
/// (in `main.dart`) becomes a no-op on those platforms.
|
||||||
|
///
|
||||||
|
/// ## No global statics
|
||||||
|
///
|
||||||
|
/// Following the pattern established by `BackIntentService`
|
||||||
|
/// (SDD-028), this class is constructor-injected. The host app
|
||||||
|
/// instantiates one instance at startup and passes it through the
|
||||||
|
/// widget tree.
|
||||||
|
///
|
||||||
|
/// ## Bridge event surface (SDD-106 §5)
|
||||||
|
///
|
||||||
|
/// SDD-106 §5 calls for a `BridgeEvent::PermissionState` variant on
|
||||||
|
/// the existing Rust → Dart event stream so the audio engine can
|
||||||
|
/// observe the resolved state for the transmit-clamp described in
|
||||||
|
/// SDD-106 §6. That Rust-side variant is intentionally deferred to a
|
||||||
|
/// follow-up Wave touching `chanora_bridge`; the current Wave 2B
|
||||||
|
/// Dart-only path is sufficient for the UI obligations of SRS-209
|
||||||
|
/// (listen-only banner; path to grant). See the structured handoff
|
||||||
|
/// note attached to this task for the rationale.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// MethodChannel name shared with Kotlin `MethodChannels.ANDROID_PERMISSIONS`.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §5.
|
||||||
|
@visibleForTesting
|
||||||
|
const String androidPermissionsChannelName = 'app.chanora/android_permissions';
|
||||||
|
|
||||||
|
/// Inbound (Kotlin → Dart) method invoked whenever the resolved
|
||||||
|
/// `RECORD_AUDIO` permission state transitions.
|
||||||
|
///
|
||||||
|
/// Payload schema: `{permission: String, state: String}` where
|
||||||
|
/// `state` is one of `"Granted"`, `"Denied"`, `"PermanentlyDenied"`.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §5; mirrors Kotlin
|
||||||
|
/// `MethodChannels.METHOD_PERMISSION_STATE_CHANGED`.
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodPermissionStateChanged = 'permissionStateChanged';
|
||||||
|
|
||||||
|
/// Outbound (Dart → Kotlin) method that asks the Android requester to
|
||||||
|
/// (re-)prompt for `RECORD_AUDIO`. The result is delivered
|
||||||
|
/// asynchronously via [methodPermissionStateChanged].
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §1.
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodRequestRecordAudio = 'requestRecordAudio';
|
||||||
|
|
||||||
|
/// Outbound (Dart → Kotlin) method that deep-links to the application's
|
||||||
|
/// Android Settings page so the user can re-grant a permanently-denied
|
||||||
|
/// permission.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §3.
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodOpenAppSettings = 'openAppSettings';
|
||||||
|
|
||||||
|
/// Discrete states surfaced to the Dart UI.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §5 (state machine).
|
||||||
|
enum AndroidRecordAudioPermissionState {
|
||||||
|
/// Permission granted; microphone capture is allowed.
|
||||||
|
granted,
|
||||||
|
|
||||||
|
/// Permission denied but the user may still be re-prompted by
|
||||||
|
/// invoking [AndroidPermissionsService.ensureRecordAudio] again.
|
||||||
|
denied,
|
||||||
|
|
||||||
|
/// Permission denied with "Don't ask again" or revoked from system
|
||||||
|
/// Settings. The UI must deep-link to app settings via
|
||||||
|
/// [AndroidPermissionsService.openAppSettings] (SDD-106 §3).
|
||||||
|
permanentlyDenied,
|
||||||
|
|
||||||
|
/// No resolved state yet (cold launch before the first emission, or
|
||||||
|
/// non-Android host before short-circuit). The voice-join gate
|
||||||
|
/// treats this as "request before joining".
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps the Kotlin-side `PermissionState` string into the Dart enum.
|
||||||
|
AndroidRecordAudioPermissionState _parseState(String? raw) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'Granted':
|
||||||
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
|
case 'Denied':
|
||||||
|
return AndroidRecordAudioPermissionState.denied;
|
||||||
|
case 'PermanentlyDenied':
|
||||||
|
return AndroidRecordAudioPermissionState.permanentlyDenied;
|
||||||
|
default:
|
||||||
|
return AndroidRecordAudioPermissionState.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dart-side integration for `AndroidPermissionRequester` (SDD-106).
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106, SRS-209.
|
||||||
|
class AndroidPermissionsService {
|
||||||
|
/// Construct a service bound to [channel]. Injected for testability;
|
||||||
|
/// production code uses the default channel keyed on
|
||||||
|
/// [androidPermissionsChannelName].
|
||||||
|
AndroidPermissionsService({MethodChannel? channel})
|
||||||
|
: _channel = channel ??
|
||||||
|
(_isAndroid
|
||||||
|
? const MethodChannel(androidPermissionsChannelName)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
/// Platform-detection seam. Web counts as non-Android.
|
||||||
|
static bool get _isAndroid {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
return Platform.isAndroid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The underlying channel, or `null` on non-Android (and unset in
|
||||||
|
/// tests that omit the optional argument).
|
||||||
|
final MethodChannel? _channel;
|
||||||
|
|
||||||
|
final ValueNotifier<AndroidRecordAudioPermissionState> _state =
|
||||||
|
ValueNotifier<AndroidRecordAudioPermissionState>(
|
||||||
|
// On non-Android, present as granted so the voice-join gate is a
|
||||||
|
// no-op (desktop / iOS have separate audio-permission paths owned
|
||||||
|
// elsewhere; see SDD-101 for iOS).
|
||||||
|
_isAndroid
|
||||||
|
? AndroidRecordAudioPermissionState.unknown
|
||||||
|
: AndroidRecordAudioPermissionState.granted,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool _started = false;
|
||||||
|
|
||||||
|
/// Latest known [RECORD_AUDIO] state. Defaults to
|
||||||
|
/// [AndroidRecordAudioPermissionState.unknown] on Android and
|
||||||
|
/// [AndroidRecordAudioPermissionState.granted] elsewhere.
|
||||||
|
ValueListenable<AndroidRecordAudioPermissionState> get recordAudioState =>
|
||||||
|
_state;
|
||||||
|
|
||||||
|
/// Start listening for state updates from Kotlin. Idempotent.
|
||||||
|
///
|
||||||
|
/// On non-Android this is a no-op.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §5.
|
||||||
|
void start() {
|
||||||
|
if (_started) return;
|
||||||
|
_started = true;
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
ch.setMethodCallHandler(_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop listening. Idempotent.
|
||||||
|
void stop() {
|
||||||
|
if (!_started) return;
|
||||||
|
_started = false;
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
ch.setMethodCallHandler(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _handle(MethodCall call) async {
|
||||||
|
if (call.method != methodPermissionStateChanged) return null;
|
||||||
|
final args = call.arguments;
|
||||||
|
if (args is! Map) return null;
|
||||||
|
// We currently track RECORD_AUDIO only. POST_NOTIFICATIONS (SDD-107
|
||||||
|
// §6) is on the same channel by design and will route through a
|
||||||
|
// sibling listenable when that work lands.
|
||||||
|
final permission = args['permission'];
|
||||||
|
if (permission != 'android.permission.RECORD_AUDIO') return null;
|
||||||
|
final state = _parseState(args['state'] as String?);
|
||||||
|
_state.value = state;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request the system permission. Invokes the Kotlin requester and
|
||||||
|
/// then, if the platform synchronously resolves the request, returns
|
||||||
|
/// the resolved state without parking for the listener; otherwise
|
||||||
|
/// awaits the next `permissionStateChanged` emission.
|
||||||
|
///
|
||||||
|
/// On non-Android, resolves immediately with
|
||||||
|
/// [AndroidRecordAudioPermissionState.granted].
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §1, §3.
|
||||||
|
Future<AndroidRecordAudioPermissionState> ensureRecordAudio() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) {
|
||||||
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
|
}
|
||||||
|
// Snapshot the pre-invocation state. If the platform synchronously
|
||||||
|
// resolves the request to a NEW state during invokeMethod (the
|
||||||
|
// platform may emit permissionStateChanged before invokeMethod
|
||||||
|
// returns), we can return immediately. If the state is unchanged
|
||||||
|
// (typical: user is being re-prompted after a previous denial; the
|
||||||
|
// platform always re-prompts and the resolution arrives
|
||||||
|
// asynchronously), we must wait for the next listener fire.
|
||||||
|
final preInvokeState = _state.value;
|
||||||
|
try {
|
||||||
|
await ch.invokeMethod<void>(methodRequestRecordAudio);
|
||||||
|
} catch (_) {
|
||||||
|
// Channel-side failure (e.g. missing handler in a debug build).
|
||||||
|
// Fall back to whatever state we currently hold; if still
|
||||||
|
// unknown, surface unknown so the caller can choose its own
|
||||||
|
// policy. Per SRS-209 the voice-join gate treats unknown as
|
||||||
|
// "proceed in listen-only".
|
||||||
|
return _state.value;
|
||||||
|
}
|
||||||
|
// M1 fix (corrected): only short-circuit if the platform changed
|
||||||
|
// the state synchronously to a resolved value. A still-unknown
|
||||||
|
// state means we must wait. A still-equal-to-pre-invoke state
|
||||||
|
// means we also must wait (the platform is re-prompting; the
|
||||||
|
// resolution arrives asynchronously after the user interacts with
|
||||||
|
// the dialog).
|
||||||
|
if (_state.value != preInvokeState &&
|
||||||
|
_state.value != AndroidRecordAudioPermissionState.unknown) {
|
||||||
|
return _state.value;
|
||||||
|
}
|
||||||
|
// Otherwise we wait for the listener to observe a state
|
||||||
|
// transition. Bounded wait to avoid hanging the voice-join flow
|
||||||
|
// if the platform dialog is dismissed without a result. The
|
||||||
|
// Kotlin requester treats dismissal as Denied (see
|
||||||
|
// AndroidPermissionRequester.handleRequestPermissionsResult) so
|
||||||
|
// this is defence-in-depth.
|
||||||
|
final completer = Completer<AndroidRecordAudioPermissionState>();
|
||||||
|
void listener() {
|
||||||
|
if (!completer.isCompleted &&
|
||||||
|
_state.value != preInvokeState &&
|
||||||
|
_state.value != AndroidRecordAudioPermissionState.unknown) {
|
||||||
|
completer.complete(_state.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.addListener(listener);
|
||||||
|
try {
|
||||||
|
return await completer.future.timeout(
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
onTimeout: () => _state.value,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_state.removeListener(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deep-link to the system app settings page for the permanently
|
||||||
|
/// denied case (SDD-106 §3). On non-Android, a no-op.
|
||||||
|
Future<void> openAppSettings() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
try {
|
||||||
|
await ch.invokeMethod<void>(methodOpenAppSettings);
|
||||||
|
} catch (e, st) {
|
||||||
|
// Best-effort; failure to launch settings is non-fatal. The
|
||||||
|
// Kotlin side already logs ActivityNotFoundException.
|
||||||
|
developer.log(
|
||||||
|
'openAppSettings failed',
|
||||||
|
name: 'AndroidPermissionsService',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release the state notifier. Test helper; production keeps the
|
||||||
|
/// service alive for the lifetime of the app.
|
||||||
|
@visibleForTesting
|
||||||
|
void dispose() {
|
||||||
|
stop();
|
||||||
|
_state.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/// Pure-Dart route-pop policy for SDD-028 (`BackIntentService`).
|
||||||
|
///
|
||||||
|
/// Trace: SDD-028 §2 (deterministic route-pop ordering) / SAD-018.
|
||||||
|
///
|
||||||
|
/// This file intentionally has **no Flutter dependency** so it can be
|
||||||
|
/// unit-tested without a `MaterialApp` or `Navigator` context. The
|
||||||
|
/// integration wrapper lives in `back_intent_service.dart`.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Decision produced by [decideBackIntent].
|
||||||
|
///
|
||||||
|
/// Trace: SDD-028 §2 — exactly one decision per platform back event.
|
||||||
|
sealed class BackIntentDecision {
|
||||||
|
const BackIntentDecision();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PTT is currently transmitting; the back event must be swallowed
|
||||||
|
/// without any UI side effect (SDD-028 §2.a).
|
||||||
|
class IgnoreDueToPtt extends BackIntentDecision {
|
||||||
|
const IgnoreDueToPtt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A modal / dialog route is on top; close it only (SDD-028 §2.b).
|
||||||
|
class CloseModal extends BackIntentDecision {
|
||||||
|
const CloseModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigator can pop a non-root route (SDD-028 §2.c).
|
||||||
|
class PopRoute extends BackIntentDecision {
|
||||||
|
const PopRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At the root route with no modal and no PTT; the platform shall be
|
||||||
|
/// asked to finish the activity (SDD-028 §2.d — `exitCandidate`).
|
||||||
|
class ExitApp extends BackIntentDecision {
|
||||||
|
const ExitApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the back-intent decision deterministically.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-028 §2. The branch order is intentionally fixed:
|
||||||
|
///
|
||||||
|
/// 1. `pttActive == true` → [IgnoreDueToPtt]
|
||||||
|
/// 2. `modalOpen == true` → [CloseModal]
|
||||||
|
/// 3. `atRoot == false` → [PopRoute]
|
||||||
|
/// 4. otherwise → [ExitApp]
|
||||||
|
///
|
||||||
|
/// This function is pure: identical inputs always produce identical
|
||||||
|
/// outputs and there are no side effects, which is the verification
|
||||||
|
/// surface targeted by SWE4-UV-042.
|
||||||
|
BackIntentDecision decideBackIntent({
|
||||||
|
required bool pttActive,
|
||||||
|
required bool modalOpen,
|
||||||
|
required bool atRoot,
|
||||||
|
}) {
|
||||||
|
if (pttActive) {
|
||||||
|
return const IgnoreDueToPtt();
|
||||||
|
}
|
||||||
|
if (modalOpen) {
|
||||||
|
return const CloseModal();
|
||||||
|
}
|
||||||
|
if (!atRoot) {
|
||||||
|
return const PopRoute();
|
||||||
|
}
|
||||||
|
return const ExitApp();
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/// SDD-028 `BackIntentService` — Dart integration layer.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-028 (Android back-intent normalization) / SAD-018.
|
||||||
|
///
|
||||||
|
/// Responsibilities:
|
||||||
|
/// * Listen on `MethodChannel("app.chanora/back_intent")` for
|
||||||
|
/// `backIntent` calls forwarded by `BackIntentBridge` (Kotlin).
|
||||||
|
/// * Read the current shell state via **injected** getter callbacks
|
||||||
|
/// (`pttActiveProbe`, `modalOpenProbe`, `atRootProbe`) so the class
|
||||||
|
/// is testable without a `MaterialApp` / global Navigator. No
|
||||||
|
/// global statics are introduced.
|
||||||
|
/// * Apply `decideBackIntent(...)` from `back_intent_policy.dart` to
|
||||||
|
/// produce a deterministic `BackIntentDecision`.
|
||||||
|
/// * Dispatch the decision to injected effect callbacks
|
||||||
|
/// (`closeTopModal`, `popRoute`) and, for `ExitApp`, ask the
|
||||||
|
/// platform to finish via `channel.invokeMethod('popToSystem')`.
|
||||||
|
///
|
||||||
|
/// Non-Android platforms: use [BackIntentService.noOp] which never
|
||||||
|
/// registers a channel handler. The rest of the app can construct a
|
||||||
|
/// `BackIntentService` unconditionally.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'back_intent_policy.dart';
|
||||||
|
|
||||||
|
/// Probe returning the current value of a shell-state flag.
|
||||||
|
typedef BackIntentBoolProbe = bool Function();
|
||||||
|
|
||||||
|
/// Effect callback invoked by [BackIntentService] when the policy
|
||||||
|
/// resolves a non-platform decision.
|
||||||
|
typedef BackIntentEffect = void Function();
|
||||||
|
|
||||||
|
/// MethodChannel contract shared with `BackIntentBridge.kt`.
|
||||||
|
@visibleForTesting
|
||||||
|
const String backIntentChannelName = 'app.chanora/back_intent';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String backIntentMethodFromPlatform = 'backIntent';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String backIntentMethodPopToSystem = 'popToSystem';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String backIntentPayloadKindKey = 'kind';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String backIntentPayloadKindSystemBack = 'system_back';
|
||||||
|
|
||||||
|
/// SDD-028 service. Construct once per app shell.
|
||||||
|
class BackIntentService {
|
||||||
|
BackIntentService({
|
||||||
|
required BackIntentBoolProbe pttActiveProbe,
|
||||||
|
required BackIntentBoolProbe modalOpenProbe,
|
||||||
|
required BackIntentBoolProbe atRootProbe,
|
||||||
|
required BackIntentEffect closeTopModal,
|
||||||
|
required BackIntentEffect popRoute,
|
||||||
|
MethodChannel? channel,
|
||||||
|
}) : _pttActiveProbe = pttActiveProbe,
|
||||||
|
_modalOpenProbe = modalOpenProbe,
|
||||||
|
_atRootProbe = atRootProbe,
|
||||||
|
_closeTopModal = closeTopModal,
|
||||||
|
_popRoute = popRoute,
|
||||||
|
_channel = channel ?? const MethodChannel(backIntentChannelName),
|
||||||
|
_enabled = true;
|
||||||
|
|
||||||
|
/// No-op factory for non-Android targets (SDD-028 §6).
|
||||||
|
///
|
||||||
|
/// Constructs a service whose probes always return false and whose
|
||||||
|
/// effect callbacks do nothing. It never installs a channel handler.
|
||||||
|
factory BackIntentService.noOp() {
|
||||||
|
return BackIntentService._noOp();
|
||||||
|
}
|
||||||
|
|
||||||
|
BackIntentService._noOp()
|
||||||
|
: _pttActiveProbe = _alwaysFalse,
|
||||||
|
_modalOpenProbe = _alwaysFalse,
|
||||||
|
_atRootProbe = _alwaysFalse,
|
||||||
|
_closeTopModal = _noEffect,
|
||||||
|
_popRoute = _noEffect,
|
||||||
|
_channel = const MethodChannel(backIntentChannelName),
|
||||||
|
_enabled = false;
|
||||||
|
|
||||||
|
final BackIntentBoolProbe _pttActiveProbe;
|
||||||
|
final BackIntentBoolProbe _modalOpenProbe;
|
||||||
|
final BackIntentBoolProbe _atRootProbe;
|
||||||
|
final BackIntentEffect _closeTopModal;
|
||||||
|
final BackIntentEffect _popRoute;
|
||||||
|
final MethodChannel _channel;
|
||||||
|
final bool _enabled;
|
||||||
|
|
||||||
|
bool _started = false;
|
||||||
|
|
||||||
|
/// Install the MethodChannel handler. Safe to call once per instance.
|
||||||
|
/// On the no-op variant this returns without registering anything.
|
||||||
|
void start() {
|
||||||
|
if (!_enabled || _started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_channel.setMethodCallHandler(_handleMethodCall);
|
||||||
|
_started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the MethodChannel handler. Safe to call repeatedly.
|
||||||
|
void stop() {
|
||||||
|
if (!_started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_channel.setMethodCallHandler(null);
|
||||||
|
_started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve and dispatch a back intent. Visible for tests so the
|
||||||
|
/// decision pipeline can be driven without a real platform channel.
|
||||||
|
@visibleForTesting
|
||||||
|
BackIntentDecision dispatch() {
|
||||||
|
final decision = decideBackIntent(
|
||||||
|
pttActive: _pttActiveProbe(),
|
||||||
|
modalOpen: _modalOpenProbe(),
|
||||||
|
atRoot: _atRootProbe(),
|
||||||
|
);
|
||||||
|
_apply(decision);
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _handleMethodCall(MethodCall call) async {
|
||||||
|
if (call.method != backIntentMethodFromPlatform) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final args = call.arguments;
|
||||||
|
if (args is Map &&
|
||||||
|
args[backIntentPayloadKindKey] != backIntentPayloadKindSystemBack) {
|
||||||
|
// Unknown kind: ignore defensively per SDD-028 §5 sanitiser stance.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
dispatch();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _apply(BackIntentDecision decision) {
|
||||||
|
switch (decision) {
|
||||||
|
case IgnoreDueToPtt():
|
||||||
|
// SDD-028 §2.a — consume without UI effect.
|
||||||
|
return;
|
||||||
|
case CloseModal():
|
||||||
|
_closeTopModal();
|
||||||
|
return;
|
||||||
|
case PopRoute():
|
||||||
|
_popRoute();
|
||||||
|
return;
|
||||||
|
case ExitApp():
|
||||||
|
// SDD-028 §2.d — defer to platform fallback.
|
||||||
|
// ignore: discarded_futures
|
||||||
|
_channel.invokeMethod<void>(backIntentMethodPopToSystem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _alwaysFalse() => false;
|
||||||
|
static void _noEffect() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/// SRS-209 listen-only banner for Android RECORD_AUDIO permission.
|
||||||
|
///
|
||||||
|
/// Trace:
|
||||||
|
/// - SDD-106 §2 (denial UX — non-blocking affordance "Enable
|
||||||
|
/// microphone"), §3 (permanent denial — "Open settings" deep-link).
|
||||||
|
/// - SRS-209 (path to grant; listen-only fallback).
|
||||||
|
///
|
||||||
|
/// Behaviour:
|
||||||
|
/// * Watches [AndroidPermissionsService.recordAudioState].
|
||||||
|
/// * On `denied`: renders a non-modal banner with a "Grant" action.
|
||||||
|
/// * On `permanentlyDenied`: action text becomes "Open Settings" and
|
||||||
|
/// invokes [AndroidPermissionsService.openAppSettings].
|
||||||
|
/// * On `granted` / `unknown`: builds an empty [SizedBox.shrink].
|
||||||
|
/// * On non-Android hosts the service stays at `granted`, so this
|
||||||
|
/// widget is effectively invisible without any extra branching.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../services/android_permissions_service.dart';
|
||||||
|
|
||||||
|
/// Listen-only banner widget. Drop this above the `VoiceBar` in the
|
||||||
|
/// app shell; it self-hides when no action is required.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §2, §3; SRS-209.
|
||||||
|
class PermissionStateBanner extends StatelessWidget {
|
||||||
|
const PermissionStateBanner({super.key, required this.service});
|
||||||
|
|
||||||
|
/// Permissions service whose [AndroidPermissionsService.recordAudioState]
|
||||||
|
/// drives the banner.
|
||||||
|
final AndroidPermissionsService service;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ValueListenableBuilder<AndroidRecordAudioPermissionState>(
|
||||||
|
valueListenable: service.recordAudioState,
|
||||||
|
builder: (ctx, state, _) {
|
||||||
|
switch (state) {
|
||||||
|
case AndroidRecordAudioPermissionState.granted:
|
||||||
|
case AndroidRecordAudioPermissionState.unknown:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
case AndroidRecordAudioPermissionState.denied:
|
||||||
|
return _BannerBody(
|
||||||
|
// TODO(localization): route through AppL10n once an arb
|
||||||
|
// entry exists. SRS-209 requires the message; the
|
||||||
|
// English literal is a placeholder.
|
||||||
|
message:
|
||||||
|
'Microphone permission required for voice transmission.',
|
||||||
|
actionLabel: 'Grant',
|
||||||
|
onPressed: () => service.ensureRecordAudio(),
|
||||||
|
);
|
||||||
|
case AndroidRecordAudioPermissionState.permanentlyDenied:
|
||||||
|
return _BannerBody(
|
||||||
|
// TODO(localization): see above.
|
||||||
|
message:
|
||||||
|
'Microphone permission required for voice transmission.',
|
||||||
|
actionLabel: 'Open Settings',
|
||||||
|
onPressed: () => service.openAppSettings(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BannerBody extends StatelessWidget {
|
||||||
|
const _BannerBody({
|
||||||
|
required this.message,
|
||||||
|
required this.actionLabel,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final String actionLabel;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Material(
|
||||||
|
color: theme.colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.mic_off, color: theme.colorScheme.onErrorContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(color: theme.colorScheme.onErrorContainer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
child: Text(actionLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user