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
+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)
}