From c4145a8727a2b6d63e4a318dfd7f467a096d7fe7 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Mon, 18 May 2026 09:52:12 +0800 Subject: [PATCH] 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. --- apps/chanora_flutter/lib/main.dart | 69 ++++ .../services/android_permissions_service.dart | 297 ++++++++++++++++++ .../lib/services/back_intent_policy.dart | 66 ++++ .../lib/services/back_intent_service.dart | 162 ++++++++++ .../lib/widgets/permission_state_banner.dart | 108 +++++++ 5 files changed, 702 insertions(+) create mode 100644 apps/chanora_flutter/lib/services/android_permissions_service.dart create mode 100644 apps/chanora_flutter/lib/services/back_intent_policy.dart create mode 100644 apps/chanora_flutter/lib/services/back_intent_service.dart create mode 100644 apps/chanora_flutter/lib/widgets/permission_state_banner.dart diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 51ac6ba..67a575e 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -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 _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, diff --git a/apps/chanora_flutter/lib/services/android_permissions_service.dart b/apps/chanora_flutter/lib/services/android_permissions_service.dart new file mode 100644 index 0000000..7311904 --- /dev/null +++ b/apps/chanora_flutter/lib/services/android_permissions_service.dart @@ -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 _state = + ValueNotifier( + // 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 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 _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 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(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(); + 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 openAppSettings() async { + final ch = _channel; + if (ch == null) return; + try { + await ch.invokeMethod(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(); + } +} diff --git a/apps/chanora_flutter/lib/services/back_intent_policy.dart b/apps/chanora_flutter/lib/services/back_intent_policy.dart new file mode 100644 index 0000000..2ec29e7 --- /dev/null +++ b/apps/chanora_flutter/lib/services/back_intent_policy.dart @@ -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(); +} diff --git a/apps/chanora_flutter/lib/services/back_intent_service.dart b/apps/chanora_flutter/lib/services/back_intent_service.dart new file mode 100644 index 0000000..6a63484 --- /dev/null +++ b/apps/chanora_flutter/lib/services/back_intent_service.dart @@ -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 _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(backIntentMethodPopToSystem); + return; + } + } + + static bool _alwaysFalse() => false; + static void _noEffect() {} +} diff --git a/apps/chanora_flutter/lib/widgets/permission_state_banner.dart b/apps/chanora_flutter/lib/widgets/permission_state_banner.dart new file mode 100644 index 0000000..d1bd6e1 --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/permission_state_banner.dart @@ -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( + 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), + ), + ], + ), + ), + ); + } +}