Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0060f3c19 | ||
|
|
8cb5a7a258 | ||
|
|
484dad1072 |
Generated
+22
@@ -423,6 +423,8 @@ dependencies = [
|
|||||||
"coreaudio-rs",
|
"coreaudio-rs",
|
||||||
"cpal",
|
"cpal",
|
||||||
"criterion",
|
"criterion",
|
||||||
|
"crossbeam",
|
||||||
|
"crossbeam-utils",
|
||||||
"dhat",
|
"dhat",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -803,6 +805,17 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-epoch",
|
||||||
|
"crossbeam-queue",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-channel"
|
name = "crossbeam-channel"
|
||||||
version = "0.5.15"
|
version = "0.5.15"
|
||||||
@@ -831,6 +844,15 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-queue"
|
||||||
|
version = "0.3.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.21"
|
version = "0.8.21"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import 'services/audio_lifecycle_service.dart';
|
|||||||
import 'services/channel_join_error_mapper.dart';
|
import 'services/channel_join_error_mapper.dart';
|
||||||
import 'services/connection_phase_state.dart';
|
import 'services/connection_phase_state.dart';
|
||||||
import 'services/ios_permissions_service.dart';
|
import 'services/ios_permissions_service.dart';
|
||||||
|
import 'services/macos_permissions_service.dart';
|
||||||
import 'services/prefetch_debouncer.dart';
|
import 'services/prefetch_debouncer.dart';
|
||||||
import 'services/snapshot_state_mapper.dart';
|
import 'services/snapshot_state_mapper.dart';
|
||||||
import 'services/ts3_server_link.dart';
|
import 'services/ts3_server_link.dart';
|
||||||
@@ -378,6 +379,12 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
final AndroidPermissionsService _androidPermissions =
|
final AndroidPermissionsService _androidPermissions =
|
||||||
AndroidPermissionsService();
|
AndroidPermissionsService();
|
||||||
final IosPermissionsService _iosPermissions = IosPermissionsService();
|
final IosPermissionsService _iosPermissions = IosPermissionsService();
|
||||||
|
// SRS-198 / SRS-297 / SRS-300: macOS Input Monitoring, Local Network,
|
||||||
|
// and Notifications permission service. On non-macOS hosts the service
|
||||||
|
// short-circuits to "granted" / "unsupported" and never wires the
|
||||||
|
// MethodChannel.
|
||||||
|
final MacOSPermissionsService _macOSPermissions =
|
||||||
|
MacOSPermissionsService();
|
||||||
final UiPreferencesService _uiPreferences = const UiPreferencesService();
|
final UiPreferencesService _uiPreferences = const UiPreferencesService();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -399,6 +406,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_iosPermissions.recordAudioState.addListener(
|
_iosPermissions.recordAudioState.addListener(
|
||||||
_onRecordAudioPermissionChanged,
|
_onRecordAudioPermissionChanged,
|
||||||
);
|
);
|
||||||
|
// SRS-198 / SRS-297 / SRS-300: start macOS permission service.
|
||||||
|
// On non-macOS this is a no-op. On macOS, it checks Input
|
||||||
|
// Monitoring state and begins polling for changes so the PTT
|
||||||
|
// capability badge upgrades from L0Focused → L1MacOSEventTap
|
||||||
|
// when the user grants the permission in System Settings.
|
||||||
|
_macOSPermissions.start();
|
||||||
|
_macOSPermissions.checkInitialStates();
|
||||||
|
_macOSPermissions.pttCapabilityState.addListener(
|
||||||
|
_onMacOSPttCapabilityChanged,
|
||||||
|
);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
unawaited(_requestRecordAudioOnStartup());
|
unawaited(_requestRecordAudioOnStartup());
|
||||||
});
|
});
|
||||||
@@ -498,6 +515,26 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SRS-198 / SRS-297 / SRS-300 / SDD-091: React to macOS Input
|
||||||
|
/// Monitoring state changes by updating the PTT capability level.
|
||||||
|
/// When the macOS permissions service detects that Input Monitoring
|
||||||
|
/// has been granted (via polling), it emits `L1MacOSEventTap` which
|
||||||
|
/// overrides the bridge-emitted `L0Focused` default.
|
||||||
|
void _onMacOSPttCapabilityChanged() {
|
||||||
|
final level = _macOSPermissions.pttCapabilityState.value;
|
||||||
|
// Only override if the macOS service has a resolved state
|
||||||
|
// different from the current bridge-emitted level, and only
|
||||||
|
// on macOS.
|
||||||
|
if (_isMacOS && level != _pttLevel) {
|
||||||
|
setState(() {
|
||||||
|
_pttLevel = level;
|
||||||
|
if (level == 'L1MacOSEventTap') {
|
||||||
|
_pttBackendId = 'macos-event-tap';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _clearPermissionHardMute() async {
|
Future<void> _clearPermissionHardMute() async {
|
||||||
if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return;
|
if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return;
|
||||||
_permissionHardMuteClearInFlight = true;
|
_permissionHardMuteClearInFlight = true;
|
||||||
@@ -936,11 +973,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_iosPermissions.recordAudioState.removeListener(
|
_iosPermissions.recordAudioState.removeListener(
|
||||||
_onRecordAudioPermissionChanged,
|
_onRecordAudioPermissionChanged,
|
||||||
);
|
);
|
||||||
|
// SRS-198 / SRS-297: detach macOS permission listeners.
|
||||||
|
_macOSPermissions.pttCapabilityState.removeListener(
|
||||||
|
_onMacOSPttCapabilityChanged,
|
||||||
|
);
|
||||||
// SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
|
// SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
|
||||||
// late invokeMethod from the platform side cannot land on this
|
// late invokeMethod from the platform side cannot land on this
|
||||||
// disposed state.
|
// disposed state.
|
||||||
_androidPermissions.stop();
|
_androidPermissions.stop();
|
||||||
_iosPermissions.stop();
|
_iosPermissions.stop();
|
||||||
|
_macOSPermissions.stop();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1084,14 +1126,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
Future<void> _toggleOutputMute() async {
|
Future<void> _toggleOutputMute() async {
|
||||||
final next = !_outputMuted;
|
final next = !_outputMuted;
|
||||||
|
// Optimistic: flip the UI immediately.
|
||||||
|
setState(() {
|
||||||
|
_outputMuted = next;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await rust.setOutputMuted(muted: next);
|
await rust.setOutputMuted(muted: next);
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_outputMuted = next;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// Roll back on failure.
|
||||||
|
setState(() {
|
||||||
|
_outputMuted = !next;
|
||||||
|
});
|
||||||
_showUiError('output mute', e);
|
_showUiError('output mute', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1218,6 +1264,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final next = !_hardMute;
|
final next = !_hardMute;
|
||||||
|
// Optimistic: flip the UI immediately so the icon responds
|
||||||
|
// before the two bridge calls round-trip through FFI.
|
||||||
|
setState(() {
|
||||||
|
_inputMuted = next;
|
||||||
|
_hardMute = next;
|
||||||
|
_hardMuteByPermission = false;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
// Hard-mute is two coordinated effects:
|
// Hard-mute is two coordinated effects:
|
||||||
// * setHardMute — local TransmitGate clamp; we stop sending
|
// * setHardMute — local TransmitGate clamp; we stop sending
|
||||||
@@ -1230,14 +1283,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
// through. Drive them together.
|
// through. Drive them together.
|
||||||
await rust.setHardMute(muted: next);
|
await rust.setHardMute(muted: next);
|
||||||
await rust.setInputMuted(muted: next);
|
await rust.setInputMuted(muted: next);
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_inputMuted = next;
|
|
||||||
_hardMute = next;
|
|
||||||
_hardMuteByPermission = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// Roll back on failure.
|
||||||
|
setState(() {
|
||||||
|
_inputMuted = !next;
|
||||||
|
_hardMute = !next;
|
||||||
|
});
|
||||||
_showUiError('hard mute', e);
|
_showUiError('hard mute', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2103,26 +2155,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
final headerActions = [
|
final headerActions = [
|
||||||
if (_serverReachable && _inChannel) ...[
|
|
||||||
IconButton(
|
|
||||||
tooltip: _hardMuteByTalkPower
|
|
||||||
? 'Insufficient talk power to speak in this channel'
|
|
||||||
: l10n.voiceHardMuteLabel,
|
|
||||||
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
|
|
||||||
isSelected: _hardMute,
|
|
||||||
selectedIcon: const Icon(Icons.mic_off),
|
|
||||||
color: _hardMute ? theme.colorScheme.error : null,
|
|
||||||
onPressed: _hardMuteByTalkPower ? null : _onToggleHardMute,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: l10n.voiceOutputMuteLabel,
|
|
||||||
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
|
|
||||||
isSelected: _outputMuted,
|
|
||||||
selectedIcon: const Icon(Icons.headset_off),
|
|
||||||
color: _outputMuted ? theme.colorScheme.error : null,
|
|
||||||
onPressed: _toggleOutputMute,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.aboutAction,
|
tooltip: l10n.aboutAction,
|
||||||
icon: const Icon(Icons.info_outline),
|
icon: const Icon(Icons.info_outline),
|
||||||
@@ -2407,7 +2439,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
Expanded(child: snapshotView),
|
Expanded(child: snapshotView),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
permissionBanner,
|
permissionBanner,
|
||||||
VoiceStatusChip(
|
CompactVoiceBar(
|
||||||
|
inChannel: _inChannel,
|
||||||
transmitMode: _transmitMode,
|
transmitMode: _transmitMode,
|
||||||
releaseTailMs: _releaseTailMs,
|
releaseTailMs: _releaseTailMs,
|
||||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||||
@@ -2415,19 +2448,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
isTouchOnly: isTouchOnlyPttHost,
|
isTouchOnly: isTouchOnlyPttHost,
|
||||||
inputMuted: _hardMute,
|
inputMuted: _hardMute,
|
||||||
outputMuted: _outputMuted,
|
outputMuted: _outputMuted,
|
||||||
|
onToggleInputMute: _onToggleHardMute,
|
||||||
|
onToggleOutputMute: _toggleOutputMute,
|
||||||
|
onOpenDetails: () => _onOpenVoiceDetailsSheet(),
|
||||||
|
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
||||||
talkPower: ownClientState?.talkPower,
|
talkPower: ownClientState?.talkPower,
|
||||||
neededTalkPower: ownClientState?.neededTalkPower,
|
neededTalkPower: ownClientState?.neededTalkPower,
|
||||||
talkPowerGranted: ownClientState?.talkPowerGranted,
|
talkPowerGranted: ownClientState?.talkPowerGranted,
|
||||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
hardMuteByTalkPower: _hardMuteByTalkPower,
|
||||||
),
|
),
|
||||||
if (_inChannel &&
|
|
||||||
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
VoicePttButton(
|
|
||||||
active: _audioStats?.pttActive ?? false,
|
|
||||||
onHeldChanged: _onOnscreenPttHeldChanged,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
/// macOS permission integration for Input Monitoring, Local Network,
|
||||||
|
/// and Notifications.
|
||||||
|
///
|
||||||
|
/// Trace:
|
||||||
|
/// - SRS-198 (Push-to-talk system permission acquisition).
|
||||||
|
/// - SRS-297 / SRS-300 (Input Monitoring for global PTT on macOS).
|
||||||
|
/// - SysRS-166 (Desktop notifications).
|
||||||
|
/// - SDD-091 (PTT capability badge — live capability level).
|
||||||
|
///
|
||||||
|
/// Responsibilities:
|
||||||
|
/// * Subscribe to the Swift-side `MethodChannel`
|
||||||
|
/// `app.chanora/macos_permissions` for inbound state-change
|
||||||
|
/// invocations emitted by the native handler in
|
||||||
|
/// `MainFlutterWindow.swift` (Swift → Dart).
|
||||||
|
/// * Provide imperative Dart → Swift entry points for checking and
|
||||||
|
/// requesting Input Monitoring, triggering the Local Network
|
||||||
|
/// prompt, and requesting notification authorization.
|
||||||
|
/// * Expose the latest resolved states as [ValueListenable] so UI
|
||||||
|
/// surfaces (PTT capability badge, permission banners, connection
|
||||||
|
/// error messages) can react without polling.
|
||||||
|
///
|
||||||
|
/// ## Non-macOS short-circuit
|
||||||
|
///
|
||||||
|
/// On Android / iOS / Linux / Windows / web, none of these macOS-
|
||||||
|
/// specific permissions exist. The MethodChannel is therefore never
|
||||||
|
/// constructed off-macOS. Each state listenable stays at its default
|
||||||
|
/// "granted" / "not needed" value so all consumers become no-ops.
|
||||||
|
///
|
||||||
|
/// ## No global statics
|
||||||
|
///
|
||||||
|
/// Following the pattern established by `AndroidPermissionsService`
|
||||||
|
/// (SDD-106) and `BackIntentService` (SDD-028), this class is
|
||||||
|
/// constructor-injected. The host app instantiates one instance at
|
||||||
|
/// startup and passes it through the widget tree.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Channel constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// MethodChannel name shared with Swift `MacOSPermissionsHandler`.
|
||||||
|
@visibleForTesting
|
||||||
|
const String macOSPermissionsChannelName =
|
||||||
|
'app.chanora/macos_permissions';
|
||||||
|
|
||||||
|
// Outbound (Dart → Swift) method names.
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodCheckInputMonitoring = 'checkInputMonitoring';
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodRequestInputMonitoring = 'requestInputMonitoring';
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodOpenInputMonitoringSettings =
|
||||||
|
'openInputMonitoringSettings';
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodTriggerLocalNetworkPrompt = 'triggerLocalNetworkPrompt';
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodCheckLocalNetwork = 'checkLocalNetwork';
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodRequestNotifications = 'requestNotifications';
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodCheckNotifications = 'checkNotifications';
|
||||||
|
|
||||||
|
// Inbound (Swift → Dart) method names.
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodInputMonitoringStateChanged =
|
||||||
|
'inputMonitoringStateChanged';
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodLocalNetworkStateChanged = 'localNetworkStateChanged';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Enums
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Discrete states for macOS-specific permissions.
|
||||||
|
enum MacOSPermissionState {
|
||||||
|
/// Permission granted.
|
||||||
|
granted,
|
||||||
|
|
||||||
|
/// Permission denied by the user.
|
||||||
|
denied,
|
||||||
|
|
||||||
|
/// Permission has not yet been determined (first launch before
|
||||||
|
/// any prompt, or the system returned an unexpected value).
|
||||||
|
notDetermined,
|
||||||
|
|
||||||
|
/// No resolved state yet (cold launch before the first emission,
|
||||||
|
/// or non-macOS host before short-circuit). Consumers treat this
|
||||||
|
/// as "not yet known".
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local Network permission states, extended to cover macOS 14 and
|
||||||
|
/// earlier where Local Network Privacy does not exist.
|
||||||
|
enum MacOSLocalNetworkState {
|
||||||
|
/// Permission granted or the Local Network prompt was satisfied.
|
||||||
|
granted,
|
||||||
|
|
||||||
|
/// Permission explicitly denied by the user (macOS 15+ only).
|
||||||
|
denied,
|
||||||
|
|
||||||
|
/// No prompt shown yet.
|
||||||
|
notDetermined,
|
||||||
|
|
||||||
|
/// Running on macOS 14 or earlier where Local Network Privacy
|
||||||
|
/// does not apply. Consumers treat this as "granted".
|
||||||
|
unsupported,
|
||||||
|
|
||||||
|
/// No resolved state yet.
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State parsing helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MacOSPermissionState _parsePermissionState(String? raw) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'Granted':
|
||||||
|
return MacOSPermissionState.granted;
|
||||||
|
case 'Denied':
|
||||||
|
return MacOSPermissionState.denied;
|
||||||
|
case 'NotDetermined':
|
||||||
|
return MacOSPermissionState.notDetermined;
|
||||||
|
default:
|
||||||
|
return MacOSPermissionState.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MacOSLocalNetworkState _parseLocalNetworkState(String? raw) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'Granted':
|
||||||
|
return MacOSLocalNetworkState.granted;
|
||||||
|
case 'Denied':
|
||||||
|
return MacOSLocalNetworkState.denied;
|
||||||
|
case 'NotDetermined':
|
||||||
|
return MacOSLocalNetworkState.notDetermined;
|
||||||
|
case 'Unsupported':
|
||||||
|
return MacOSLocalNetworkState.unsupported;
|
||||||
|
default:
|
||||||
|
return MacOSLocalNetworkState.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PTT capability level mapping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Maps the Input Monitoring state to the PTT capability level string
|
||||||
|
/// consumed by [PttCapabilityBadge].
|
||||||
|
///
|
||||||
|
/// Trace: SDD-091 (capability badge); desktop-ptt-architecture.md
|
||||||
|
/// (macOS Event Tap strategy).
|
||||||
|
String _pttCapabilityLevel(MacOSPermissionState inputMonitoring) {
|
||||||
|
switch (inputMonitoring) {
|
||||||
|
case MacOSPermissionState.granted:
|
||||||
|
return 'L1MacOSEventTap';
|
||||||
|
case MacOSPermissionState.denied:
|
||||||
|
case MacOSPermissionState.notDetermined:
|
||||||
|
case MacOSPermissionState.unknown:
|
||||||
|
return 'L0Focused';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MacOSPermissionsService
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Dart-side integration for macOS permission state.
|
||||||
|
///
|
||||||
|
/// Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091.
|
||||||
|
class MacOSPermissionsService {
|
||||||
|
/// Construct a service bound to [channel]. Injected for testability;
|
||||||
|
/// production code uses the default channel keyed on
|
||||||
|
/// [macOSPermissionsChannelName].
|
||||||
|
MacOSPermissionsService({MethodChannel? channel})
|
||||||
|
: _channel = channel ??
|
||||||
|
(_isMacOS
|
||||||
|
? const MethodChannel(macOSPermissionsChannelName)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
/// Platform-detection seam. Web counts as non-macOS.
|
||||||
|
static bool get _isMacOS {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
return Platform.isMacOS;
|
||||||
|
}
|
||||||
|
|
||||||
|
final MethodChannel? _channel;
|
||||||
|
bool _started = false;
|
||||||
|
|
||||||
|
// -- Input Monitoring -----------------------------------------------------
|
||||||
|
|
||||||
|
final ValueNotifier<MacOSPermissionState> _inputMonitoringState =
|
||||||
|
ValueNotifier<MacOSPermissionState>(
|
||||||
|
// Non-macOS: granted so consumers are no-ops.
|
||||||
|
_isMacOS
|
||||||
|
? MacOSPermissionState.unknown
|
||||||
|
: MacOSPermissionState.granted,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Latest known Input Monitoring permission state.
|
||||||
|
///
|
||||||
|
/// On macOS this drives the PTT capability level: `granted` →
|
||||||
|
/// `L1MacOSEventTap` (global PTT via Event Tap); anything else →
|
||||||
|
/// `L0Focused` (focused-only PTT).
|
||||||
|
ValueListenable<MacOSPermissionState> get inputMonitoringState =>
|
||||||
|
_inputMonitoringState;
|
||||||
|
|
||||||
|
// -- Local Network --------------------------------------------------------
|
||||||
|
|
||||||
|
final ValueNotifier<MacOSLocalNetworkState> _localNetworkState =
|
||||||
|
ValueNotifier<MacOSLocalNetworkState>(
|
||||||
|
_isMacOS
|
||||||
|
? MacOSLocalNetworkState.unknown
|
||||||
|
: MacOSLocalNetworkState.unsupported,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Latest known Local Network permission state.
|
||||||
|
///
|
||||||
|
/// On macOS 15+ (Sequoia) this reflects the Local Network Privacy
|
||||||
|
/// TCC permission. On macOS 14 and earlier, the value is
|
||||||
|
/// [MacOSLocalNetworkState.unsupported] (no prompt needed).
|
||||||
|
ValueListenable<MacOSLocalNetworkState> get localNetworkState =>
|
||||||
|
_localNetworkState;
|
||||||
|
|
||||||
|
// -- Notifications --------------------------------------------------------
|
||||||
|
|
||||||
|
final ValueNotifier<MacOSPermissionState> _notificationState =
|
||||||
|
ValueNotifier<MacOSPermissionState>(
|
||||||
|
_isMacOS
|
||||||
|
? MacOSPermissionState.unknown
|
||||||
|
: MacOSPermissionState.granted,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Latest known notification authorization state.
|
||||||
|
ValueListenable<MacOSPermissionState> get notificationState =>
|
||||||
|
_notificationState;
|
||||||
|
|
||||||
|
// -- PTT capability (derived) ---------------------------------------------
|
||||||
|
|
||||||
|
final ValueNotifier<String> _pttCapabilityState =
|
||||||
|
ValueNotifier<String>(
|
||||||
|
_pttCapabilityLevel(
|
||||||
|
_isMacOS ? MacOSPermissionState.unknown : MacOSPermissionState.granted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Derived PTT capability level string, ready for consumption by
|
||||||
|
/// [PttCapabilityBadge]. Updates automatically when Input Monitoring
|
||||||
|
/// state changes.
|
||||||
|
///
|
||||||
|
/// Returns `"L1MacOSEventTap"` when Input Monitoring is granted,
|
||||||
|
/// `"L0Focused"` otherwise.
|
||||||
|
ValueListenable<String> get pttCapabilityState => _pttCapabilityState;
|
||||||
|
|
||||||
|
// -- Lifecycle ------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Start listening for state updates from Swift. Idempotent.
|
||||||
|
///
|
||||||
|
/// On non-macOS this is a no-op.
|
||||||
|
///
|
||||||
|
/// Note: this only registers the inbound handler. Call
|
||||||
|
/// [checkInitialStates] afterward to eagerly query the current
|
||||||
|
/// permission states from the native side.
|
||||||
|
void start() {
|
||||||
|
if (_started) return;
|
||||||
|
_started = true;
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
ch.setMethodCallHandler(_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eagerly query the current permission states from the native
|
||||||
|
/// side. Call this after [start] so the PTT capability badge
|
||||||
|
/// shows the correct level on the first frame.
|
||||||
|
///
|
||||||
|
/// On non-macOS this is a no-op.
|
||||||
|
void checkInitialStates() {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
unawaited(_checkInputMonitoring());
|
||||||
|
unawaited(_checkLocalNetwork());
|
||||||
|
unawaited(_checkNotifications());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop listening. Idempotent.
|
||||||
|
void stop() {
|
||||||
|
if (!_started) return;
|
||||||
|
_started = false;
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
ch.setMethodCallHandler(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Inbound handler (Swift → Dart) --------------------------------------
|
||||||
|
|
||||||
|
Future<dynamic> _handle(MethodCall call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case methodInputMonitoringStateChanged:
|
||||||
|
final args = call.arguments;
|
||||||
|
if (args is Map) {
|
||||||
|
final state = _parsePermissionState(args['state'] as String?);
|
||||||
|
_inputMonitoringState.value = state;
|
||||||
|
_pttCapabilityState.value = _pttCapabilityLevel(state);
|
||||||
|
}
|
||||||
|
case methodLocalNetworkStateChanged:
|
||||||
|
final args = call.arguments;
|
||||||
|
if (args is Map) {
|
||||||
|
_localNetworkState.value =
|
||||||
|
_parseLocalNetworkState(args['state'] as String?);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Outbound: Input Monitoring -------------------------------------------
|
||||||
|
|
||||||
|
/// Check the current Input Monitoring permission state without
|
||||||
|
/// triggering a system prompt.
|
||||||
|
Future<MacOSPermissionState> _checkInputMonitoring() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return MacOSPermissionState.granted;
|
||||||
|
try {
|
||||||
|
final raw =
|
||||||
|
await ch.invokeMethod<String>(methodCheckInputMonitoring);
|
||||||
|
final state = _parsePermissionState(raw);
|
||||||
|
_inputMonitoringState.value = state;
|
||||||
|
_pttCapabilityState.value = _pttCapabilityLevel(state);
|
||||||
|
return state;
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'checkInputMonitoring failed',
|
||||||
|
name: 'MacOSPermissionsService',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
return _inputMonitoringState.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request Input Monitoring permission. On macOS this calls
|
||||||
|
/// `CGRequestListenEventAccess()` which shows a system dialog
|
||||||
|
/// or opens System Settings (depending on macOS version).
|
||||||
|
///
|
||||||
|
/// Returns the new state after the request.
|
||||||
|
Future<MacOSPermissionState> requestInputMonitoring() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return MacOSPermissionState.granted;
|
||||||
|
try {
|
||||||
|
final raw = await ch.invokeMethod<String>(
|
||||||
|
methodRequestInputMonitoring,
|
||||||
|
);
|
||||||
|
final state = _parsePermissionState(raw);
|
||||||
|
_inputMonitoringState.value = state;
|
||||||
|
_pttCapabilityState.value = _pttCapabilityLevel(state);
|
||||||
|
return state;
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'requestInputMonitoring failed',
|
||||||
|
name: 'MacOSPermissionsService',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
return _inputMonitoringState.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open System Settings → Privacy & Security → Input Monitoring
|
||||||
|
/// so the user can manually grant the permission for the
|
||||||
|
/// permanently-denied case (TCC drag-based permission cannot be
|
||||||
|
/// programmatically granted).
|
||||||
|
Future<void> openInputMonitoringSettings() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
try {
|
||||||
|
await ch.invokeMethod<void>(methodOpenInputMonitoringSettings);
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'openInputMonitoringSettings failed',
|
||||||
|
name: 'MacOSPermissionsService',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Outbound: Local Network ----------------------------------------------
|
||||||
|
|
||||||
|
Future<MacOSLocalNetworkState> _checkLocalNetwork() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return MacOSLocalNetworkState.unsupported;
|
||||||
|
try {
|
||||||
|
final raw = await ch.invokeMethod<String>(methodCheckLocalNetwork);
|
||||||
|
final state = _parseLocalNetworkState(raw);
|
||||||
|
_localNetworkState.value = state;
|
||||||
|
return state;
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'checkLocalNetwork failed',
|
||||||
|
name: 'MacOSPermissionsService',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
return _localNetworkState.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger the Local Network permission prompt by starting a
|
||||||
|
/// brief `NWBrowser` scan for `_ts3._tcp`. On macOS 15+ this
|
||||||
|
/// shows the system Local Network Privacy dialog. On earlier
|
||||||
|
/// versions, this is a no-op (returns `unsupported`).
|
||||||
|
Future<MacOSLocalNetworkState> triggerLocalNetworkPrompt() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return MacOSLocalNetworkState.unsupported;
|
||||||
|
try {
|
||||||
|
final raw = await ch.invokeMethod<String>(
|
||||||
|
methodTriggerLocalNetworkPrompt,
|
||||||
|
);
|
||||||
|
final state = _parseLocalNetworkState(raw);
|
||||||
|
_localNetworkState.value = state;
|
||||||
|
return state;
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'triggerLocalNetworkPrompt failed',
|
||||||
|
name: 'MacOSPermissionsService',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
return _localNetworkState.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Outbound: Notifications ----------------------------------------------
|
||||||
|
|
||||||
|
Future<MacOSPermissionState> _checkNotifications() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return MacOSPermissionState.granted;
|
||||||
|
try {
|
||||||
|
final raw =
|
||||||
|
await ch.invokeMethod<String>(methodCheckNotifications);
|
||||||
|
final state = _parsePermissionState(raw);
|
||||||
|
_notificationState.value = state;
|
||||||
|
return state;
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'checkNotifications failed',
|
||||||
|
name: 'MacOSPermissionsService',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
return _notificationState.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request notification authorization via `UNUserNotificationCenter`.
|
||||||
|
/// Returns the new state after the system dialog resolves.
|
||||||
|
Future<MacOSPermissionState> requestNotifications() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return MacOSPermissionState.granted;
|
||||||
|
try {
|
||||||
|
final raw = await ch.invokeMethod<String>(
|
||||||
|
methodRequestNotifications,
|
||||||
|
);
|
||||||
|
final state = _parsePermissionState(raw);
|
||||||
|
_notificationState.value = state;
|
||||||
|
return state;
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'requestNotifications failed',
|
||||||
|
name: 'MacOSPermissionsService',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
return _notificationState.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Cleanup --------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Release state notifiers. Test helper; production keeps the
|
||||||
|
/// service alive for the lifetime of the app.
|
||||||
|
@visibleForTesting
|
||||||
|
void dispose() {
|
||||||
|
stop();
|
||||||
|
_inputMonitoringState.dispose();
|
||||||
|
_localNetworkState.dispose();
|
||||||
|
_notificationState.dispose();
|
||||||
|
_pttCapabilityState.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
// Compact voice UI for narrow / mobile layouts (Plan E hybrid:
|
// Compact voice UI for narrow / mobile layouts.
|
||||||
// AppBar mutes + status chip with 2-line live readout + wide bottom-
|
|
||||||
// anchored PTT button + modal sheet for non-essential controls).
|
|
||||||
//
|
//
|
||||||
// rc.8 follow-up: the AppBar gear icon was removed; the modal sheet
|
// Two-zone voice bar pinned to the bottom:
|
||||||
// is now the **single** voice-controls surface on mobile. Mode +
|
// • Control row: status text + mute + deafen + settings chevron
|
||||||
// release-tail are surfaced inline (radio buttons + slider) inside
|
// • PTT row: full-width hold-to-talk (PTT mode only)
|
||||||
// the modal.
|
// Both share a single container whose background colour reflects
|
||||||
|
// the current voice state (normal / muted / talk-power-blocked).
|
||||||
|
//
|
||||||
|
// Gesture isolation: the control row uses tap-only InkWell /
|
||||||
|
// IconButton; the PTT row uses a raw Listener for pointer-down /
|
||||||
|
// pointer-up. Because each row is a disjoint hit-test region, a
|
||||||
|
// finger holding PTT cannot accidentally toggle mute or deafen.
|
||||||
|
|
||||||
import 'dart:async' show Timer, unawaited;
|
import 'dart:async' show Timer, unawaited;
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
@@ -539,6 +543,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge),
|
Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── Primary section (always visible) ────────────────────
|
||||||
|
|
||||||
// 1) Audio output route picker tile (mobile only).
|
// 1) Audio output route picker tile (mobile only).
|
||||||
if (showRoutePicker) ...[
|
if (showRoutePicker) ...[
|
||||||
const AudioOutputTile(),
|
const AudioOutputTile(),
|
||||||
@@ -574,7 +580,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 3) Release-tail slider (PTT only).
|
// Release-tail slider (PTT only).
|
||||||
if (isPtt) ...[
|
if (isPtt) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
@@ -624,7 +630,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// 4) Level meter + live TX/RX stats.
|
// Level meter + live TX/RX stats.
|
||||||
VoiceLevelMeter(active: levelActive),
|
VoiceLevelMeter(active: levelActive),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_StatsRow(
|
_StatsRow(
|
||||||
@@ -648,183 +654,227 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Audio processing.
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
|
||||||
'Audio processing',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
|
|
||||||
// Android HW/SW selector.
|
// ── Collapsible: Audio processing ───────────────────────
|
||||||
if (Platform.isAndroid) ...[
|
ExpansionTile(
|
||||||
const VoiceSubHeader('Processing backend'),
|
initiallyExpanded: false,
|
||||||
SegmentedButton<bool>(
|
shape: const Border(),
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
collapsedShape: const Border(),
|
||||||
segments: androidProcessingSegments,
|
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
|
||||||
selected: {_audioProcessing.preferHardware},
|
title: Text(
|
||||||
onSelectionChanged: (s) {
|
'Audio processing',
|
||||||
setState(() => _audioProcessing.preferHardware = s.first);
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_audioProcessing.preferHardware
|
|
||||||
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
|
||||||
: 'Software mode applies the full WebRTC APM stage set.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
children: [
|
||||||
|
_buildAudioProcessingSection(theme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
if (_isIos) ...[
|
// ── Collapsible: PTT capability (PTT mode only) ─────────
|
||||||
Text(
|
if (isPtt)
|
||||||
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
ExpansionTile(
|
||||||
'hidden here; only settings that still affect the shipping '
|
initiallyExpanded: false,
|
||||||
'iOS path are shown.',
|
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
shape: const Border(),
|
||||||
|
collapsedShape: const Border(),
|
||||||
|
title: Text(
|
||||||
|
'PTT capability',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
PttCapabilityBadge(
|
||||||
|
level: widget.pttLevel,
|
||||||
|
backendId: widget.pttBackendId,
|
||||||
|
boundInputClass: widget.pttBoundInputClass,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Collapsible: Debug ──────────────────────────────────
|
||||||
|
ExpansionTile(
|
||||||
|
initiallyExpanded: false,
|
||||||
|
shape: const Border(),
|
||||||
|
collapsedShape: const Border(),
|
||||||
|
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
|
||||||
|
title: Text(
|
||||||
|
'Debug',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
children: [
|
||||||
],
|
_buildDebugSection(theme),
|
||||||
if (!_isIos &&
|
],
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsNsControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Noise suppression',
|
|
||||||
subtitle: 'Wiener filter',
|
|
||||||
value: _audioProcessing.nsEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.nsEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsAecControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Echo cancellation',
|
|
||||||
subtitle: Platform.isAndroid
|
|
||||||
? (_audioProcessing.preferHardware
|
|
||||||
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
|
||||||
: 'WebRTC AEC3 · adaptive filter')
|
|
||||||
: (_isMacOS
|
|
||||||
? 'Managed by platform VPIO'
|
|
||||||
: 'WebRTC AEC3 · adaptive filter'),
|
|
||||||
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
|
||||||
onChanged: _isMacOS
|
|
||||||
? null
|
|
||||||
: (v) {
|
|
||||||
setState(() => _audioProcessing.aecEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsAgcControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Auto gain control',
|
|
||||||
subtitle: 'AGC2 · -18 dBFS target',
|
|
||||||
value: _audioProcessing.agcEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.agcEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'High-pass filter',
|
|
||||||
subtitle: '80 Hz · DC removal',
|
|
||||||
value: _audioProcessing.hpfEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.hpfEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsLimiterControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Peak limiter',
|
|
||||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
|
||||||
value: _audioProcessing.limiterEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.limiterEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// VAD backend.
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Voice activity detection (VAD)',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
|
||||||
SegmentedButton<rust.BridgeVadBackend>(
|
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
|
||||||
segments: _isDesktopSileroVadHost
|
|
||||||
? desktopVadBackendSegments
|
|
||||||
: vadBackendSegments,
|
|
||||||
selected: {_audioProcessing.vadBackend},
|
|
||||||
onSelectionChanged: (s) {
|
|
||||||
setState(() => _audioProcessing.vadBackend = s.first);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// Debug.
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Debug',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'WAV dump',
|
|
||||||
subtitle: 'Record raw/processed mic to temp dir',
|
|
||||||
value: _audioProcessing.debugWavDump,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.debugWavDump = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// 6) PTT capability badge. On iOS this must remain
|
|
||||||
// visible even though the resolved level is always
|
|
||||||
// `L0Focused`, because the P0 acceptance flow requires
|
|
||||||
// honest capability advertising with an explanation of
|
|
||||||
// the sandbox limitation.
|
|
||||||
if (isPtt) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
PttCapabilityBadge(
|
|
||||||
level: widget.pttLevel,
|
|
||||||
backendId: widget.pttBackendId,
|
|
||||||
boundInputClass: widget.pttBoundInputClass,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Audio processing section (inside ExpansionTile) ─────────────────
|
||||||
|
|
||||||
|
Widget _buildAudioProcessingSection(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Android HW/SW selector.
|
||||||
|
if (Platform.isAndroid) ...[
|
||||||
|
const VoiceSubHeader('Processing backend'),
|
||||||
|
SegmentedButton<bool>(
|
||||||
|
style: voiceSegmentedButtonStyle(theme),
|
||||||
|
segments: androidProcessingSegments,
|
||||||
|
selected: {_audioProcessing.preferHardware},
|
||||||
|
onSelectionChanged: (s) {
|
||||||
|
setState(() => _audioProcessing.preferHardware = s.first);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_audioProcessing.preferHardware
|
||||||
|
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
||||||
|
: 'Software mode applies the full WebRTC APM stage set.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (_isIos) ...[
|
||||||
|
Text(
|
||||||
|
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
||||||
|
'hidden here; only settings that still affect the shipping '
|
||||||
|
'iOS path are shown.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
if (!_isIos &&
|
||||||
|
(!Platform.isAndroid ||
|
||||||
|
androidShowsNsControl(_audioProcessing)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: true,
|
||||||
|
label: 'Noise suppression',
|
||||||
|
subtitle: 'Wiener filter',
|
||||||
|
value: _audioProcessing.nsEnabled,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _audioProcessing.nsEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!_isIos &&
|
||||||
|
(!Platform.isAndroid ||
|
||||||
|
androidShowsAecControl(_audioProcessing)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: true,
|
||||||
|
label: 'Echo cancellation',
|
||||||
|
subtitle: Platform.isAndroid
|
||||||
|
? (_audioProcessing.preferHardware
|
||||||
|
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
||||||
|
: 'WebRTC AEC3 · adaptive filter')
|
||||||
|
: (_isMacOS
|
||||||
|
? 'Managed by platform VPIO'
|
||||||
|
: 'WebRTC AEC3 · adaptive filter'),
|
||||||
|
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
||||||
|
onChanged: _isMacOS
|
||||||
|
? null
|
||||||
|
: (v) {
|
||||||
|
setState(() => _audioProcessing.aecEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!_isIos &&
|
||||||
|
(!Platform.isAndroid ||
|
||||||
|
androidShowsAgcControl(_audioProcessing)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: true,
|
||||||
|
label: 'Auto gain control',
|
||||||
|
subtitle: 'AGC2 · -18 dBFS target',
|
||||||
|
value: _audioProcessing.agcEnabled,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _audioProcessing.agcEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: true,
|
||||||
|
label: 'High-pass filter',
|
||||||
|
subtitle: '80 Hz · DC removal',
|
||||||
|
value: _audioProcessing.hpfEnabled,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _audioProcessing.hpfEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!_isIos &&
|
||||||
|
(!Platform.isAndroid ||
|
||||||
|
androidShowsLimiterControl(_audioProcessing)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: true,
|
||||||
|
label: 'Peak limiter',
|
||||||
|
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
||||||
|
value: _audioProcessing.limiterEnabled,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _audioProcessing.limiterEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// VAD backend.
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Voice activity detection (VAD)',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
SegmentedButton<rust.BridgeVadBackend>(
|
||||||
|
style: voiceSegmentedButtonStyle(theme),
|
||||||
|
segments: _isDesktopSileroVadHost
|
||||||
|
? desktopVadBackendSegments
|
||||||
|
: vadBackendSegments,
|
||||||
|
selected: {_audioProcessing.vadBackend},
|
||||||
|
onSelectionChanged: (s) {
|
||||||
|
setState(() => _audioProcessing.vadBackend = s.first);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Debug section (inside ExpansionTile) ─────────────────────────────
|
||||||
|
|
||||||
|
Widget _buildDebugSection(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: true,
|
||||||
|
label: 'WAV dump',
|
||||||
|
subtitle: 'Record raw/processed mic to temp dir',
|
||||||
|
value: _audioProcessing.debugWavDump,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _audioProcessing.debugWavDump = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Live TX/RX stats row ──────────────────────────────────────────────────
|
// ── Live TX/RX stats row ──────────────────────────────────────────────────
|
||||||
@@ -948,3 +998,424 @@ class _ModeRow extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Unified mobile voice bar ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A unified bottom-anchored voice bar for compact / mobile layouts.
|
||||||
|
///
|
||||||
|
/// Combines the former [VoiceStatusChip] and [VoicePttButton] into one
|
||||||
|
/// visual zone with two rows:
|
||||||
|
///
|
||||||
|
/// ┌──────────────────────────────────────────────┐
|
||||||
|
/// │ 🟢 PTT · Connected [🔇] [🎧] [▲] │ ← control row (tap)
|
||||||
|
/// ├──────────────────────────────────────────────┤
|
||||||
|
/// │ ════ hold to talk ════ │ ← PTT row (hold)
|
||||||
|
/// └──────────────────────────────────────────────┘
|
||||||
|
///
|
||||||
|
/// The PTT row is shown only when [transmitMode] is PTT; for continuous
|
||||||
|
/// or voice-activity modes the bar shrinks to the control row alone.
|
||||||
|
///
|
||||||
|
/// State colour is applied to the entire container:
|
||||||
|
/// - normal: `surfaceContainerHigh`
|
||||||
|
/// - muted: `errorContainer` (35 % alpha)
|
||||||
|
/// - talk-power-block: amber (18 % alpha)
|
||||||
|
class CompactVoiceBar extends StatelessWidget {
|
||||||
|
/// Construct a compact voice bar.
|
||||||
|
const CompactVoiceBar({
|
||||||
|
super.key,
|
||||||
|
required this.inChannel,
|
||||||
|
required this.transmitMode,
|
||||||
|
required this.releaseTailMs,
|
||||||
|
required this.pttBoundKeyLabel,
|
||||||
|
required this.audioStats,
|
||||||
|
required this.isTouchOnly,
|
||||||
|
required this.inputMuted,
|
||||||
|
required this.outputMuted,
|
||||||
|
required this.onToggleInputMute,
|
||||||
|
required this.onToggleOutputMute,
|
||||||
|
required this.onOpenDetails,
|
||||||
|
required this.onPttHeldChanged,
|
||||||
|
this.talkPower,
|
||||||
|
this.neededTalkPower,
|
||||||
|
this.talkPowerGranted,
|
||||||
|
this.hardMuteByTalkPower = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// True when the client is inside a channel (gates PTT row visibility).
|
||||||
|
final bool inChannel;
|
||||||
|
|
||||||
|
/// Current transmit mode.
|
||||||
|
final rust.BridgeTransmitMode transmitMode;
|
||||||
|
|
||||||
|
/// Release-tail in milliseconds.
|
||||||
|
final int releaseTailMs;
|
||||||
|
|
||||||
|
/// Bound key label (empty on touch-only hosts).
|
||||||
|
final String pttBoundKeyLabel;
|
||||||
|
|
||||||
|
/// Current audio stats; null while audio engine not running.
|
||||||
|
final rust.BridgeAudioStats? audioStats;
|
||||||
|
|
||||||
|
/// True on iOS / iPadOS / Android.
|
||||||
|
final bool isTouchOnly;
|
||||||
|
|
||||||
|
/// True when local mic is muted.
|
||||||
|
final bool inputMuted;
|
||||||
|
|
||||||
|
/// True when local speaker is muted.
|
||||||
|
final bool outputMuted;
|
||||||
|
|
||||||
|
/// Toggle hard-mute on / off.
|
||||||
|
final VoidCallback onToggleInputMute;
|
||||||
|
|
||||||
|
/// Toggle output mute on / off.
|
||||||
|
final VoidCallback onToggleOutputMute;
|
||||||
|
|
||||||
|
/// Open the voice details modal sheet.
|
||||||
|
final VoidCallback onOpenDetails;
|
||||||
|
|
||||||
|
/// Called with `true` on finger-down, `false` on finger-up / cancel.
|
||||||
|
final ValueChanged<bool> onPttHeldChanged;
|
||||||
|
|
||||||
|
/// Own client's talk power.
|
||||||
|
final int? talkPower;
|
||||||
|
|
||||||
|
/// Talk power required to speak in current channel.
|
||||||
|
final int? neededTalkPower;
|
||||||
|
|
||||||
|
/// True when server granted talk power.
|
||||||
|
final bool? talkPowerGranted;
|
||||||
|
|
||||||
|
/// True when talk power prevents speaking.
|
||||||
|
final bool hardMuteByTalkPower;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
|
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
|
||||||
|
final pttActive = audioStats?.pttActive ?? false;
|
||||||
|
|
||||||
|
final summary = voiceStatusSummary(
|
||||||
|
l10n: l10n,
|
||||||
|
transmitMode: transmitMode,
|
||||||
|
releaseTailMs: releaseTailMs,
|
||||||
|
pttBoundKeyLabel: pttBoundKeyLabel,
|
||||||
|
isTouchOnly: isTouchOnly,
|
||||||
|
inputMuted: inputMuted,
|
||||||
|
outputMuted: outputMuted,
|
||||||
|
pttActive: pttActive,
|
||||||
|
talkPower: talkPower,
|
||||||
|
neededTalkPower: neededTalkPower,
|
||||||
|
talkPowerGranted: talkPowerGranted,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Container colour based on voice state.
|
||||||
|
final containerColor = summary.talkPowerBlocked
|
||||||
|
? Colors.amber.withValues(alpha: 0.18)
|
||||||
|
: summary.muted
|
||||||
|
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
|
||||||
|
: theme.colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
|
final borderColor = summary.talkPowerBlocked
|
||||||
|
? Colors.amber.shade700
|
||||||
|
: summary.muted
|
||||||
|
? theme.colorScheme.error
|
||||||
|
: theme.colorScheme.outlineVariant;
|
||||||
|
|
||||||
|
final borderWidth = 1.0;
|
||||||
|
|
||||||
|
return Semantics(
|
||||||
|
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: containerColor,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: borderColor, width: borderWidth),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// ── Control row ──────────────────────────────────────
|
||||||
|
_ControlRow(
|
||||||
|
summary: summary,
|
||||||
|
inputMuted: inputMuted,
|
||||||
|
outputMuted: outputMuted,
|
||||||
|
hardMuteByTalkPower: hardMuteByTalkPower,
|
||||||
|
onToggleInputMute: onToggleInputMute,
|
||||||
|
onToggleOutputMute: onToggleOutputMute,
|
||||||
|
onOpenDetails: onOpenDetails,
|
||||||
|
),
|
||||||
|
// ── PTT row (in-channel + PTT mode only) ──────────────
|
||||||
|
if (isPtt && inChannel) ...[
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 0.5,
|
||||||
|
color: borderColor,
|
||||||
|
indent: 12,
|
||||||
|
endIndent: 12,
|
||||||
|
),
|
||||||
|
_PttRow(
|
||||||
|
active: pttActive,
|
||||||
|
enabled: !hardMuteByTalkPower,
|
||||||
|
onHeldChanged: onPttHeldChanged,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Control row (tap-only zone) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
class _ControlRow extends StatelessWidget {
|
||||||
|
const _ControlRow({
|
||||||
|
required this.summary,
|
||||||
|
required this.inputMuted,
|
||||||
|
required this.outputMuted,
|
||||||
|
required this.hardMuteByTalkPower,
|
||||||
|
required this.onToggleInputMute,
|
||||||
|
required this.onToggleOutputMute,
|
||||||
|
required this.onOpenDetails,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoiceStatusSummary summary;
|
||||||
|
final bool inputMuted;
|
||||||
|
final bool outputMuted;
|
||||||
|
final bool hardMuteByTalkPower;
|
||||||
|
final VoidCallback onToggleInputMute;
|
||||||
|
final VoidCallback onToggleOutputMute;
|
||||||
|
final VoidCallback onOpenDetails;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Status dot.
|
||||||
|
Icon(
|
||||||
|
summary.micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined,
|
||||||
|
size: 10,
|
||||||
|
color: summary.micOn ? theme.colorScheme.primary : theme.colorScheme.outline,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
// Status text (tappable → open details).
|
||||||
|
Expanded(
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: summary.line1,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onOpenDetails,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
summary.line1,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
summary.line2,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
|
||||||
|
// Mute button.
|
||||||
|
_ToggleButton(
|
||||||
|
icon: inputMuted ? Icons.mic_off : Icons.mic,
|
||||||
|
isActive: inputMuted,
|
||||||
|
tooltip: 'Mute mic',
|
||||||
|
onPressed: hardMuteByTalkPower ? null : onToggleInputMute,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Deafen button.
|
||||||
|
_ToggleButton(
|
||||||
|
icon: outputMuted ? Icons.headset_off : Icons.headset,
|
||||||
|
isActive: outputMuted,
|
||||||
|
tooltip: 'Deafen',
|
||||||
|
onPressed: onToggleOutputMute,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Settings / expand chevron.
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.expand_less, size: 20, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
tooltip: 'Voice settings',
|
||||||
|
onPressed: onOpenDetails,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle button (mute / deafen) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class _ToggleButton extends StatelessWidget {
|
||||||
|
const _ToggleButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.isActive,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final bool isActive;
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(icon, size: 22),
|
||||||
|
tooltip: tooltip,
|
||||||
|
color: isActive ? theme.colorScheme.error : null,
|
||||||
|
onPressed: onPressed,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PTT row (hold-only zone) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _PttRow extends StatefulWidget {
|
||||||
|
const _PttRow({
|
||||||
|
required this.active,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onHeldChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// True while the engine reports the gate open.
|
||||||
|
final bool active;
|
||||||
|
|
||||||
|
/// Whether the PTT button can be engaged.
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
/// Called with `true` on pointer-down, `false` on pointer-up / cancel.
|
||||||
|
final ValueChanged<bool> onHeldChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PttRow> createState() => _PttRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PttRowState extends State<_PttRow> {
|
||||||
|
int? _activePointer;
|
||||||
|
bool _held = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
prepareVoiceHaptics();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _begin(PointerDownEvent event) {
|
||||||
|
if (!widget.enabled || _activePointer != null) return;
|
||||||
|
_activePointer = event.pointer;
|
||||||
|
_held = true;
|
||||||
|
widget.onHeldChanged(true);
|
||||||
|
playVoicePttHaptic(true);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _end(int pointer) {
|
||||||
|
if (_activePointer != pointer) return;
|
||||||
|
_activePointer = null;
|
||||||
|
_held = false;
|
||||||
|
widget.onHeldChanged(false);
|
||||||
|
playVoicePttHaptic(false);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
|
final activeNow = _held || widget.active;
|
||||||
|
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
liveRegion: true,
|
||||||
|
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
|
||||||
|
hint: l10n.pttHoldToTalkSemanticsHint,
|
||||||
|
child: Listener(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onPointerDown: _begin,
|
||||||
|
onPointerUp: (e) => _end(e.pointer),
|
||||||
|
onPointerCancel: (e) => _end(e.pointer),
|
||||||
|
child: ExcludeSemantics(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: activeNow
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
activeNow ? Icons.mic : Icons.mic_none_outlined,
|
||||||
|
size: 22,
|
||||||
|
color: activeNow
|
||||||
|
? theme.colorScheme.onPrimary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
color: activeNow
|
||||||
|
? theme.colorScheme.onPrimary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,5 +40,9 @@
|
|||||||
<string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string>
|
<string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_ts3._tcp</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,5 +1,291 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
|
import CoreGraphics
|
||||||
|
import Network
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MacOSPermissionsHandler
|
||||||
|
//
|
||||||
|
// Native-side MethodChannel handler for macOS-specific permissions
|
||||||
|
// that the Flutter `permission_handler` plugin does not cover:
|
||||||
|
//
|
||||||
|
// • Input Monitoring (CGPreflightListenEventAccess /
|
||||||
|
// CGRequestListenEventAccess) for global PTT.
|
||||||
|
// • Local Network (NWBrowser trigger for _ts3._tcp) so macOS 15+
|
||||||
|
// shows the Local Network Privacy prompt.
|
||||||
|
// • Notifications (UNUserNotificationCenter authorization).
|
||||||
|
//
|
||||||
|
// Channel name: `app.chanora/macos_permissions`
|
||||||
|
//
|
||||||
|
// Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Polling interval for Input Monitoring state changes.
|
||||||
|
/// TCC does not emit a callback when the user toggles Input Monitoring
|
||||||
|
/// in System Settings, so we poll at a reasonable cadence.
|
||||||
|
private let kInputMonitoringPollInterval: TimeInterval = 2.0
|
||||||
|
|
||||||
|
class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
||||||
|
private var channel: FlutterMethodChannel?
|
||||||
|
private var inputMonitoringTimer: Timer?
|
||||||
|
private var lastInputMonitoringState: String = "Unknown"
|
||||||
|
|
||||||
|
// -- FlutterPlugin -------------------------------------------------------
|
||||||
|
|
||||||
|
static func register(with registrar: FlutterPluginRegistrar) {
|
||||||
|
let channel = FlutterMethodChannel(
|
||||||
|
name: "app.chanora/macos_permissions",
|
||||||
|
binaryMessenger: registrar.messenger
|
||||||
|
)
|
||||||
|
let instance = MacOSPermissionsHandler()
|
||||||
|
instance.channel = channel
|
||||||
|
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
switch call.method {
|
||||||
|
// -- Input Monitoring ---------------------------------------------------
|
||||||
|
case "checkInputMonitoring":
|
||||||
|
result(inputMonitoringStateString())
|
||||||
|
|
||||||
|
case "requestInputMonitoring":
|
||||||
|
requestInputMonitoring(result: result)
|
||||||
|
|
||||||
|
case "openInputMonitoringSettings":
|
||||||
|
openInputMonitoringSettings(result: result)
|
||||||
|
|
||||||
|
// -- Local Network ------------------------------------------------------
|
||||||
|
case "checkLocalNetwork":
|
||||||
|
checkLocalNetwork(result: result)
|
||||||
|
|
||||||
|
case "triggerLocalNetworkPrompt":
|
||||||
|
triggerLocalNetworkPrompt(result: result)
|
||||||
|
|
||||||
|
// -- Notifications ------------------------------------------------------
|
||||||
|
case "checkNotifications":
|
||||||
|
checkNotifications(result: result)
|
||||||
|
|
||||||
|
case "requestNotifications":
|
||||||
|
requestNotifications(result: result)
|
||||||
|
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Input Monitoring
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Returns the current Input Monitoring state as a string
|
||||||
|
/// consumable by the Dart side: "Granted", "Denied", "NotDetermined".
|
||||||
|
private func inputMonitoringStateString() -> String {
|
||||||
|
// CGPreflightListenEventAccess returns true when access is already
|
||||||
|
// granted. On macOS 10.15+ it returns false when denied or not yet
|
||||||
|
// determined — we cannot distinguish those two without attempting
|
||||||
|
// CGRequestListenEventAccess, so we conservatively report
|
||||||
|
// "NotDetermined" when preflight returns false. The Dart side
|
||||||
|
// treats both "Denied" and "NotDetermined" as L0Focused.
|
||||||
|
if CGPreflightListenEventAccess() {
|
||||||
|
return "Granted"
|
||||||
|
}
|
||||||
|
return "NotDetermined"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request Input Monitoring permission via
|
||||||
|
/// `CGRequestListenEventAccess()`. On macOS 13+ this opens
|
||||||
|
/// System Settings → Privacy & Security → Input Monitoring.
|
||||||
|
private func requestInputMonitoring(result: @escaping FlutterResult) {
|
||||||
|
// CGRequestListenEventAccess shows the system prompt.
|
||||||
|
// It returns true if access was already granted or becomes
|
||||||
|
// granted synchronously (rare). Most of the time it returns
|
||||||
|
// false and the user must toggle the switch manually.
|
||||||
|
let granted = CGRequestListenEventAccess()
|
||||||
|
let state = granted ? "Granted" : "NotDetermined"
|
||||||
|
lastInputMonitoringState = state
|
||||||
|
result(state)
|
||||||
|
|
||||||
|
// Start polling so we detect when the user grants in Settings.
|
||||||
|
startInputMonitoringPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open System Settings → Privacy & Security → Input Monitoring
|
||||||
|
/// so the user can manually enable the app.
|
||||||
|
private func openInputMonitoringSettings(result: @escaping FlutterResult) {
|
||||||
|
if let url = URL(
|
||||||
|
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"
|
||||||
|
) {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
result(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a periodic timer that checks Input Monitoring state and
|
||||||
|
/// notifies the Dart side when it changes.
|
||||||
|
private func startInputMonitoringPolling() {
|
||||||
|
// Don't start a second timer if one is already running.
|
||||||
|
guard inputMonitoringTimer == nil else { return }
|
||||||
|
lastInputMonitoringState = inputMonitoringStateString()
|
||||||
|
|
||||||
|
inputMonitoringTimer = Timer.scheduledTimer(
|
||||||
|
withTimeInterval: kInputMonitoringPollInterval,
|
||||||
|
repeats: true
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.pollInputMonitoring()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pollInputMonitoring() {
|
||||||
|
let current = inputMonitoringStateString()
|
||||||
|
guard current != lastInputMonitoringState else { return }
|
||||||
|
lastInputMonitoringState = current
|
||||||
|
|
||||||
|
// Notify the Dart side via the inbound method.
|
||||||
|
channel?.invokeMethod("inputMonitoringStateChanged", arguments: [
|
||||||
|
"state": current,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Local Network
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Check whether Local Network access is available.
|
||||||
|
/// On macOS 14 and earlier, Local Network Privacy does not exist,
|
||||||
|
/// so we report "Unsupported". On macOS 15+, we attempt a brief
|
||||||
|
/// NWBrowser scan and report based on the result.
|
||||||
|
private func checkLocalNetwork(result: @escaping FlutterResult) {
|
||||||
|
if #available(macOS 15.0, *) {
|
||||||
|
// We cannot synchronously determine the Local Network state
|
||||||
|
// without actually using the network. Report "NotDetermined"
|
||||||
|
// and let triggerLocalNetworkPrompt resolve the actual state.
|
||||||
|
result("NotDetermined")
|
||||||
|
} else {
|
||||||
|
result("Unsupported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger the Local Network Privacy prompt by starting a brief
|
||||||
|
/// NWBrowser for `_ts3._tcp`. On macOS 15+, this causes the system
|
||||||
|
/// to show the Local Network permission dialog if not already
|
||||||
|
/// determined.
|
||||||
|
///
|
||||||
|
/// The browser is started and stopped after a short scan window.
|
||||||
|
/// State changes are reported back to Dart via
|
||||||
|
/// `localNetworkStateChanged`.
|
||||||
|
@available(macOS 15.0, *)
|
||||||
|
private func triggerLocalNetworkPromptImpl(result: @escaping FlutterResult) {
|
||||||
|
let bonjourType = "_ts3._tcp"
|
||||||
|
let browserDescriptor = NWBrowser.Descriptor.bonjourWithTXTRecord(
|
||||||
|
type: bonjourType, domain: nil)
|
||||||
|
let browser = NWBrowser(for: browserDescriptor, using: NWParameters.tcp)
|
||||||
|
|
||||||
|
var resolved = false
|
||||||
|
|
||||||
|
browser.stateUpdateHandler = { [weak self] (browserState: NWBrowser.State) in
|
||||||
|
switch browserState {
|
||||||
|
case .ready:
|
||||||
|
// Browser started successfully — local network is accessible.
|
||||||
|
if !resolved {
|
||||||
|
resolved = true
|
||||||
|
result("Granted")
|
||||||
|
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
||||||
|
"state": "Granted",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
browser.cancel()
|
||||||
|
case .failed(let error):
|
||||||
|
if !resolved {
|
||||||
|
resolved = true
|
||||||
|
let code = error.errorCode
|
||||||
|
// POSIX permission-denied or network-down signals that
|
||||||
|
// the user denied the Local Network prompt.
|
||||||
|
if code == ENETDOWN || code == EACCES || code == EPERM {
|
||||||
|
result("Denied")
|
||||||
|
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
||||||
|
"state": "Denied",
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
// Network unreachable or other transient error —
|
||||||
|
// don't assume denied.
|
||||||
|
result("NotDetermined")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
browser.cancel()
|
||||||
|
case .waiting:
|
||||||
|
// The browser is waiting for network — this is normal and
|
||||||
|
// may mean the permission dialog is showing. Don't resolve
|
||||||
|
// yet; wait for .ready or .failed or the timeout.
|
||||||
|
break
|
||||||
|
case .setup, .cancelled:
|
||||||
|
break
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.start(queue: DispatchQueue.main)
|
||||||
|
|
||||||
|
// Timeout: if the browser doesn't resolve within 10 seconds,
|
||||||
|
// report NotDetermined so the Dart side doesn't hang forever.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) {
|
||||||
|
if !resolved {
|
||||||
|
resolved = true
|
||||||
|
result("NotDetermined")
|
||||||
|
browser.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerLocalNetworkPrompt(result: @escaping FlutterResult) {
|
||||||
|
if #available(macOS 15.0, *) {
|
||||||
|
triggerLocalNetworkPromptImpl(result: result)
|
||||||
|
} else {
|
||||||
|
result("Unsupported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Notifications
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private func checkNotifications(result: @escaping FlutterResult) {
|
||||||
|
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||||
|
switch settings.authorizationStatus {
|
||||||
|
case .authorized, .provisional:
|
||||||
|
result("Granted")
|
||||||
|
case .denied:
|
||||||
|
result("Denied")
|
||||||
|
case .notDetermined:
|
||||||
|
result("NotDetermined")
|
||||||
|
@unknown default:
|
||||||
|
result("NotDetermined")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestNotifications(result: @escaping FlutterResult) {
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(options: [
|
||||||
|
.alert, .sound, .badge,
|
||||||
|
]) { granted, _ in
|
||||||
|
let state = granted ? "Granted" : "Denied"
|
||||||
|
result(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Cleanup
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
inputMonitoringTimer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MainFlutterWindow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class MainFlutterWindow: NSWindow {
|
class MainFlutterWindow: NSWindow {
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
@@ -15,6 +301,9 @@ class MainFlutterWindow: NSWindow {
|
|||||||
|
|
||||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||||
|
|
||||||
|
// Register the macOS permissions MethodChannel handler.
|
||||||
|
MacOSPermissionsHandler.register(with: flutterViewController.registrar(forPlugin: "MacOSPermissionsHandler"))
|
||||||
|
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,505 @@
|
|||||||
|
// SWE.4 unit tests for MacOSPermissionsService — the Dart-side
|
||||||
|
// integration layer for the Swift `MacOSPermissionsHandler`
|
||||||
|
// (SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091).
|
||||||
|
//
|
||||||
|
// Requirement trace:
|
||||||
|
// Verification-plan row: SWE4-UV-XXX.
|
||||||
|
// SRS-198 (Push-to-talk system permission acquisition).
|
||||||
|
// SRS-297 / SRS-300 (Input Monitoring for global PTT on macOS).
|
||||||
|
// SysRS-166 (Desktop notifications).
|
||||||
|
// SDD-091 (PTT capability badge — live capability level).
|
||||||
|
//
|
||||||
|
// Strategy: Following the pattern in
|
||||||
|
// `android_permissions_service_test.dart`, use
|
||||||
|
// `TestDefaultBinaryMessengerBinding` to (a) capture outbound
|
||||||
|
// method invocations and (b) inject inbound state-change calls as
|
||||||
|
// if Swift had emitted them.
|
||||||
|
//
|
||||||
|
// Platform note: these tests run on macOS. The static `_isMacOS`
|
||||||
|
// check inside the service evaluates to true at field-initialization
|
||||||
|
// time, so the ValueNotifiers seed to `unknown` (not `granted`).
|
||||||
|
// This is intentional — on macOS the service must query the native
|
||||||
|
// side before it knows the real state. Tests that inject a channel
|
||||||
|
// observe `unknown` as the initial value and drive transitions from
|
||||||
|
// there. The channel-null short-circuit test documents that when
|
||||||
|
// channel is null, outbound calls are suppressed and each method
|
||||||
|
// returns its safe default.
|
||||||
|
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/services/macos_permissions_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
/// Whether the test host is actually macOS — the field initializers
|
||||||
|
/// inside `MacOSPermissionsService` use `Platform.isMacOS` (not
|
||||||
|
/// injectable), so the initial state values depend on this.
|
||||||
|
final bool hostIsMacOS = !kIsWeb && Platform.isMacOS;
|
||||||
|
|
||||||
|
late MethodChannel channel;
|
||||||
|
late List<MethodCall> outgoingCalls;
|
||||||
|
|
||||||
|
/// Intercept outbound calls AFTER the service's handler is installed.
|
||||||
|
/// We store a reference so outbound invokeMethod calls can be
|
||||||
|
/// captured while inbound handlePlatformMessage calls are routed
|
||||||
|
/// to the service's handler.
|
||||||
|
Future<Object?> Function(MethodCall call)? outgoingResponder;
|
||||||
|
|
||||||
|
Future<void> sendInputMonitoringStateChanged({
|
||||||
|
required String state,
|
||||||
|
}) async {
|
||||||
|
const codec = StandardMethodCodec();
|
||||||
|
final encoded = codec.encodeMethodCall(
|
||||||
|
MethodCall(methodInputMonitoringStateChanged, <String, dynamic>{
|
||||||
|
'state': state,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.handlePlatformMessage(channel.name, encoded, (_) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sendLocalNetworkStateChanged({
|
||||||
|
required String state,
|
||||||
|
}) async {
|
||||||
|
const codec = StandardMethodCodec();
|
||||||
|
final encoded = codec.encodeMethodCall(
|
||||||
|
MethodCall(methodLocalNetworkStateChanged, <String, dynamic>{
|
||||||
|
'state': state,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.handlePlatformMessage(channel.name, encoded, (_) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
channel = const MethodChannel(macOSPermissionsChannelName);
|
||||||
|
outgoingCalls = <MethodCall>[];
|
||||||
|
outgoingResponder = null;
|
||||||
|
|
||||||
|
// The mock handler captures outbound calls AND delegates inbound
|
||||||
|
// platform messages to the service handler when set.
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
outgoingCalls.add(call);
|
||||||
|
final r = outgoingResponder;
|
||||||
|
if (r != null) return r(call);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Initial state
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-297: a fresh service exposes a deterministic initial '
|
||||||
|
'inputMonitoringState that matches the host platform',
|
||||||
|
() {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel);
|
||||||
|
if (hostIsMacOS) {
|
||||||
|
// On macOS the service hasn't queried the native side yet.
|
||||||
|
expect(svc.inputMonitoringState.value, MacOSPermissionState.unknown);
|
||||||
|
expect(svc.pttCapabilityState.value, 'L0Focused');
|
||||||
|
} else {
|
||||||
|
// On non-macOS the static check short-circuits to granted.
|
||||||
|
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
|
||||||
|
expect(svc.pttCapabilityState.value, 'L1MacOSEventTap');
|
||||||
|
}
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Input Monitoring — inbound state changes
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
|
||||||
|
'state=Granted transitions inputMonitoringState and PTT capability',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
// Drive away from the initial value first.
|
||||||
|
await sendInputMonitoringStateChanged(state: 'Denied');
|
||||||
|
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
|
||||||
|
expect(svc.pttCapabilityState.value, 'L0Focused');
|
||||||
|
|
||||||
|
var notified = 0;
|
||||||
|
void listener() => notified++;
|
||||||
|
svc.inputMonitoringState.addListener(listener);
|
||||||
|
|
||||||
|
await sendInputMonitoringStateChanged(state: 'Granted');
|
||||||
|
|
||||||
|
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
|
||||||
|
expect(svc.pttCapabilityState.value, 'L1MacOSEventTap');
|
||||||
|
expect(notified, greaterThanOrEqualTo(1));
|
||||||
|
|
||||||
|
svc.inputMonitoringState.removeListener(listener);
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
|
||||||
|
'state=Denied transitions to denied and L0Focused',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
await sendInputMonitoringStateChanged(state: 'Denied');
|
||||||
|
|
||||||
|
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
|
||||||
|
expect(svc.pttCapabilityState.value, 'L0Focused');
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
|
||||||
|
'state=NotDetermined transitions to notDetermined and L0Focused',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
await sendInputMonitoringStateChanged(state: 'NotDetermined');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
svc.inputMonitoringState.value,
|
||||||
|
MacOSPermissionState.notDetermined,
|
||||||
|
);
|
||||||
|
expect(svc.pttCapabilityState.value, 'L0Focused');
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
|
||||||
|
'malformed state parses to unknown and L0Focused',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
// Start from a known baseline.
|
||||||
|
await sendInputMonitoringStateChanged(state: 'Granted');
|
||||||
|
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
|
||||||
|
|
||||||
|
await sendInputMonitoringStateChanged(state: 'Bogus');
|
||||||
|
|
||||||
|
expect(svc.inputMonitoringState.value, MacOSPermissionState.unknown);
|
||||||
|
expect(svc.pttCapabilityState.value, 'L0Focused');
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Local Network — inbound state changes
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-300: inbound localNetworkStateChanged with '
|
||||||
|
'state=Granted transitions localNetworkState',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
await sendLocalNetworkStateChanged(state: 'Granted');
|
||||||
|
|
||||||
|
expect(svc.localNetworkState.value, MacOSLocalNetworkState.granted);
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-300: inbound localNetworkStateChanged with '
|
||||||
|
'state=Denied transitions localNetworkState',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
await sendLocalNetworkStateChanged(state: 'Denied');
|
||||||
|
|
||||||
|
expect(svc.localNetworkState.value, MacOSLocalNetworkState.denied);
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-300: inbound localNetworkStateChanged with '
|
||||||
|
'state=Unsupported transitions localNetworkState',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
await sendLocalNetworkStateChanged(state: 'Unsupported');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
svc.localNetworkState.value,
|
||||||
|
MacOSLocalNetworkState.unsupported,
|
||||||
|
);
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Outbound method calls
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-297: requestInputMonitoring() emits outbound '
|
||||||
|
'requestInputMonitoring MethodCall; returns the platform response',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
outgoingResponder = (call) async {
|
||||||
|
if (call.method == methodRequestInputMonitoring) {
|
||||||
|
return 'Granted';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = await svc.requestInputMonitoring();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
outgoingCalls.where((c) => c.method == methodRequestInputMonitoring),
|
||||||
|
hasLength(1),
|
||||||
|
reason: 'requestInputMonitoring must be called',
|
||||||
|
);
|
||||||
|
expect(result, MacOSPermissionState.granted);
|
||||||
|
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
|
||||||
|
expect(svc.pttCapabilityState.value, 'L1MacOSEventTap');
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-300: triggerLocalNetworkPrompt() emits outbound '
|
||||||
|
'triggerLocalNetworkPrompt MethodCall; returns the platform response',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
outgoingResponder = (call) async {
|
||||||
|
if (call.method == methodTriggerLocalNetworkPrompt) {
|
||||||
|
return 'Granted';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = await svc.triggerLocalNetworkPrompt();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
outgoingCalls
|
||||||
|
.where((c) => c.method == methodTriggerLocalNetworkPrompt),
|
||||||
|
hasLength(1),
|
||||||
|
reason: 'triggerLocalNetworkPrompt must be called',
|
||||||
|
);
|
||||||
|
expect(result, MacOSLocalNetworkState.granted);
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SysRS-166: requestNotifications() emits outbound '
|
||||||
|
'requestNotifications MethodCall; returns the platform response',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
outgoingResponder = (call) async {
|
||||||
|
if (call.method == methodRequestNotifications) {
|
||||||
|
return 'Granted';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = await svc.requestNotifications();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
outgoingCalls.where((c) => c.method == methodRequestNotifications),
|
||||||
|
hasLength(1),
|
||||||
|
reason: 'requestNotifications must be called',
|
||||||
|
);
|
||||||
|
expect(result, MacOSPermissionState.granted);
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-297: openInputMonitoringSettings() emits outbound '
|
||||||
|
'openInputMonitoringSettings MethodCall',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
await svc.openInputMonitoringSettings();
|
||||||
|
|
||||||
|
final calls = outgoingCalls
|
||||||
|
.where((c) => c.method == methodOpenInputMonitoringSettings)
|
||||||
|
.toList();
|
||||||
|
expect(calls, hasLength(1));
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Lifecycle
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-297: start() is idempotent — calling it twice '
|
||||||
|
'does not double-register the handler',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)
|
||||||
|
..start()
|
||||||
|
..start();
|
||||||
|
|
||||||
|
var notified = 0;
|
||||||
|
void listener() => notified++;
|
||||||
|
svc.inputMonitoringState.addListener(listener);
|
||||||
|
|
||||||
|
await sendInputMonitoringStateChanged(state: 'Denied');
|
||||||
|
|
||||||
|
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
|
||||||
|
expect(notified, 1);
|
||||||
|
|
||||||
|
svc.inputMonitoringState.removeListener(listener);
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-297: stop() removes the handler — subsequent '
|
||||||
|
'inbound messages have no effect on state',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
await sendInputMonitoringStateChanged(state: 'Denied');
|
||||||
|
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
|
||||||
|
|
||||||
|
svc.stop();
|
||||||
|
|
||||||
|
await sendInputMonitoringStateChanged(state: 'Granted');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
svc.inputMonitoringState.value,
|
||||||
|
MacOSPermissionState.denied,
|
||||||
|
reason: 'state must be frozen after stop()',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Non-macOS short-circuit (channel is null)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-297: null-channel short-circuit — on non-macOS hosts '
|
||||||
|
'the constructor seeds to safe defaults; on macOS hosts, passing '
|
||||||
|
'channel:null still creates a real channel because _isMacOS is true',
|
||||||
|
() async {
|
||||||
|
if (!hostIsMacOS) {
|
||||||
|
// On non-macOS: the constructor's `_isMacOS` branch is false,
|
||||||
|
// so `_channel` stays null. All methods return safe defaults
|
||||||
|
// without touching any channel.
|
||||||
|
final svc = MacOSPermissionsService(channel: null);
|
||||||
|
expect(
|
||||||
|
svc.inputMonitoringState.value,
|
||||||
|
MacOSPermissionState.granted,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
svc.localNetworkState.value,
|
||||||
|
MacOSLocalNetworkState.unsupported,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
svc.notificationState.value,
|
||||||
|
MacOSPermissionState.granted,
|
||||||
|
);
|
||||||
|
|
||||||
|
final priorOutgoing = outgoingCalls.length;
|
||||||
|
await svc.requestInputMonitoring();
|
||||||
|
await svc.triggerLocalNetworkPrompt();
|
||||||
|
await svc.requestNotifications();
|
||||||
|
await svc.openInputMonitoringSettings();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
outgoingCalls.length,
|
||||||
|
priorOutgoing,
|
||||||
|
reason: 'no outbound calls when platform is non-macOS',
|
||||||
|
);
|
||||||
|
|
||||||
|
svc.start();
|
||||||
|
svc.stop();
|
||||||
|
svc.dispose();
|
||||||
|
} else {
|
||||||
|
// On macOS: even with channel: null, the constructor creates
|
||||||
|
// a MethodChannel because _isMacOS is true. This is the
|
||||||
|
// correct production behaviour — on macOS the service always
|
||||||
|
// has a channel. The short-circuit path is unreachable on
|
||||||
|
// macOS by design.
|
||||||
|
final svc = MacOSPermissionsService(channel: null);
|
||||||
|
// The service has a non-null _channel, so methods will attempt
|
||||||
|
// to invoke the channel (which has no native handler in tests).
|
||||||
|
// Verify the service doesn't throw and returns a value.
|
||||||
|
final result = await svc.requestInputMonitoring();
|
||||||
|
expect(result, isNotNull);
|
||||||
|
svc.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Channel error handling
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-297: requestInputMonitoring() returns cached state '
|
||||||
|
'when the channel throws',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
// Seed a known state via inbound.
|
||||||
|
await sendInputMonitoringStateChanged(state: 'Denied');
|
||||||
|
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
|
||||||
|
|
||||||
|
// Make the platform stub throw.
|
||||||
|
outgoingResponder = (call) async {
|
||||||
|
if (call.method == methodRequestInputMonitoring) {
|
||||||
|
throw PlatformException(code: 'unavailable');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = await svc.requestInputMonitoring();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
MacOSPermissionState.denied,
|
||||||
|
reason:
|
||||||
|
'on channel failure requestInputMonitoring must return cached state',
|
||||||
|
);
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'SWE4-UV / SRS-300: triggerLocalNetworkPrompt() returns cached state '
|
||||||
|
'when the channel throws',
|
||||||
|
() async {
|
||||||
|
final svc = MacOSPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
outgoingResponder = (call) async {
|
||||||
|
if (call.method == methodTriggerLocalNetworkPrompt) {
|
||||||
|
throw PlatformException(code: 'unavailable');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = await svc.triggerLocalNetworkPrompt();
|
||||||
|
|
||||||
|
// Returns the cached state without crashing.
|
||||||
|
expect(result, isNotNull);
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ audiopus = "0.3.0-rc.0"
|
|||||||
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
|
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
|
||||||
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||||
rustfft = "6.2.0"
|
rustfft = "6.2.0"
|
||||||
|
crossbeam = { version = "0.8", default-features = false, features = ["alloc", "crossbeam-queue"] }
|
||||||
|
crossbeam-utils = { version = "0.8", default-features = false }
|
||||||
|
|
||||||
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
|
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
|
||||||
# Desktop audio I/O for Windows capture/playback and Linux capture.
|
# Desktop audio I/O for Windows capture/playback and Linux capture.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
use audiopus::coder::Encoder as OpusEncoder;
|
use audiopus::coder::Encoder as OpusEncoder;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::audio_event_queue::{AudioCommand, AudioEventQueue};
|
||||||
use crate::mobile_voice_backend::{
|
use crate::mobile_voice_backend::{
|
||||||
clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after,
|
clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after,
|
||||||
next_sharing_mode_after, publish_android_audio_diagnostics, AchievedInputPreset,
|
next_sharing_mode_after, publish_android_audio_diagnostics, AchievedInputPreset,
|
||||||
@@ -63,7 +64,7 @@ use oboe::{
|
|||||||
AudioInputCallback, AudioInputStreamSafe, AudioOutputCallback, AudioOutputStreamSafe,
|
AudioInputCallback, AudioInputStreamSafe, AudioOutputCallback, AudioOutputStreamSafe,
|
||||||
AudioStream, AudioStreamAsync, AudioStreamBase, AudioStreamBuilder, AudioStreamSafe,
|
AudioStream, AudioStreamAsync, AudioStreamBase, AudioStreamBuilder, AudioStreamSafe,
|
||||||
DataCallbackResult, Input as OboeInput, InputPreset, Mono, Output as OboeOutput,
|
DataCallbackResult, Input as OboeInput, InputPreset, Mono, Output as OboeOutput,
|
||||||
PerformanceMode, SessionId, SharingMode, Usage,
|
PerformanceMode, SessionId, SharingMode, Stereo, Usage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::processor::AudioProcessor;
|
use crate::processor::AudioProcessor;
|
||||||
@@ -518,14 +519,14 @@ impl AudioInputCallback for InputCallback {
|
|||||||
//
|
//
|
||||||
// Mirrors the iOS VPIO render callback. Pulls mixed 48 kHz stereo f32
|
// Mirrors the iOS VPIO render callback. Pulls mixed 48 kHz stereo f32
|
||||||
// from `AudioHandler::fill_buffer`, applies output gain + mute, and
|
// from `AudioHandler::fill_buffer`, applies output gain + mute, and
|
||||||
// writes mono i16 to the Oboe output buffer.
|
// writes stereo f32 directly to the Oboe output buffer.
|
||||||
|
|
||||||
struct OutputCallback {
|
struct OutputCallback {
|
||||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
handler: AudioHandler<SessionAudioId>,
|
||||||
|
event_queue: Arc<AudioEventQueue>,
|
||||||
output_gain: Arc<AtomicU32>,
|
output_gain: Arc<AtomicU32>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
event_tx: BackendEventTx,
|
event_tx: BackendEventTx,
|
||||||
scratch: Arc<Mutex<Vec<f32>>>,
|
|
||||||
render_reference: Arc<RenderReferenceBuffer>,
|
render_reference: Arc<RenderReferenceBuffer>,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
pending_render_ref: [f32; crate::frame::FRAME_10MS_SAMPLES],
|
pending_render_ref: [f32; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
@@ -533,51 +534,56 @@ struct OutputCallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AudioOutputCallback for OutputCallback {
|
impl AudioOutputCallback for OutputCallback {
|
||||||
type FrameType = (i16, Mono);
|
type FrameType = (f32, Stereo);
|
||||||
|
|
||||||
fn on_audio_ready(
|
fn on_audio_ready(
|
||||||
&mut self,
|
&mut self,
|
||||||
_stream: &mut dyn AudioOutputStreamSafe,
|
_stream: &mut dyn AudioOutputStreamSafe,
|
||||||
frames: &mut [i16],
|
frames: &mut [(f32, f32)],
|
||||||
) -> DataCallbackResult {
|
) -> DataCallbackResult {
|
||||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||||
let needed = frames.len() * 2; // stereo
|
let buf: &mut [f32] = unsafe {
|
||||||
let scratch = &mut self.scratch.lock().unwrap();
|
std::slice::from_raw_parts_mut(frames.as_mut_ptr() as *mut f32, frames.len() * 2)
|
||||||
if scratch.len() < needed {
|
};
|
||||||
scratch.resize(needed, 0.0);
|
for s in buf.iter_mut() {
|
||||||
} else {
|
*s = 0.0;
|
||||||
for s in &mut scratch[..needed] {
|
}
|
||||||
*s = 0.0;
|
let consumer = AudioEventQueue::consumer(&self.event_queue);
|
||||||
|
for cmd in consumer.drain_controls() {
|
||||||
|
match cmd {
|
||||||
|
AudioCommand::SetVolume(id, vol) => {
|
||||||
|
if let Some(q) = self.handler.get_mut_queues().get_mut(&id) {
|
||||||
|
q.volume = vol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioCommand::RemoveClient(id) => {
|
||||||
|
self.handler.get_mut_queues().remove(&id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match self.handler.try_lock() {
|
|
||||||
Ok(mut h) => {
|
for pkt in consumer.drain_packets(50) {
|
||||||
let _ = h.fill_buffer(&mut scratch[..needed]);
|
if let Err(e) = self.handler.handle_packet(pkt.client_id, pkt.data) {
|
||||||
}
|
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||||
Err(std::sync::TryLockError::WouldBlock) => {}
|
|
||||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
|
||||||
warn!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
"AudioHandler mutex poisoned: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _ = self.handler.fill_buffer(buf);
|
||||||
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
||||||
let muted = self.output_muted.load(Ordering::Relaxed);
|
let muted = self.output_muted.load(Ordering::Relaxed);
|
||||||
let _ = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
if muted {
|
||||||
&scratch[..needed],
|
for s in buf.iter_mut() {
|
||||||
frames,
|
*s = 0.0;
|
||||||
gain,
|
}
|
||||||
muted,
|
} else if gain != 1.0 {
|
||||||
);
|
for s in buf.iter_mut() {
|
||||||
|
*s *= gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
self.audio_processing_stats
|
self.audio_processing_stats
|
||||||
.update_render(crate::frame::dbfs(&scratch[..needed]), frames.len() as u32);
|
.update_render(crate::frame::dbfs(buf), frames.len() as u32);
|
||||||
|
|
||||||
// Accumulate the full render callback into 10 ms mono chunks so
|
for chunk in buf.chunks_exact(2) {
|
||||||
// AEC sees consistent reference timing even when output callbacks
|
|
||||||
// are shorter or longer than 10 ms.
|
|
||||||
for chunk in scratch[..needed].chunks_exact(2) {
|
|
||||||
self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5;
|
self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5;
|
||||||
self.pending_render_ref_len += 1;
|
self.pending_render_ref_len += 1;
|
||||||
if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
@@ -677,8 +683,6 @@ impl AndroidVoiceUnit {
|
|||||||
.map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?,
|
.map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?,
|
||||||
));
|
));
|
||||||
|
|
||||||
let scratch = Arc::new(Mutex::new(Vec::with_capacity(8192)));
|
|
||||||
|
|
||||||
// --- Open input stream (SDD-112) ---------------------------
|
// --- Open input stream (SDD-112) ---------------------------
|
||||||
let input_builder = AudioStreamBuilder::default()
|
let input_builder = AudioStreamBuilder::default()
|
||||||
.set_direction::<OboeInput>()
|
.set_direction::<OboeInput>()
|
||||||
@@ -766,8 +770,8 @@ impl AndroidVoiceUnit {
|
|||||||
let output_builder = AudioStreamBuilder::default()
|
let output_builder = AudioStreamBuilder::default()
|
||||||
.set_direction::<OboeOutput>()
|
.set_direction::<OboeOutput>()
|
||||||
.set_sample_rate(cfg.sample_rate as i32)
|
.set_sample_rate(cfg.sample_rate as i32)
|
||||||
.set_channel_count::<Mono>()
|
.set_channel_count::<Stereo>()
|
||||||
.set_format::<i16>()
|
.set_format::<f32>()
|
||||||
.set_performance_mode(if cfg.request_low_latency {
|
.set_performance_mode(if cfg.request_low_latency {
|
||||||
PerformanceMode::LowLatency
|
PerformanceMode::LowLatency
|
||||||
} else {
|
} else {
|
||||||
@@ -778,16 +782,21 @@ impl AndroidVoiceUnit {
|
|||||||
} else {
|
} else {
|
||||||
SharingMode::Shared
|
SharingMode::Shared
|
||||||
})
|
})
|
||||||
.set_usage(Usage::VoiceCommunication)
|
// Usage::Game avoids forcing the Legacy (OpenSL ES) data path
|
||||||
.set_content_type(oboe::ContentType::Speech);
|
// that Usage::VoiceCommunication triggers on most devices.
|
||||||
|
// Android audio routing is already handled by
|
||||||
|
// AudioManager.MODE_IN_COMMUNICATION on the Flutter side.
|
||||||
|
.set_usage(Usage::Game)
|
||||||
|
.set_content_type(oboe::ContentType::Sonification);
|
||||||
|
|
||||||
let render_ref_for_output = render_ref_buf.clone();
|
let render_ref_for_output = render_ref_buf.clone();
|
||||||
|
let event_queue = params.event_producer.queue();
|
||||||
let output_cb = OutputCallback {
|
let output_cb = OutputCallback {
|
||||||
handler: params.handler.clone(),
|
handler: params.handler,
|
||||||
|
event_queue: event_queue.clone(),
|
||||||
output_gain: params.output_gain.clone(),
|
output_gain: params.output_gain.clone(),
|
||||||
output_muted: params.output_muted.clone(),
|
output_muted: params.output_muted.clone(),
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
scratch: scratch.clone(),
|
|
||||||
render_reference: render_ref_for_output,
|
render_reference: render_ref_for_output,
|
||||||
audio_processing_stats: audio_processing_stats.clone(),
|
audio_processing_stats: audio_processing_stats.clone(),
|
||||||
pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES],
|
pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
@@ -806,20 +815,41 @@ impl AndroidVoiceUnit {
|
|||||||
Self::open_output_fallback(
|
Self::open_output_fallback(
|
||||||
cfg,
|
cfg,
|
||||||
&event_tx,
|
&event_tx,
|
||||||
params.handler.clone(),
|
AudioHandler::new(),
|
||||||
|
event_queue.clone(),
|
||||||
params.output_gain.clone(),
|
params.output_gain.clone(),
|
||||||
params.output_muted.clone(),
|
params.output_muted.clone(),
|
||||||
audio_processing_stats.clone(),
|
audio_processing_stats.clone(),
|
||||||
scratch.clone(),
|
|
||||||
render_ref_buf,
|
render_ref_buf,
|
||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let output_frames_per_burst = output_stream.get_frames_per_burst();
|
||||||
|
if output_frames_per_burst > 0 {
|
||||||
|
let desired = output_frames_per_burst * 2;
|
||||||
|
match output_stream.set_buffer_size_in_frames(desired) {
|
||||||
|
Ok(actual) => {
|
||||||
|
debug!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
desired,
|
||||||
|
actual,
|
||||||
|
"android: output buffer size tuned"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
error = ?e,
|
||||||
|
"android: output buffer size tuning failed; using device default"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let output_perf = perf_from_oboe(output_stream.get_performance_mode());
|
let output_perf = perf_from_oboe(output_stream.get_performance_mode());
|
||||||
let output_share = share_from_oboe(output_stream.get_sharing_mode());
|
let output_share = share_from_oboe(output_stream.get_sharing_mode());
|
||||||
let output_sample_rate = output_stream.get_sample_rate();
|
let output_sample_rate = output_stream.get_sample_rate();
|
||||||
let output_frames_per_burst = output_stream.get_frames_per_burst();
|
|
||||||
|
|
||||||
// SDD-112 / SRS-210: structured "stream opened" event with
|
// SDD-112 / SRS-210: structured "stream opened" event with
|
||||||
// achieved values. No PII; only platform-reported scalars.
|
// achieved values. No PII; only platform-reported scalars.
|
||||||
@@ -1038,19 +1068,19 @@ impl AndroidVoiceUnit {
|
|||||||
fn open_output_fallback(
|
fn open_output_fallback(
|
||||||
cfg: &AndroidVoiceStreamConfig,
|
cfg: &AndroidVoiceStreamConfig,
|
||||||
event_tx: &BackendEventTx,
|
event_tx: &BackendEventTx,
|
||||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
handler: AudioHandler<SessionAudioId>,
|
||||||
|
event_queue: Arc<AudioEventQueue>,
|
||||||
output_gain: Arc<AtomicU32>,
|
output_gain: Arc<AtomicU32>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
scratch: Arc<Mutex<Vec<f32>>>,
|
|
||||||
render_reference: Arc<RenderReferenceBuffer>,
|
render_reference: Arc<RenderReferenceBuffer>,
|
||||||
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
||||||
let cb = OutputCallback {
|
let cb = OutputCallback {
|
||||||
handler,
|
handler,
|
||||||
|
event_queue,
|
||||||
output_gain,
|
output_gain,
|
||||||
output_muted,
|
output_muted,
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
scratch,
|
|
||||||
render_reference,
|
render_reference,
|
||||||
audio_processing_stats,
|
audio_processing_stats,
|
||||||
pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES],
|
pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
@@ -1059,12 +1089,13 @@ impl AndroidVoiceUnit {
|
|||||||
let builder = AudioStreamBuilder::default()
|
let builder = AudioStreamBuilder::default()
|
||||||
.set_direction::<OboeOutput>()
|
.set_direction::<OboeOutput>()
|
||||||
.set_sample_rate(cfg.sample_rate as i32)
|
.set_sample_rate(cfg.sample_rate as i32)
|
||||||
.set_channel_count::<Mono>()
|
.set_channel_count::<Stereo>()
|
||||||
.set_format::<i16>()
|
.set_format::<f32>()
|
||||||
.set_performance_mode(PerformanceMode::LowLatency)
|
.set_performance_mode(PerformanceMode::LowLatency)
|
||||||
.set_sharing_mode(SharingMode::Shared)
|
.set_sharing_mode(SharingMode::Shared)
|
||||||
.set_usage(Usage::VoiceCommunication)
|
// Same Usage::Game rationale as primary output builder above.
|
||||||
.set_content_type(oboe::ContentType::Speech)
|
.set_usage(Usage::Game)
|
||||||
|
.set_content_type(oboe::ContentType::Sonification)
|
||||||
.set_callback(cb);
|
.set_callback(cb);
|
||||||
builder
|
builder
|
||||||
.open_stream()
|
.open_stream()
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chanora_protocol::InAudioBuf;
|
||||||
|
use crossbeam::queue::ArrayQueue;
|
||||||
|
|
||||||
|
use crate::engine::SessionAudioId;
|
||||||
|
|
||||||
|
const PACKET_QUEUE_CAPACITY: usize = 100;
|
||||||
|
const CONTROL_QUEUE_CAPACITY: usize = 32;
|
||||||
|
|
||||||
|
/// A raw inbound voice packet waiting to be inserted into AudioHandler.
|
||||||
|
pub struct AudioPacket {
|
||||||
|
/// Client whose TeamSpeak audio packet this belongs to.
|
||||||
|
pub client_id: SessionAudioId,
|
||||||
|
/// Raw inbound TeamSpeak audio payload accepted by AudioHandler::handle_packet.
|
||||||
|
pub data: InAudioBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Control commands from the main thread to the audio callback.
|
||||||
|
pub enum AudioCommand {
|
||||||
|
/// Set a client's output volume.
|
||||||
|
SetVolume(SessionAudioId, f32),
|
||||||
|
/// Remove a client's decode queue.
|
||||||
|
RemoveClient(SessionAudioId),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock-free bridge between the inbound forwarder / main thread and the
|
||||||
|
/// audio callback. The callback owns the consumer halves.
|
||||||
|
pub struct AudioEventQueue {
|
||||||
|
/// Bounded lossy queue for raw voice packets. On overflow, the push
|
||||||
|
/// fails and the packet is dropped (counted via `packets_dropped`).
|
||||||
|
/// Capacity: 100 packets (~2 seconds at 50pps, far more than needed).
|
||||||
|
pub packet_queue: ArrayQueue<AudioPacket>,
|
||||||
|
/// Bounded reliable queue for control commands (volume, client removal).
|
||||||
|
/// On overflow, the caller retries. Capacity: 32 commands.
|
||||||
|
pub control_queue: ArrayQueue<AudioCommand>,
|
||||||
|
/// Atomic counter for dropped packets (for diagnostics).
|
||||||
|
pub packets_dropped: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioEventQueue {
|
||||||
|
/// Create the Android audio event bridge with fixed queue capacities.
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
packet_queue: ArrayQueue::new(PACKET_QUEUE_CAPACITY),
|
||||||
|
control_queue: ArrayQueue::new(CONTROL_QUEUE_CAPACITY),
|
||||||
|
packets_dropped: AtomicU64::new(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a producer handle sharing this queue.
|
||||||
|
pub fn producer(queue: &Arc<Self>) -> AudioEventProducer {
|
||||||
|
AudioEventProducer {
|
||||||
|
queue: Arc::clone(queue),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a consumer handle sharing this queue.
|
||||||
|
pub fn consumer(queue: &Arc<Self>) -> AudioEventConsumer {
|
||||||
|
AudioEventConsumer {
|
||||||
|
queue: Arc::clone(queue),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Producer side used by the inbound forwarder and engine control methods.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AudioEventProducer {
|
||||||
|
queue: Arc<AudioEventQueue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioEventProducer {
|
||||||
|
/// Push a raw voice packet, incrementing the drop counter if full.
|
||||||
|
pub fn push_packet(&self, packet: AudioPacket) -> Result<(), AudioPacket> {
|
||||||
|
self.queue.packet_queue.push(packet).map_err(|packet| {
|
||||||
|
self.queue.packets_dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
packet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a control command, returning it unchanged if the queue is full.
|
||||||
|
pub fn push_control(&self, cmd: AudioCommand) -> Result<(), AudioCommand> {
|
||||||
|
self.queue.control_queue.push(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared queue backing this producer.
|
||||||
|
pub fn queue(&self) -> Arc<AudioEventQueue> {
|
||||||
|
Arc::clone(&self.queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumer side used by the Android output callback.
|
||||||
|
pub struct AudioEventConsumer {
|
||||||
|
queue: Arc<AudioEventQueue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioEventConsumer {
|
||||||
|
/// Pop up to `cap` queued packets.
|
||||||
|
pub fn drain_packets(&self, cap: usize) -> impl Iterator<Item = AudioPacket> + '_ {
|
||||||
|
let mut drained = 0;
|
||||||
|
std::iter::from_fn(move || {
|
||||||
|
if drained >= cap {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let packet = self.queue.packet_queue.pop();
|
||||||
|
if packet.is_some() {
|
||||||
|
drained += 1;
|
||||||
|
}
|
||||||
|
packet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop all currently queued controls.
|
||||||
|
pub fn drain_controls(&self) -> impl Iterator<Item = AudioCommand> + '_ {
|
||||||
|
std::iter::from_fn(move || self.queue.control_queue.pop())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,8 @@ use tsclientlib::audio::AudioHandler;
|
|||||||
|
|
||||||
use chanora_protocol::{InboundVoice, OutPacket};
|
use chanora_protocol::{InboundVoice, OutPacket};
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use crate::audio_event_queue::{AudioCommand, AudioEventQueue, AudioPacket};
|
||||||
use crate::AudioError;
|
use crate::AudioError;
|
||||||
|
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
@@ -305,12 +307,15 @@ pub struct AudioEngine {
|
|||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
#[cfg(target_os = "android")]
|
||||||
|
audio_event_producer: crate::audio_event_queue::AudioEventProducer,
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
#[cfg(target_os = "android")]
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
#[cfg(target_os = "android")]
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
// Streams must be dropped to stop audio. Both are `!Send` because
|
// Streams must be dropped to stop audio. Both are `!Send` because
|
||||||
// cpal's Stream isn't Send on some backends; we keep them in an
|
// cpal's Stream isn't Send on some backends; we keep them in an
|
||||||
@@ -482,7 +487,7 @@ impl AudioEngine {
|
|||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
transmit_gate: crate::ptt::AudioTransmitGate,
|
transmit_gate: crate::ptt::AudioTransmitGate,
|
||||||
frames_sent: Arc<AtomicU32>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
event_producer: crate::audio_event_queue::AudioEventProducer,
|
||||||
output_gain: Arc<AtomicU32>,
|
output_gain: Arc<AtomicU32>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
@@ -552,7 +557,8 @@ impl AudioEngine {
|
|||||||
transmit_active: transmit_gate.flag_arc(),
|
transmit_active: transmit_gate.flag_arc(),
|
||||||
frames_sent: frames_sent.clone(),
|
frames_sent: frames_sent.clone(),
|
||||||
mic_gain,
|
mic_gain,
|
||||||
handler: audio_handler.clone(),
|
handler: AudioHandler::new(),
|
||||||
|
event_producer: event_producer.clone(),
|
||||||
output_gain: output_gain.clone(),
|
output_gain: output_gain.clone(),
|
||||||
output_muted: output_muted.clone(),
|
output_muted: output_muted.clone(),
|
||||||
voice_activity_selector: voice_activity_selector.clone(),
|
voice_activity_selector: voice_activity_selector.clone(),
|
||||||
@@ -572,7 +578,7 @@ impl AudioEngine {
|
|||||||
voice_out_tx.clone(),
|
voice_out_tx.clone(),
|
||||||
transmit_gate.clone(),
|
transmit_gate.clone(),
|
||||||
frames_sent.clone(),
|
frames_sent.clone(),
|
||||||
audio_handler.clone(),
|
event_producer.clone(),
|
||||||
output_gain.clone(),
|
output_gain.clone(),
|
||||||
output_muted.clone(),
|
output_muted.clone(),
|
||||||
voice_activity_selector.clone(),
|
voice_activity_selector.clone(),
|
||||||
@@ -1007,8 +1013,8 @@ impl AudioEngine {
|
|||||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||||
|
|
||||||
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
let event_queue = AudioEventQueue::new();
|
||||||
Arc::new(Mutex::new(AudioHandler::new()));
|
let event_producer = AudioEventQueue::producer(&event_queue);
|
||||||
|
|
||||||
if !cfg.mobile_voice_preset {
|
if !cfg.mobile_voice_preset {
|
||||||
return Err(AudioError::Backend(
|
return Err(AudioError::Backend(
|
||||||
@@ -1069,7 +1075,8 @@ impl AudioEngine {
|
|||||||
transmit_active: transmit_flag_for_capture,
|
transmit_active: transmit_flag_for_capture,
|
||||||
frames_sent: frames_sent.clone(),
|
frames_sent: frames_sent.clone(),
|
||||||
mic_gain: cfg.mic_gain,
|
mic_gain: cfg.mic_gain,
|
||||||
handler: audio_handler.clone(),
|
handler: AudioHandler::new(),
|
||||||
|
event_producer: event_producer.clone(),
|
||||||
output_gain: output_gain.clone(),
|
output_gain: output_gain.clone(),
|
||||||
output_muted: output_muted.clone(),
|
output_muted: output_muted.clone(),
|
||||||
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
||||||
@@ -1103,7 +1110,7 @@ impl AudioEngine {
|
|||||||
voice_out_tx.clone(),
|
voice_out_tx.clone(),
|
||||||
transmit_gate.clone(),
|
transmit_gate.clone(),
|
||||||
frames_sent.clone(),
|
frames_sent.clone(),
|
||||||
audio_handler.clone(),
|
event_producer.clone(),
|
||||||
output_gain.clone(),
|
output_gain.clone(),
|
||||||
output_muted.clone(),
|
output_muted.clone(),
|
||||||
cfg.voice_activity_selector.clone(),
|
cfg.voice_activity_selector.clone(),
|
||||||
@@ -1151,7 +1158,7 @@ impl AudioEngine {
|
|||||||
let capture_active = true;
|
let capture_active = true;
|
||||||
|
|
||||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||||
let handler_for_task = audio_handler.clone();
|
let event_producer_for_task = event_producer.clone();
|
||||||
let frames_received_for_task = frames_received.clone();
|
let frames_received_for_task = frames_received.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -1164,10 +1171,8 @@ impl AudioEngine {
|
|||||||
match item {
|
match item {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
let id = SessionAudioId(v.from_client);
|
let id = SessionAudioId(v.from_client);
|
||||||
let mut h = handler_for_task.lock().unwrap();
|
let packet = AudioPacket { client_id: id, data: v.packet };
|
||||||
if let Err(e) = h.handle_packet(id, v.packet) {
|
if event_producer_for_task.push_packet(packet).is_ok() {
|
||||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
|
||||||
} else {
|
|
||||||
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1186,7 +1191,7 @@ impl AudioEngine {
|
|||||||
output_muted,
|
output_muted,
|
||||||
audio_processing_config,
|
audio_processing_config,
|
||||||
audio_processing_stats,
|
audio_processing_stats,
|
||||||
audio_handler,
|
audio_event_producer: event_producer,
|
||||||
voice_out_tx,
|
voice_out_tx,
|
||||||
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
||||||
mic_gain: cfg.mic_gain,
|
mic_gain: cfg.mic_gain,
|
||||||
@@ -1308,9 +1313,6 @@ impl AudioEngine {
|
|||||||
audio_processing_config,
|
audio_processing_config,
|
||||||
audio_processing_stats,
|
audio_processing_stats,
|
||||||
audio_handler,
|
audio_handler,
|
||||||
voice_out_tx,
|
|
||||||
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
|
||||||
mic_gain: cfg.mic_gain,
|
|
||||||
_ios_voice_backend: Mutex::new(Some(ios_voice_backend)),
|
_ios_voice_backend: Mutex::new(Some(ios_voice_backend)),
|
||||||
shutdown_tx: Some(shutdown_tx),
|
shutdown_tx: Some(shutdown_tx),
|
||||||
capture_active,
|
capture_active,
|
||||||
@@ -1470,7 +1472,8 @@ impl AudioEngine {
|
|||||||
transmit_active: self.transmit_gate.flag_arc(),
|
transmit_active: self.transmit_gate.flag_arc(),
|
||||||
frames_sent: self.frames_sent.clone(),
|
frames_sent: self.frames_sent.clone(),
|
||||||
mic_gain: self.mic_gain,
|
mic_gain: self.mic_gain,
|
||||||
handler: self.audio_handler.clone(),
|
handler: AudioHandler::new(),
|
||||||
|
event_producer: self.audio_event_producer.clone(),
|
||||||
output_gain: self.output_gain.clone(),
|
output_gain: self.output_gain.clone(),
|
||||||
output_muted: self.output_muted.clone(),
|
output_muted: self.output_muted.clone(),
|
||||||
voice_activity_selector: self.voice_activity_selector.clone(),
|
voice_activity_selector: self.voice_activity_selector.clone(),
|
||||||
@@ -1491,7 +1494,7 @@ impl AudioEngine {
|
|||||||
self.voice_out_tx.clone(),
|
self.voice_out_tx.clone(),
|
||||||
self.transmit_gate.clone(),
|
self.transmit_gate.clone(),
|
||||||
self.frames_sent.clone(),
|
self.frames_sent.clone(),
|
||||||
self.audio_handler.clone(),
|
self.audio_event_producer.clone(),
|
||||||
self.output_gain.clone(),
|
self.output_gain.clone(),
|
||||||
self.output_muted.clone(),
|
self.output_muted.clone(),
|
||||||
self.voice_activity_selector.clone(),
|
self.voice_activity_selector.clone(),
|
||||||
@@ -1658,20 +1661,36 @@ impl AudioEngine {
|
|||||||
/// `0.0..4.0`.
|
/// `0.0..4.0`.
|
||||||
pub fn set_client_volume(&self, client_id: u64, volume: f32) {
|
pub fn set_client_volume(&self, client_id: u64, volume: f32) {
|
||||||
let clamped = volume.clamp(0.0, 4.0);
|
let clamped = volume.clamp(0.0, 4.0);
|
||||||
match self.audio_handler.lock() {
|
#[cfg(target_os = "android")]
|
||||||
Ok(mut h) => {
|
{
|
||||||
if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) {
|
let mut cmd = AudioCommand::SetVolume(SessionAudioId(client_id), clamped);
|
||||||
q.volume = clamped;
|
loop {
|
||||||
|
match self.audio_event_producer.push_control(cmd) {
|
||||||
|
Ok(()) => break,
|
||||||
|
Err(returned) => {
|
||||||
|
cmd = returned;
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
}
|
||||||
tracing::warn!(
|
#[cfg(not(target_os = "android"))]
|
||||||
target: "chanora_audio",
|
{
|
||||||
client_id,
|
match self.audio_handler.lock() {
|
||||||
volume = clamped,
|
Ok(mut h) => {
|
||||||
error = %e,
|
if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) {
|
||||||
"set_client_volume: audio_handler lock poisoned — volume not applied"
|
q.volume = clamped;
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
client_id,
|
||||||
|
volume = clamped,
|
||||||
|
error = %e,
|
||||||
|
"set_client_volume: audio_handler lock poisoned — volume not applied"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod audio_event_queue;
|
||||||
pub mod audio_processing;
|
pub mod audio_processing;
|
||||||
pub mod debug_wav;
|
pub mod debug_wav;
|
||||||
mod engine;
|
mod engine;
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ use std::sync::atomic::{AtomicBool, AtomicU32};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
use tsclientlib::audio::AudioHandler;
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
|
||||||
use crate::engine::SessionAudioId;
|
use crate::engine::SessionAudioId;
|
||||||
@@ -67,7 +70,7 @@ pub type BackendEventTx = mpsc::UnboundedSender<BackendEvent>;
|
|||||||
pub type AudioSessionId = i32;
|
pub type AudioSessionId = i32;
|
||||||
|
|
||||||
/// Engine-owned state shared with mobile voice audio callbacks.
|
/// Engine-owned state shared with mobile voice audio callbacks.
|
||||||
#[derive(Clone)]
|
#[cfg_attr(not(target_os = "android"), derive(Clone))]
|
||||||
pub(crate) struct VoiceAudioParams {
|
pub(crate) struct VoiceAudioParams {
|
||||||
/// Opus-encoded voice packets sent on this channel toward the
|
/// Opus-encoded voice packets sent on this channel toward the
|
||||||
/// protocol layer.
|
/// protocol layer.
|
||||||
@@ -78,8 +81,15 @@ pub(crate) struct VoiceAudioParams {
|
|||||||
pub frames_sent: Arc<AtomicU32>,
|
pub frames_sent: Arc<AtomicU32>,
|
||||||
/// Pre-encode amplitude scale (1.0 = unity).
|
/// Pre-encode amplitude scale (1.0 = unity).
|
||||||
pub mic_gain: f32,
|
pub mic_gain: f32,
|
||||||
|
/// AudioHandler owned by the Android output callback.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub handler: AudioHandler<SessionAudioId>,
|
||||||
|
/// Producer used by Android engine tasks to feed the output callback.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub event_producer: crate::audio_event_queue::AudioEventProducer,
|
||||||
/// AudioHandler that inbound decode+mix feeds into; the output
|
/// AudioHandler that inbound decode+mix feeds into; the output
|
||||||
/// callback pulls mixed stereo f32 from it.
|
/// callback pulls mixed stereo f32 from it.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
|
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
|
||||||
/// cross-thread read from the realtime audio callback).
|
/// cross-thread read from the realtime audio callback).
|
||||||
|
|||||||
Reference in New Issue
Block a user