fix(ui,core): auto-VoiceState on connect + StatefulWidget dialogs (no PTT after join, save-bookmark crash)

Two user-reported issues addressed:

1. 'when user join the server aka default channel, but there are
   no ptt button also can not talk'

   Root cause: TS3 servers auto-place a newly-connected client into
   the server's default channel. We did not detect that. The UI
   gated all voice controls (PTT button, mic/headset AppBar icons,
   voice modal entry point) on _inChannel which was only flipped
   true by an explicit voice_join() call from the user. So after
   connect the user saw themselves in the default channel via the
   channel tree but had no way to talk.

   Fix: in chanora_core::Session::connect(), after the initial
   snapshot resolves, call find_own_in(&snap) to determine whether
   the server placed us in a channel. If yes, voice_selector
   .set_in_channel(true) + emit SessionEvent::VoiceState
   { in_channel: true } + ensure_audio_running. This treats the
   server-side default channel placement identically to a user-
   driven voice_join: the UI receives a VoiceState(true) event and
   renders all voice controls.

   Tolerates audio engine startup failure the same way voice_join
   does \u2014 server-side we are in the channel regardless; if mic
   permission / device init fails, the UI gains the controls and
   emits SessionEvent::AudioStopped so the user can resolve the
   underlying issue.

   Drops the inner lock before calling the public helpers because
   set_in_channel + emit_voice_state + ensure_audio_running all
   re-lock self.inner.

2. 'save bookmark cause a crash framework.dart line 6268
   _dependents.isEmpty is not true'

   Root cause: the showDialog-with-inline-TextEditingController-
   dispose anti-pattern. _onAddCurrentBookmark and
   _askChannelPassword both constructed a TextEditingController in
   the surrounding async function, passed it to a dialog's
   TextField via the dialog builder, then called ctl.dispose()
   synchronously after  returned.

   On iOS the dialog route pop animation is still mid-flight when
   showDialog's Future resolves. The inline dispose tore the
   controller out from under EditableText while EditableText still
   held InheritedWidget dependencies on the dialog route (theme,
   localizations, default text style). When the dialog route's
   InheritedElement then deactivated as part of the pop animation,
   the framework's debug-only assertion _dependents.isEmpty tripped
   because the disposed dialog tree had not finished detaching its
   dependents yet.

   Fix: hoist both dialogs into dedicated StatefulWidgets
   (_BookmarkNameDialog and _ChannelPasswordDialog) that own their
   own TextEditingController. The State.dispose() runs as part of
   the dialog's normal unmount lifecycle, AFTER the pop animation
   completes and all InheritedWidget dependencies have been cleared.
   No race possible.

   Bonus: dialog builders now use the dialog's own ctx for
   AppL10n.of(...), Theme.of(...), and Navigator.of(...) calls
   uniformly, rather than capturing the outer _HomePageState
   context's l10n in a closure. That avoids a secondary leak where
   the dialog widget tree held references back to the outer
   route's InheritedElements through closure capture.

   Added onSubmitted: -> pop(_ctl.text) on both fields so iOS
   hardware-keyboard 'return' submits the dialog (small UX win
   discovered while restructuring).

iOS build: flutter build ios --release --no-codesign 20.3 s,
Runner.app 30.4 MB. flutter analyze: 6 pre-existing Radio
deprecation infos (unchanged). cargo test -p chanora_core --release
--lib: 13 passed.
This commit is contained in:
EdisonJwa
2026-05-16 21:09:54 +08:00
parent f0016155aa
commit be160f5edd
5 changed files with 296 additions and 44 deletions
@@ -547,6 +547,12 @@ abstract class AppL10n {
/// **'Bind PTT key'**
String get voiceBindKeyAction;
/// No description provided for @voiceBoundKeyLabel.
///
/// In en, this message translates to:
/// **'Bound key'**
String get voiceBoundKeyLabel;
/// No description provided for @voicePttHoldHint.
///
/// In en, this message translates to:
@@ -565,6 +571,60 @@ abstract class AppL10n {
/// **'off'**
String get voiceMicOff;
/// No description provided for @voiceSheetTitle.
///
/// In en, this message translates to:
/// **'Voice'**
String get voiceSheetTitle;
/// No description provided for @audioOutputLabel.
///
/// In en, this message translates to:
/// **'Audio output'**
String get audioOutputLabel;
/// No description provided for @audioRouteSpeaker.
///
/// In en, this message translates to:
/// **'Speaker'**
String get audioRouteSpeaker;
/// No description provided for @audioRouteReceiver.
///
/// In en, this message translates to:
/// **'iPhone receiver'**
String get audioRouteReceiver;
/// No description provided for @audioRouteBluetooth.
///
/// In en, this message translates to:
/// **'Bluetooth'**
String get audioRouteBluetooth;
/// No description provided for @audioRouteWiredHeadset.
///
/// In en, this message translates to:
/// **'Wired headset'**
String get audioRouteWiredHeadset;
/// No description provided for @audioRouteCarAudio.
///
/// In en, this message translates to:
/// **'Car audio'**
String get audioRouteCarAudio;
/// No description provided for @audioRouteAirplay.
///
/// In en, this message translates to:
/// **'AirPlay'**
String get audioRouteAirplay;
/// No description provided for @audioRouteUnknown.
///
/// In en, this message translates to:
/// **'Unknown'**
String get audioRouteUnknown;
/// No description provided for @channelJoinFailedPermission.
///
/// In en, this message translates to:
@@ -266,6 +266,9 @@ class AppL10nEn extends AppL10n {
@override
String get voiceBindKeyAction => 'Bind PTT key';
@override
String get voiceBoundKeyLabel => 'Bound key';
@override
String get voicePttHoldHint => 'Hold the button';
@@ -275,6 +278,33 @@ class AppL10nEn extends AppL10n {
@override
String get voiceMicOff => 'off';
@override
String get voiceSheetTitle => 'Voice';
@override
String get audioOutputLabel => 'Audio output';
@override
String get audioRouteSpeaker => 'Speaker';
@override
String get audioRouteReceiver => 'iPhone receiver';
@override
String get audioRouteBluetooth => 'Bluetooth';
@override
String get audioRouteWiredHeadset => 'Wired headset';
@override
String get audioRouteCarAudio => 'Car audio';
@override
String get audioRouteAirplay => 'AirPlay';
@override
String get audioRouteUnknown => 'Unknown';
@override
String get channelJoinFailedPermission =>
'Insufficient permission to join this channel.';
@@ -260,6 +260,9 @@ class AppL10nZh extends AppL10n {
@override
String get voiceBindKeyAction => '绑定 PTT 按键';
@override
String get voiceBoundKeyLabel => '已绑定按键';
@override
String get voicePttHoldHint => '按住按钮';
@@ -269,6 +272,33 @@ class AppL10nZh extends AppL10n {
@override
String get voiceMicOff => '关闭';
@override
String get voiceSheetTitle => '语音';
@override
String get audioOutputLabel => '音频输出';
@override
String get audioRouteSpeaker => '扬声器';
@override
String get audioRouteReceiver => '听筒';
@override
String get audioRouteBluetooth => '蓝牙';
@override
String get audioRouteWiredHeadset => '有线耳机';
@override
String get audioRouteCarAudio => '车载音频';
@override
String get audioRouteAirplay => 'AirPlay';
@override
String get audioRouteUnknown => '未知';
@override
String get channelJoinFailedPermission => '权限不足,无法加入此频道。';
+135 -44
View File
@@ -563,31 +563,18 @@ class _BetaHomeState extends State<_BetaHome> {
}
Future<String?> _askChannelPassword(AppL10n l10n) async {
final ctl = TextEditingController();
final result = await showDialog<String>(
// Same pattern as _onAddCurrentBookmark: route the dialog
// through a dedicated StatefulWidget so its
// TextEditingController is disposed at unmount time, not
// synchronously after `await showDialog` resumes. Inline
// dispose-after-await caused framework.dart:6268
// _dependents.isEmpty assertions on iOS \u2014 the controller was
// torn out while EditableText still depended on InheritedWidgets
// belonging to the still-popping dialog route.
return await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.channelPasswordTitle),
content: TextField(
controller: ctl,
obscureText: true,
autofocus: true,
decoration: InputDecoration(labelText: l10n.fieldPassword),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(ctl.text),
child: Text(l10n.connectAction),
),
],
),
builder: (ctx) => const _ChannelPasswordDialog(),
);
ctl.dispose();
return result;
}
Future<void> _onRefresh() async {
@@ -794,31 +781,33 @@ class _BetaHomeState extends State<_BetaHome> {
}
Future<void> _onAddCurrentBookmark() async {
final l10n = AppL10n.of(context);
final nameCtl = TextEditingController(text: _hostCtl.text.trim());
// Use the dialog's own context for AppL10n.of(...) inside its
// builder. Capturing the outer _HomePageState context's l10n in
// a closure and reading it inside the dialog's widget tree
// caused the dialog's TextField to depend on InheritedElements
// (Localizations / _LocalizationsScope) that belong to the
// outer route. When the dialog popped, the framework
// deactivated the dialog route's elements first while those
// outer-route InheritedElements were still alive but had
// dependents from the disposed dialog tree \u2014 producing the
// assertion 'package:flutter/src/widgets/framework.dart line
// 6268 _dependents.isEmpty is not true'.
//
// Also: dispose the TextEditingController via the dialog's own
// StatefulBuilder lifecycle instead of an inline dispose() right
// after showDialog returns. The inline dispose runs synchronously
// before the dialog route is fully torn down (the route pop
// animation is still mid-flight on iOS), and tearing the
// controller out from under EditableText while it has a live
// InheritedWidget dependency was the second trigger for the
// same assertion.
final initial = _hostCtl.text.trim();
final name = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.bookmarkAddTitle),
content: TextField(
controller: nameCtl,
autofocus: true,
decoration: InputDecoration(labelText: l10n.fieldDisplayName),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(nameCtl.text),
child: Text(l10n.bookmarkAddAction),
),
],
),
builder: (ctx) => _BookmarkNameDialog(initialName: initial),
);
nameCtl.dispose();
if (name == null || name.trim().isEmpty) return;
if (!mounted) return;
try {
await rust.addBookmark(
b: rust.BridgeBookmark(
@@ -1238,6 +1227,108 @@ class _AppBarTitle extends StatelessWidget {
}
}
/// 'Save bookmark' name-entry dialog. Owns its own
/// TextEditingController via a StatefulWidget lifecycle so the
/// dispose() runs cleanly at unmount time (after the route pop
/// animation has fully detached the dialog subtree), not
/// synchronously from the caller's `await showDialog` resumption
/// point.
///
/// Inline dispose-after-showDialog pattern previously caused
/// 'framework.dart line 6268 _dependents.isEmpty' on iOS because
/// the controller was torn down mid-pop while EditableText still
/// had live InheritedWidget dependencies on the dialog route.
class _BookmarkNameDialog extends StatefulWidget {
const _BookmarkNameDialog({required this.initialName});
final String initialName;
@override
State<_BookmarkNameDialog> createState() => _BookmarkNameDialogState();
}
class _BookmarkNameDialogState extends State<_BookmarkNameDialog> {
late final TextEditingController _ctl =
TextEditingController(text: widget.initialName);
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return AlertDialog(
title: Text(l10n.bookmarkAddTitle),
content: TextField(
controller: _ctl,
autofocus: true,
decoration: InputDecoration(labelText: l10n.fieldDisplayName),
onSubmitted: (_) => Navigator.of(context).pop(_ctl.text),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_ctl.text),
child: Text(l10n.bookmarkAddAction),
),
],
);
}
}
/// Channel-password dialog. Same StatefulWidget pattern as
/// [_BookmarkNameDialog] to keep TextEditingController disposal
/// inside the dialog's own lifecycle and avoid the
/// framework.dart:6268 _dependents.isEmpty assertion.
class _ChannelPasswordDialog extends StatefulWidget {
const _ChannelPasswordDialog();
@override
State<_ChannelPasswordDialog> createState() =>
_ChannelPasswordDialogState();
}
class _ChannelPasswordDialogState extends State<_ChannelPasswordDialog> {
final TextEditingController _ctl = TextEditingController();
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return AlertDialog(
title: Text(l10n.channelPasswordTitle),
content: TextField(
controller: _ctl,
obscureText: true,
autofocus: true,
decoration: InputDecoration(labelText: l10n.fieldPassword),
onSubmitted: (_) => Navigator.of(context).pop(_ctl.text),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_ctl.text),
child: Text(l10n.connectAction),
),
],
);
}
}
class _ConnectForm extends StatefulWidget {
const _ConnectForm({
required this.hostCtl,
+41
View File
@@ -505,6 +505,47 @@ impl ChanoraSession {
cfg,
sup_inner,
});
// TS3 servers auto-place a newly-connected client into the
// server's default channel (or whichever channel the
// identity has a 'joined this channel last time' preference
// for). The protocol layer reports that channel id in the
// initial snapshot; we therefore consider the user 'in a
// channel' immediately after connect, without needing them
// to invoke voice_join manually.
//
// Without this auto-detect, the UI rendered the user as
// 'connected but not in a channel': no PTT button, no
// mic/headset AppBar icons, no voice modal sheet entry
// point. The user could see they were in the default
// channel in the channel tree, but had no way to talk
// because all voice controls were gated on _inChannel which
// was still false.
//
// We need to drop the inner lock before calling the public
// helpers (they re-lock self.inner). Scope the temporary.
drop(guard);
if let Some((_my_id, _channel_id)) = self.find_own_in(&snap).await {
self.voice_selector.set_in_channel(true);
self.emit_voice_state(true).await;
// Bring the audio engine up so the user can immediately
// hear other speakers + transmit on PTT. Tolerates
// failure the same way voice_join does: server-side
// we're in the channel regardless; if audio fails
// (missing mic permission, no device), the UI will
// still expose the controls and the user can resolve
// the underlying issue.
if let Err(audio_err) = self.ensure_audio_running().await {
warn!(
target: "chanora_core",
error = %audio_err,
"auto-join default channel: server placed us in a channel but audio engine \
failed to start; continuing with no-audio in-channel state"
);
let _ = self.events_tx.send(SessionEvent::AudioStopped);
}
}
Ok(snap)
}