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
@@ -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() {}
}