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,
|
||||
|
||||
@@ -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