diff --git a/apps/chanora_flutter/ios/Runner/Info.plist b/apps/chanora_flutter/ios/Runner/Info.plist
index b1ba0bb..590b676 100644
--- a/apps/chanora_flutter/ios/Runner/Info.plist
+++ b/apps/chanora_flutter/ios/Runner/Info.plist
@@ -72,5 +72,14 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
+
+ UIFileSharingEnabled
+
+ LSSupportsOpeningDocumentsInPlace
+
diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs
index f5d7858..52e2523 100644
--- a/core/chanora_core/src/lib.rs
+++ b/core/chanora_core/src/lib.rs
@@ -918,8 +918,36 @@ impl ChanoraSession {
self.emit_voice_state(false).await;
return Err(e);
}
- // 2. Bring the audio engine up.
- self.ensure_audio_running().await?;
+ // 2. Bring the audio engine up. Tolerate failure: the
+ // server-side channel move has ALREADY succeeded (step
+ // 1), so the user is in the channel from every other
+ // peer's perspective. Failing voice_join hard here would
+ // leave the UI in a phantom "you're in the channel
+ // visually but no controls" state because the calling
+ // Dart code wouldn't receive the VoiceState(true) event
+ // that gates the AppBar mute icons + the on-screen PTT
+ // button. Honest behaviour: surface the audio error
+ // once on the event channel (via the AudioStopped event
+ // consumers already handle), then continue so the UI
+ // matches reality — user is in the channel, but mic /
+ // speakers may be silent until they resolve the audio
+ // error (e.g. grant mic permission, plug in a working
+ // device).
+ if let Err(audio_err) = self.ensure_audio_running().await {
+ warn!(
+ target: "chanora_core",
+ error = %audio_err,
+ channel_id,
+ "voice_join: server move succeeded but audio engine \
+ failed to start; continuing with no-audio in-channel \
+ state so the UI matches the server-side state"
+ );
+ // Tell subscribers the audio engine is not running so
+ // any audio-stats poll / level meter renders correctly.
+ // The voice_join itself still resolves Ok below so the
+ // UI gains the channel + mute + PTT controls.
+ let _ = self.events_tx.send(SessionEvent::AudioStopped);
+ }
// 3. Belt-and-braces: if the server replied Ok but never
// actually moved us (legacy server, command processed
// but rolled back later, etc.) the snapshot poll catches
diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs
index 7bf429d..cc4ebdf 100644
--- a/crates/chanora_bridge/src/api.rs
+++ b/crates/chanora_bridge/src/api.rs
@@ -180,8 +180,31 @@ fn log_file_path() -> Option {
.join("chanora.log"),
)
}
- #[cfg(any(target_os = "android", target_os = "ios"))]
+ #[cfg(target_os = "ios")]
{
+ // iOS sandbox: write the log to the app's Documents
+ // directory so it persists across launches and can be
+ // pulled via Xcode -> Devices and Simulators -> Download
+ // Container, OR via Files.app on the device (the app
+ // appears under "On My iPhone" once we declare
+ // UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace
+ // in Info.plist — done in a follow-up).
+ //
+ // HOME on iOS resolves to the app sandbox root; Documents
+ // is the standard user-visible subdirectory.
+ let home = std::env::var_os("HOME")?;
+ Some(
+ std::path::PathBuf::from(home)
+ .join("Documents")
+ .join("chanora.log"),
+ )
+ }
+ #[cfg(target_os = "android")]
+ {
+ // Android log file location is set up via the bridge's
+ // Java-side init that writes the chosen path into an env
+ // var (not currently wired; P1 follow-up). For now we
+ // return None and rely on logcat.
None
}
}