feat: multi-platform bug fixes, Android audio path, and build tooling

Flutter UI fixes:
- Fix stale channel badge/speaker when moved by others (derive current
  channel from ownClientId instead of optimistic local state)
- Fix Linux PTT via focused fallback key handler
- Distinguish ServerQuery clients with terminal icon in client list
- Reduce duplicate current-channel badge display
- Prevent PTT key-bind save from permanently closing voice settings
- Fix Linux GTK reopen-after-close (quit app on window destroy)
- Fix focused PTT: consume key events, release held keys on
  disconnect/leave-channel/mode/backend changes, suppress stale errors

Flutter Rust bridge:
- Thread is_server_query flag through protocol→bridge→Dart
- Add own_client_id to BridgeSnapshot DTO
- Add log_file_path_str() for platform log path queries

Rust protocol:
- Add ServerQuery test coverage (query_client_type_maps_to_server_query_flag)
- Split reqwest TLS: native-tls for desktop/iOS, rustls for Android

Rust audio:
- Upgrade cpal 0.16→0.17.3 with API adjustments (SampleRate, description())
- Suppress Android-only dead-code warnings (open_log_file, keyring_account)

Android build tooling:
- tools/build-opus-android.sh: NDK auto-discovery, correct CMake
  Android variables (ANDROID_ABI, ANDROID_PLATFORM), portable baseline
- tools/build-android-rust.sh: build+copy Rust cdylib for arm64-v8a,
  armeabi-v7a, x86_64 into android/app/src/main/jniLibs/
- Add jniLibs/ to .gitignore

Rust bridge:
- Guard open_log_file() on non-Android (Android uses logcat)
This commit is contained in:
Edison Jwa
2026-05-18 00:13:21 +09:00
parent 00692b76a2
commit dc9c5c0a4e
17 changed files with 760 additions and 208 deletions
+9 -12
View File
@@ -15,7 +15,7 @@ thiserror.workspace = true
tracing.workspace = true
# Cross-platform audio I/O (DEC-011.1).
cpal = "0.16"
cpal = "0.17.3"
# Opus encoder. tsclientlib already pulls this; we depend explicitly so
# this crate can compile against it without going through tsclientlib.
audiopus = "0.3.0-rc.0"
@@ -24,19 +24,13 @@ audiopus = "0.3.0-rc.0"
# feature. We import the crate just for the AudioHandler type; the
# Connection type stays inside chanora_protocol.
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
# Force the reqwest TLS backend to native-tls (Security.framework on
# Apple, SChannel on Windows, system OpenSSL on Linux/BSD) instead of
# the rustls + aws-lc-rs combination tsclientlib's `default-tls`
# feature would otherwise pick. aws-lc-sys does not cross-compile
# cleanly to aarch64-apple-ios, and native-tls is the standard
# answer for "TLS that just works on every desktop+mobile platform
# without a vendored C dependency". Cargo's feature unification means
# this single direct dep applies to the transitive
# tsclientlib -> reqwest chain too.
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
[target.'cfg(not(target_os = "android"))'.dependencies]
# Desktop/iOS: native TLS maps to the platform TLS backend (Security.framework
# on Apple, SChannel on Windows, system OpenSSL on Linux/BSD).
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
[target.'cfg(target_os = "ios")'.dependencies]
# Direct CoreAudio AudioUnit access on iOS (DEC-011.x follow-up).
# cpal's iOS backend is unsuitable for VoIP: it opens
@@ -59,6 +53,9 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
coreaudio-rs = "0.14"
[target.'cfg(target_os = "android")'.dependencies]
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
# native-tls for Apple targets where aws-lc/rustls is problematic for iOS.
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] }
# JNI bindings to flip Android's AudioManager into MODE_IN_COMMUNICATION
# when the voice-comm preset is requested. ndk_context is initialised
# by the bridge crate's android_init shim.
+7 -7
View File
@@ -251,8 +251,8 @@ impl AudioEngine {
info!(
target: "chanora_audio",
in_device = %in_dev.name().unwrap_or_default(),
out_device = %out_dev.name().unwrap_or_default(),
in_device = %in_dev.description().map(|d| d.name().to_owned()).unwrap_or_default(),
out_device = %out_dev.description().map(|d| d.name().to_owned()).unwrap_or_default(),
"starting audio engine"
);
@@ -270,7 +270,7 @@ impl AudioEngine {
Ok(c) => info!(
target: "chanora_audio",
channels = c.channels(),
sample_rate = c.sample_rate().0,
sample_rate = c.sample_rate(),
sample_format = ?c.sample_format(),
"default_input_config reported"
),
@@ -284,7 +284,7 @@ impl AudioEngine {
Ok(c) => info!(
target: "chanora_audio",
channels = c.channels(),
sample_rate = c.sample_rate().0,
sample_rate = c.sample_rate(),
sample_format = ?c.sample_format(),
"default_output_config reported"
),
@@ -409,7 +409,7 @@ impl AudioEngine {
.default_output_config()
.map_err(|e| AudioError::StreamConfig(format!("output default: {e}")))?;
let out_format = out_cfg.sample_format();
let dev_sample_rate = out_cfg.sample_rate().0;
let dev_sample_rate = out_cfg.sample_rate();
let dev_channels = out_cfg.channels() as usize;
// Buffer-size rationale:
// * Windows (WASAPI via cpal): the default period is
@@ -840,7 +840,7 @@ fn try_open_capture(
let in_cfg = in_dev
.default_input_config()
.map_err(|e| AudioError::StreamConfig(format!("input default: {e}")))?;
let in_sample_rate = in_cfg.sample_rate().0;
let in_sample_rate = in_cfg.sample_rate();
let in_channels = in_cfg.channels() as usize;
let in_format = in_cfg.sample_format();
// Buffer-size rationale (same shape as the output path):
@@ -1339,7 +1339,7 @@ where
target: "chanora_audio",
error = %e,
requested_channels = config.channels,
requested_sample_rate = config.sample_rate.0,
requested_sample_rate = config.sample_rate,
"build_output_stream FAILED"
);
AudioError::Backend(format!("build_output_stream: {e}"))
+4
View File
@@ -217,6 +217,7 @@ fn log_file_path() -> Option<std::path::PathBuf> {
/// Open the platform log file in append mode, rotating once on
/// startup so each launch begins with a fresh file. Best-effort:
/// returns `None` on any I/O error.
#[cfg(not(target_os = "android"))]
fn open_log_file() -> Option<std::fs::File> {
let path = log_file_path()?;
if let Some(parent) = path.parent() {
@@ -278,6 +279,8 @@ pub struct BridgeClient {
pub channel: u64,
/// Nickname.
pub name: String,
/// True for TeamSpeak ServerQuery clients.
pub is_server_query: bool,
}
/// Server snapshot as seen by Dart.
@@ -325,6 +328,7 @@ impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
id: c.id.0,
channel: c.channel.0,
name: c.name,
is_server_query: c.is_server_query,
})
.collect(),
own_client_id: s.own_client_id,
@@ -1290,10 +1290,12 @@ impl SseDecode for crate::api::BridgeClient {
let mut var_id = <u64>::sse_decode(deserializer);
let mut var_channel = <u64>::sse_decode(deserializer);
let mut var_name = <String>::sse_decode(deserializer);
let mut var_isServerQuery = <bool>::sse_decode(deserializer);
return crate::api::BridgeClient {
id: var_id,
channel: var_channel,
name: var_name,
is_server_query: var_isServerQuery,
};
}
}
@@ -1721,6 +1723,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeClient {
self.id.into_into_dart().into_dart(),
self.channel.into_into_dart().into_dart(),
self.name.into_into_dart().into_dart(),
self.is_server_query.into_into_dart().into_dart(),
]
.into_dart()
}
@@ -1999,6 +2002,7 @@ impl SseEncode for crate::api::BridgeClient {
<u64>::sse_encode(self.id, serializer);
<u64>::sse_encode(self.channel, serializer);
<String>::sse_encode(self.name, serializer);
<bool>::sse_encode(self.is_server_query, serializer);
}
}
+11 -11
View File
@@ -19,23 +19,23 @@ once_cell = "1"
# pulls in `audiopus` only — `sdl2` is a dev-dep used by upstream
# examples; the library itself does not link SDL.
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
# Force the reqwest TLS backend to native-tls (Security.framework on
# Apple, SChannel on Windows, system OpenSSL on Linux/BSD) instead of
# the rustls + aws-lc-rs combination tsclientlib's `default-tls`
# feature would otherwise pick. aws-lc-sys does not cross-compile
# cleanly to aarch64-apple-ios, and native-tls is the standard
# answer for "TLS that just works on every desktop+mobile platform
# without a vendored C dependency". Cargo's feature unification means
# this single direct dep applies to the transitive
# tsclientlib -> reqwest chain too.
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
# tsproto_packets exposes OutAudio / InAudioBuf / AudioData /
# CodecType / Direction. Pinning to the same git rev as tsclientlib
# avoids any version-skew confusion.
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" }
tsproto-types = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" }
# Async runtime utilities used by the connection task.
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
futures = "0.3"
async-trait = "0.1"
[target.'cfg(not(target_os = "android"))'.dependencies]
# Desktop/iOS: native TLS maps to the platform TLS backend (Security.framework
# on Apple, SChannel on Windows, system OpenSSL on Linux/BSD).
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
[target.'cfg(target_os = "android")'.dependencies]
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
# native-tls for Apple targets where aws-lc/rustls is problematic for iOS.
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] }
+15 -1
View File
@@ -31,6 +31,7 @@ use tsclientlib::{
OutCommandExt, StreamItem, Version,
};
use tsproto_packets::packets::{InAudioBuf, OutPacket};
use tsproto_types::ClientType;
use crate::dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
use crate::ProtocolError;
@@ -872,6 +873,7 @@ fn build_snapshot(con: &Connection) -> Result<ServerSnapshot, ProtocolError> {
id: ClientId(c.id.0 as u64),
channel: ChannelId(c.channel.0),
name: sanitize(&c.name),
is_server_query: is_server_query_client_type(&c.client_type),
})
.collect();
@@ -886,6 +888,10 @@ fn build_snapshot(con: &Connection) -> Result<ServerSnapshot, ProtocolError> {
})
}
fn is_server_query_client_type(client_type: &ClientType) -> bool {
matches!(client_type, ClientType::Query { .. })
}
/// Light sanitisation of strings before they cross the protocol
/// boundary. The redaction policy proper lives in
/// `chanora_diagnostics`; this filter only strips control characters
@@ -905,7 +911,8 @@ const _ROOT_MATCHES_UPSTREAM: () = {
#[cfg(test)]
mod tests {
use super::sort_channels_tree_by;
use super::{is_server_query_client_type, sort_channels_tree_by};
use tsproto_types::ClientType;
/// Lightweight fixture mirroring just the (id, parent, order)
/// triple that the linked-list sort needs. Avoids constructing
@@ -922,6 +929,13 @@ mod tests {
(c.id, c.parent, c.order)
}
#[test]
fn query_client_type_maps_to_server_query_flag() {
assert!(!is_server_query_client_type(&ClientType::Normal));
assert!(is_server_query_client_type(&ClientType::Query { admin: false }));
assert!(is_server_query_client_type(&ClientType::Query { admin: true }));
}
#[test]
fn channel_sort_linked_list_under_one_parent() {
// Server emits four root-level channels in arbitrary HashMap
+2
View File
@@ -34,6 +34,8 @@ pub struct ClientInfo {
pub channel: ChannelId,
/// Nickname, preserved verbatim per ADR-008.
pub name: String,
/// True for TeamSpeak ServerQuery clients.
pub is_server_query: bool,
}
/// Snapshot of the server's published state at a moment in time.
+1
View File
@@ -173,6 +173,7 @@ pub struct IdentityFileStore {
/// Stable per-install identifier used as the keyring account
/// name. Derived from the install directory so the same store
/// finds the same keyring entry across restarts.
#[cfg_attr(target_os = "android", allow(dead_code))]
keyring_account: String,
}