feat(voice): harden Android audio and channel joins

This commit is contained in:
Edison Jwa
2026-05-19 01:58:07 +09:00
parent 29a553d4e1
commit 8c253f1d4d
23 changed files with 2948 additions and 363 deletions
+68 -48
View File
@@ -252,6 +252,9 @@ class _BetaHomeState extends State<_BetaHome> {
bool _hardMute = false;
int _releaseTailMs = 200;
BigInt? _currentVoiceChannelId;
BigInt? _pendingVoiceChannelId;
bool _canJoinVoiceChannel = true;
bool _canLeaveVoiceChannel = false;
String? _lostReason;
int? _reconnectAttempt;
@@ -312,7 +315,8 @@ class _BetaHomeState extends State<_BetaHome> {
bool _handleFocusedPttKey(KeyEvent event) {
final label = _pttDisplayLabelForKey(event.logicalKey);
final isBoundKey = _pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel;
final isBoundKey =
_pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel;
if (event is KeyUpEvent && _focusedPttHeldKeys.contains(event.logicalKey)) {
if (_focusedPttHeldKeys.remove(event.logicalKey)) {
unawaited(_setPtt(false));
@@ -423,6 +427,10 @@ class _BetaHomeState extends State<_BetaHome> {
:final transmitMode,
:final mute,
:final releaseTailMs,
:final currentChannelId,
:final pendingTargetChannelId,
:final canJoin,
:final canLeave,
):
setState(() {
_inChannel = inChannel;
@@ -430,6 +438,10 @@ class _BetaHomeState extends State<_BetaHome> {
_hardMute = mute;
_releaseTailMs = releaseTailMs;
_audioStarted = inChannel;
_currentVoiceChannelId = currentChannelId;
_pendingVoiceChannelId = pendingTargetChannelId;
_canJoinVoiceChannel = canJoin;
_canLeaveVoiceChannel = canLeave;
});
if (inChannel) {
_ensureStatsTimer();
@@ -438,7 +450,6 @@ class _BetaHomeState extends State<_BetaHome> {
_statsTimer?.cancel();
_statsTimer = null;
_releaseFocusedPttIfHeld();
setState(() => _currentVoiceChannelId = null);
}
if (transmitMode != rust.BridgeTransmitMode.ptt) {
_releaseFocusedPttIfHeld();
@@ -485,10 +496,7 @@ class _BetaHomeState extends State<_BetaHome> {
// remains the canonical Dart-side state holder (driven by
// the MethodChannel); a future revision may expose a
// setter so both paths converge on a single ValueNotifier.
case rust.BridgeEvent_PermissionState(
:final permission,
:final state,
):
case rust.BridgeEvent_PermissionState(:final permission, :final state):
debugPrint(
'bridge permission_state: permission=$permission state=$state',
);
@@ -568,27 +576,27 @@ class _BetaHomeState extends State<_BetaHome> {
void _showPermissionDeniedDialog(String rawError) {
final l10n = AppL10n.of(context);
final isNetwork =
rawError.contains('9987') || rawError.contains('connect');
final isNetwork = rawError.contains('9987') || rawError.contains('connect');
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title:
Text(isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied),
title: Text(
isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied,
),
content: Text(
isNetwork ? l10n.networkPermissionBody : l10n.microphonePermissionBody),
isNetwork
? l10n.networkPermissionBody
: l10n.microphonePermissionBody,
),
actions: [
if (Platform.isMacOS)
TextButton(
onPressed: () {
Navigator.pop(ctx);
try {
Process.run(
'open',
[
'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
],
);
Process.run('open', [
'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
]);
} catch (_) {}
},
child: Text(l10n.networkPermissionOpenSettings),
@@ -649,6 +657,8 @@ class _BetaHomeState extends State<_BetaHome> {
}
Future<void> _onJoinChannel(rust.BridgeChannel ch) async {
if (!_canJoinVoiceChannel) return;
if (_pendingVoiceChannelId != null) return;
if (ch.id == _currentVoiceChannelId) return;
final l10n = AppL10n.of(context);
final messenger = ScaffoldMessenger.of(context);
@@ -689,7 +699,7 @@ class _BetaHomeState extends State<_BetaHome> {
}
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
if (!mounted) return;
setState(() => _currentVoiceChannelId = ch.id);
unawaited(_onRefresh());
} catch (e) {
if (!mounted) return;
// Surface as a SnackBar so the user sees it even while
@@ -753,10 +763,9 @@ class _BetaHomeState extends State<_BetaHome> {
// ignore: unused_element
Future<void> _onLeaveVoice() async {
if (!_canLeaveVoiceChannel) return;
try {
await rust.voiceLeave();
if (!mounted) return;
setState(() => _currentVoiceChannelId = null);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
@@ -916,6 +925,9 @@ class _BetaHomeState extends State<_BetaHome> {
_outputMuted = false;
_inChannel = false;
_currentVoiceChannelId = null;
_pendingVoiceChannelId = null;
_canJoinVoiceChannel = true;
_canLeaveVoiceChannel = false;
});
_releaseFocusedPttIfHeld();
}
@@ -932,17 +944,6 @@ class _BetaHomeState extends State<_BetaHome> {
void _applySnapshot(rust.BridgeSnapshot snap) {
_snapshot = snap;
if (!_inChannel) {
_currentVoiceChannelId = null;
return;
}
for (final client in snap.clients) {
if (client.id == snap.ownClientId) {
_currentVoiceChannelId = client.channel;
return;
}
}
_currentVoiceChannelId = null;
}
Future<void> _onShowDiagnostics(BuildContext context) async {
@@ -1335,6 +1336,10 @@ class _BetaHomeState extends State<_BetaHome> {
final snapshotView = _SnapshotView(
snapshot: _snapshot!,
currentVoiceChannelId: _currentVoiceChannelId,
pendingVoiceChannelId: _pendingVoiceChannelId,
hasJoinPending: _pendingVoiceChannelId != null,
canJoinVoiceChannel: _canJoinVoiceChannel,
canLeaveVoiceChannel: _canLeaveVoiceChannel,
onJoinChannel: _onJoinChannel,
onLeaveVoice: _onLeaveVoice,
);
@@ -1346,8 +1351,7 @@ class _BetaHomeState extends State<_BetaHome> {
SizedBox(
width: voiceBarWidthWide,
child: Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
banner,
const SizedBox(height: 12),
@@ -1376,8 +1380,7 @@ class _BetaHomeState extends State<_BetaHome> {
onTap: () => _onOpenVoiceDetailsSheet(),
),
if (_inChannel &&
_transmitMode ==
rust.BridgeTransmitMode.ptt) ...[
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
const SizedBox(height: 8),
VoicePttButton(
active: _audioStats?.pttActive ?? false,
@@ -1410,7 +1413,12 @@ class _BetaHomeState extends State<_BetaHome> {
],
),
),
Expanded(child: Padding(padding: const EdgeInsets.all(16), child: bodyContent)),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: bodyContent,
),
),
],
),
),
@@ -1418,14 +1426,8 @@ class _BetaHomeState extends State<_BetaHome> {
}
return Scaffold(
appBar: AppBar(
title: headerTitle,
actions: headerActions,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: bodyContent,
),
appBar: AppBar(title: headerTitle, actions: headerActions),
body: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
);
}
}
@@ -1991,12 +1993,20 @@ class _SnapshotView extends StatelessWidget {
const _SnapshotView({
required this.snapshot,
required this.currentVoiceChannelId,
required this.pendingVoiceChannelId,
required this.hasJoinPending,
required this.canJoinVoiceChannel,
required this.canLeaveVoiceChannel,
required this.onJoinChannel,
required this.onLeaveVoice,
});
final rust.BridgeSnapshot snapshot;
final BigInt? currentVoiceChannelId;
final BigInt? pendingVoiceChannelId;
final bool hasJoinPending;
final bool canJoinVoiceChannel;
final bool canLeaveVoiceChannel;
final ValueChanged<rust.BridgeChannel> onJoinChannel;
final VoidCallback onLeaveVoice;
@@ -2077,17 +2087,27 @@ class _SnapshotView extends StatelessWidget {
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
trailing: IconButton(
icon: Icon(
ch.id == currentVoiceChannelId ? Icons.logout : Icons.login,
ch.id == currentVoiceChannelId
? Icons.logout
: ch.id == pendingVoiceChannelId
? Icons.hourglass_top
: Icons.login,
),
tooltip: ch.id == currentVoiceChannelId
? l10n.leaveChannelAction
: ch.id == pendingVoiceChannelId
? l10n.statusConnecting
: l10n.joinChannelAction,
onPressed: ch.id == currentVoiceChannelId
? onLeaveVoice
onPressed: hasJoinPending
? null
: ch.id == pendingVoiceChannelId
? null
: ch.id == currentVoiceChannelId
? (canLeaveVoiceChannel ? onLeaveVoice : null)
: () => onJoinChannel(ch),
),
selected: ch.id == currentVoiceChannelId,
onTap: ch.id == currentVoiceChannelId
onTap: hasJoinPending || !canJoinVoiceChannel || ch.id == currentVoiceChannelId
? null
: () => onJoinChannel(ch),
),