From dc9c5c0a4e61d5f8a7cbd1d33f2ce428c573607a Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 18 May 2026 00:13:21 +0900 Subject: [PATCH] feat: multi-platform bug fixes, Android audio path, and build tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Cargo.lock | 399 ++++++++++++++++-- apps/chanora_flutter/android/.gitignore | 1 + apps/chanora_flutter/lib/main.dart | 266 ++++++------ apps/chanora_flutter/lib/src/rust/api.dart | 10 +- .../lib/src/rust/frb_generated.dart | 14 +- .../linux/runner/my_application.cc | 10 +- core/chanora_core/src/lib.rs | 1 + crates/chanora_audio/Cargo.toml | 21 +- crates/chanora_audio/src/engine.rs | 14 +- crates/chanora_bridge/src/api.rs | 4 + crates/chanora_bridge/src/frb_generated.rs | 4 + crates/chanora_protocol/Cargo.toml | 22 +- crates/chanora_protocol/src/adapter.rs | 16 +- crates/chanora_protocol/src/dto.rs | 2 + crates/chanora_storage/src/lib.rs | 1 + tools/build-android-rust.sh | 75 ++++ tools/build-opus-android.sh | 108 +++++ 17 files changed, 760 insertions(+), 208 deletions(-) create mode 100755 tools/build-android-rust.sh create mode 100755 tools/build-opus-android.sh diff --git a/Cargo.lock b/Cargo.lock index 5c7db61..8e850f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,9 +72,9 @@ dependencies = [ [[package]] name = "alsa" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", "bitflags 2.11.1", @@ -84,9 +84,9 @@ dependencies = [ [[package]] name = "alsa-sys" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", @@ -193,6 +193,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -256,6 +278,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "build-target" version = "0.4.0" @@ -293,6 +324,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -308,6 +341,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -349,7 +388,7 @@ version = "0.2.0-beta.1" dependencies = [ "audiopus", "chanora_protocol", - "coreaudio-rs 0.14.2", + "coreaudio-rs", "cpal", "futures-util", "jni 0.21.1", @@ -362,7 +401,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tsclientlib", - "windows", + "windows 0.54.0", "zbus", ] @@ -422,6 +461,7 @@ dependencies = [ "tracing", "tsclientlib", "tsproto-packets", + "tsproto-types", ] [[package]] @@ -540,20 +580,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "coreaudio-rs" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" -dependencies = [ - "bitflags 1.3.2", - "libc", - "objc2-audio-toolbox", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", -] - [[package]] name = "coreaudio-rs" version = "0.14.2" @@ -570,12 +596,12 @@ dependencies = [ [[package]] name = "cpal" -version = "0.16.0" +version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" dependencies = [ "alsa", - "coreaudio-rs 0.13.0", + "coreaudio-rs", "dasp_sample", "jni 0.21.1", "js-sys", @@ -585,13 +611,17 @@ dependencies = [ "ndk-context", "num-derive", "num-traits", + "objc2", "objc2-audio-toolbox", + "objc2-avf-audio", "objc2-core-audio", "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows", + "windows 0.62.2", ] [[package]] @@ -846,6 +876,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "eax" version = "0.5.0" @@ -1095,6 +1131,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -1214,8 +1256,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -1226,7 +1284,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", @@ -1784,6 +1842,16 @@ dependencies = [ "syn", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.98" @@ -1888,10 +1956,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "mach2" -version = "0.4.3" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" dependencies = [ "libc", ] @@ -2145,6 +2219,16 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-audio" version = "0.3.2" @@ -2155,6 +2239,7 @@ dependencies = [ "objc2", "objc2-core-audio-types", "objc2-core-foundation", + "objc2-foundation", ] [[package]] @@ -2174,7 +2259,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.1", + "block2", "dispatch2", + "libc", "objc2", ] @@ -2190,7 +2277,11 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", "objc2", + "objc2-core-foundation", ] [[package]] @@ -2475,6 +2566,62 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2484,6 +2631,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -2497,10 +2650,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -2522,6 +2685,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2531,6 +2704,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -2619,10 +2801,14 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", + "rustls-platform-verifier", "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -2682,6 +2868,12 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2710,6 +2902,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -2717,21 +2910,62 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3872,6 +4106,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "widestring" version = "1.2.1" @@ -3893,10 +4146,31 @@ version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" dependencies = [ - "windows-core", + "windows-core 0.54.0", "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.54.0" @@ -3907,12 +4181,68 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -4035,6 +4365,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" diff --git a/apps/chanora_flutter/android/.gitignore b/apps/chanora_flutter/android/.gitignore index be3943c..e63b922 100644 --- a/apps/chanora_flutter/android/.gitignore +++ b/apps/chanora_flutter/android/.gitignore @@ -12,3 +12,4 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks +/app/src/main/jniLibs/ diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 5243be7..51ac6ba 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -32,6 +32,48 @@ bool get _isMacOS => !kIsWeb && Platform.isMacOS; /// Top padding for macOS to clear traffic-light buttons. const double _macOSTrafficLightPad = 56.0; +/// Translate a [LogicalKeyboardKey] into the platform-neutral label +/// stored by the PTT binding flow. +String? _pttDisplayLabelForKey(LogicalKeyboardKey k) { + if (k == LogicalKeyboardKey.space) return 'Space'; + if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) { + return 'Enter'; + } + if (k == LogicalKeyboardKey.tab) return 'Tab'; + if (k == LogicalKeyboardKey.escape) return 'Escape'; + if (k == LogicalKeyboardKey.backspace) return 'Backspace'; + if (k == LogicalKeyboardKey.delete) return 'Delete'; + if (k == LogicalKeyboardKey.insert) return 'Insert'; + if (k == LogicalKeyboardKey.home) return 'Home'; + if (k == LogicalKeyboardKey.end) return 'End'; + if (k == LogicalKeyboardKey.pageUp) return 'Page Up'; + if (k == LogicalKeyboardKey.pageDown) return 'Page Down'; + if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up'; + if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down'; + if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left'; + if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right'; + if (k == LogicalKeyboardKey.shift || + k == LogicalKeyboardKey.shiftLeft || + k == LogicalKeyboardKey.shiftRight || + k == LogicalKeyboardKey.control || + k == LogicalKeyboardKey.controlLeft || + k == LogicalKeyboardKey.controlRight || + k == LogicalKeyboardKey.alt || + k == LogicalKeyboardKey.altLeft || + k == LogicalKeyboardKey.altRight || + k == LogicalKeyboardKey.meta || + k == LogicalKeyboardKey.metaLeft || + k == LogicalKeyboardKey.metaRight || + k == LogicalKeyboardKey.capsLock || + k == LogicalKeyboardKey.numLock || + k == LogicalKeyboardKey.scrollLock) { + return null; + } + final fallback = k.keyLabel.trim(); + if (fallback.isEmpty) return null; + return fallback; +} + /// True when the host is a touch-only mobile platform without a /// hardware keyboard. Mirrors the helpers in widgets/voice_bar.dart /// and widgets/voice_settings.dart so the AppBar + narrow-mode @@ -241,17 +283,50 @@ class _BetaHomeState extends State<_BetaHome> { // only the platform-neutral label that already crossed into // the Rust side. String _pttBoundKeyLabel = ''; + final Set _focusedPttHeldKeys = {}; List _bookmarks = const []; @override void initState() { super.initState(); + HardwareKeyboard.instance.addHandler(_handleFocusedPttKey); _eventsSub = rust.eventsStream().listen(_onEvent); unawaited(_reloadBookmarks()); unawaited(_hydratePttBinding()); } + bool _handleFocusedPttKey(KeyEvent event) { + final label = _pttDisplayLabelForKey(event.logicalKey); + final isBoundKey = _pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel; + if (event is KeyUpEvent && _focusedPttHeldKeys.contains(event.logicalKey)) { + if (_focusedPttHeldKeys.remove(event.logicalKey)) { + unawaited(_setPtt(false)); + } + return true; + } + if (_pttBackendId != 'focused' || + _phase != _Phase.connected || + !_inChannel || + _transmitMode != rust.BridgeTransmitMode.ptt || + !isBoundKey) { + return false; + } + if (event is KeyDownEvent) { + if (_focusedPttHeldKeys.add(event.logicalKey)) { + unawaited(_setPtt(true)); + } + return true; + } + return false; + } + + void _releaseFocusedPttIfHeld() { + if (_focusedPttHeldKeys.isEmpty) return; + _focusedPttHeldKeys.clear(); + unawaited(_setPtt(false, reportError: false)); + } + /// Hydrate the bound-key display state from the bridge so the /// Voice Bar shows the user's persisted hotkey label immediately /// at launch — without having to wait for them to open the @@ -326,6 +401,9 @@ class _BetaHomeState extends State<_BetaHome> { _pttBackendId = backendId; _pttBoundInputClass = boundInputClass; }); + if (backendId != 'focused') { + _releaseFocusedPttIfHeld(); + } case rust.BridgeEvent_VoiceState( :final inChannel, :final transmitMode, @@ -341,9 +419,15 @@ class _BetaHomeState extends State<_BetaHome> { }); if (inChannel) { _ensureStatsTimer(); + unawaited(_onRefresh()); } else { _statsTimer?.cancel(); _statsTimer = null; + _releaseFocusedPttIfHeld(); + setState(() => _currentVoiceChannelId = null); + } + if (transmitMode != rust.BridgeTransmitMode.ptt) { + _releaseFocusedPttIfHeld(); } case rust.BridgeEvent_InterruptionState( :final began, @@ -403,6 +487,7 @@ class _BetaHomeState extends State<_BetaHome> { @override void dispose() { + HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey); _eventsSub?.cancel(); _statsTimer?.cancel(); _hostCtl.dispose(); @@ -421,6 +506,7 @@ class _BetaHomeState extends State<_BetaHome> { _error = null; _snapshot = null; }); + _releaseFocusedPttIfHeld(); try { final snap = await rust.connect( host: (host ?? _hostCtl.text).trim(), @@ -430,7 +516,7 @@ class _BetaHomeState extends State<_BetaHome> { if (!mounted) return; setState(() { _phase = _Phase.connected; - _snapshot = snap; + _applySnapshot(snap); }); } catch (e) { if (!mounted) return; @@ -483,11 +569,11 @@ class _BetaHomeState extends State<_BetaHome> { } // ignore: unused_element - Future _setPtt(bool active) async { + Future _setPtt(bool active, {bool reportError = true}) async { try { await rust.setPtt(active: active); } catch (e) { - if (!mounted) return; + if (!mounted || !reportError) return; setState(() => _error = e.toString()); } } @@ -719,6 +805,9 @@ class _BetaHomeState extends State<_BetaHome> { } if (result.bindKeyRequested && mounted) { await _onConfigurePtt(context); + if (mounted) { + unawaited(_onOpenVoiceSettings()); + } } } @@ -741,7 +830,7 @@ class _BetaHomeState extends State<_BetaHome> { try { final snap = await rust.snapshot(); if (!mounted) return; - setState(() => _snapshot = snap); + setState(() => _applySnapshot(snap)); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); @@ -766,6 +855,7 @@ class _BetaHomeState extends State<_BetaHome> { _inChannel = false; _currentVoiceChannelId = null; }); + _releaseFocusedPttIfHeld(); } String _currentVoiceChannelName() { @@ -778,6 +868,21 @@ class _BetaHomeState extends State<_BetaHome> { return ''; } + 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 _onShowDiagnostics(BuildContext context) async { final l10n = AppL10n.of(context); final text = rust.exportDiagnostics(); @@ -1045,11 +1150,7 @@ class _BetaHomeState extends State<_BetaHome> { ], ]; - final headerTitle = _AppBarTitle( - phase: _phase, - inChannel: _inChannel, - channelName: _currentVoiceChannelName(), - ); + const headerTitle = _AppBarTitle(); final bodyContent = LayoutBuilder( builder: (ctx, bodyConstraints) { @@ -1260,81 +1361,16 @@ class _BetaHomeState extends State<_BetaHome> { } } -/// AppBar title that shows just the app name when idle / connecting, -/// and 'app name · #channel' (channel as an outlined chip-style -/// pill) when the user is in a voice channel. The chip is read- -/// only — tapping does nothing because the source of truth for -/// "current channel" is the channel tree below. Long names ellipsize. +/// App header title. The current channel is highlighted only in the +/// channel tree and Voice Bar to avoid repeating the same badge in +/// multiple places. class _AppBarTitle extends StatelessWidget { - const _AppBarTitle({ - required this.phase, - required this.inChannel, - required this.channelName, - }); - - final _Phase phase; - final bool inChannel; - final String channelName; + const _AppBarTitle(); @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); - final theme = Theme.of(context); - if (phase != _Phase.connected || !inChannel || channelName.isEmpty) { - return Text(l10n.appTitle); - } - // When in a voice channel on a narrow phone width, the AppBar - // is already crowded with mic / headset / settings / about / - // diagnostics / disconnect icons (5-6 action buttons). Keeping - // the "Chanora" app-name label in the title here causes the - // Row to overflow at typical iPhone widths and Flutter renders - // its yellow-and-black "OVERFLOWED BY X PIXELS" debug strip - // next to the title — the user reported seeing "RFLOWED BY" - // there. Drop the app-name label on narrow widths and let the - // channel pill be the only title content; the user knows - // they're in Chanora because they just opened it. On wide - // widths (>= 840 dp, tablet/desktop) restore the app name - // because there's plenty of room. - final isNarrow = MediaQuery.of(context).size.width < 840.0; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isNarrow) ...[Text(l10n.appTitle), const SizedBox(width: 12)], - Flexible( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.tag, - size: 14, - color: theme.colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - channelName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - ), - ], - ), - ), - ), - ], - ); + return Text(l10n.appTitle); } } @@ -1995,8 +2031,16 @@ class _SnapshotView extends StatelessWidget { child: ListTile( dense: true, visualDensity: VisualDensity.compact, - leading: const Icon(Icons.person, size: 18), - title: Text(cl.name), + leading: Icon( + cl.isServerQuery ? Icons.terminal : Icons.person, + size: 18, + ), + title: Text( + cl.name, + style: cl.isServerQuery + ? TextStyle(color: theme.colorScheme.onSurfaceVariant) + : null, + ), ), ), ], @@ -2049,7 +2093,7 @@ class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> { KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; - final label = _displayLabelForKey(event.logicalKey); + final label = _pttDisplayLabelForKey(event.logicalKey); if (label == null) return KeyEventResult.ignored; setState(() { _captured = label; @@ -2058,64 +2102,6 @@ class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> { return KeyEventResult.handled; } - /// Translate a [LogicalKeyboardKey] into the platform-neutral - /// label string the bridge expects (matching the entries in - /// `crates/chanora_audio/src/ptt_backends/windows_keymap.rs`). - /// - /// `LogicalKeyboardKey.keyLabel` returns `" "` for Space, empty - /// for pure modifiers (shift / ctrl / alt / meta), and localised - /// strings for some special keys; we normalise to the canonical - /// English label so the badge displays something readable AND so - /// the Windows backend's keymap can resolve to a `VK_*`. Pure - /// modifier keys are intentionally rejected — chording into the - /// real binding (e.g. Ctrl+Shift+M) is supported by ignoring the - /// individual modifier-down events. - String? _displayLabelForKey(LogicalKeyboardKey k) { - // Whitespace / common control keys whose keyLabel is unhelpful. - if (k == LogicalKeyboardKey.space) return 'Space'; - if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) { - return 'Enter'; - } - if (k == LogicalKeyboardKey.tab) return 'Tab'; - if (k == LogicalKeyboardKey.escape) return 'Escape'; - if (k == LogicalKeyboardKey.backspace) return 'Backspace'; - if (k == LogicalKeyboardKey.delete) return 'Delete'; - if (k == LogicalKeyboardKey.insert) return 'Insert'; - if (k == LogicalKeyboardKey.home) return 'Home'; - if (k == LogicalKeyboardKey.end) return 'End'; - if (k == LogicalKeyboardKey.pageUp) return 'Page Up'; - if (k == LogicalKeyboardKey.pageDown) return 'Page Down'; - if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up'; - if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down'; - if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left'; - if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right'; - // Pure modifier keys are not bindable on their own (user can - // still chord by pressing a non-modifier while holding them). - if (k == LogicalKeyboardKey.shift || - k == LogicalKeyboardKey.shiftLeft || - k == LogicalKeyboardKey.shiftRight || - k == LogicalKeyboardKey.control || - k == LogicalKeyboardKey.controlLeft || - k == LogicalKeyboardKey.controlRight || - k == LogicalKeyboardKey.alt || - k == LogicalKeyboardKey.altLeft || - k == LogicalKeyboardKey.altRight || - k == LogicalKeyboardKey.meta || - k == LogicalKeyboardKey.metaLeft || - k == LogicalKeyboardKey.metaRight || - k == LogicalKeyboardKey.capsLock || - k == LogicalKeyboardKey.numLock || - k == LogicalKeyboardKey.scrollLock) { - return null; - } - // Fall back to keyLabel for letters, digits, function keys, - // numpad digits, and punctuation. Trim whitespace as a final - // belt-and-braces guard. - final fallback = k.keyLabel.trim(); - if (fallback.isEmpty) return null; - return fallback; - } - void _captureMouseSideButton(int button) { setState(() { _captured = 'mouse-side-button:$button'; diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index e154883..161dc74 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -338,14 +338,19 @@ class BridgeClient { /// Nickname. final String name; + /// True for TeamSpeak ServerQuery clients. + final bool isServerQuery; + const BridgeClient({ required this.id, required this.channel, required this.name, + required this.isServerQuery, }); @override - int get hashCode => id.hashCode ^ channel.hashCode ^ name.hashCode; + int get hashCode => + id.hashCode ^ channel.hashCode ^ name.hashCode ^ isServerQuery.hashCode; @override bool operator ==(Object other) => @@ -354,7 +359,8 @@ class BridgeClient { runtimeType == other.runtimeType && id == other.id && channel == other.channel && - name == other.name; + name == other.name && + isServerQuery == other.isServerQuery; } @freezed diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index d158be7..e67071f 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -1157,12 +1157,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { BridgeClient dco_decode_bridge_client(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 3) - throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); + if (arr.length != 4) + throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); return BridgeClient( id: dco_decode_u_64(arr[0]), channel: dco_decode_u_64(arr[1]), name: dco_decode_String(arr[2]), + isServerQuery: dco_decode_bool(arr[3]), ); } @@ -1454,7 +1455,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_id = sse_decode_u_64(deserializer); var var_channel = sse_decode_u_64(deserializer); var var_name = sse_decode_String(deserializer); - return BridgeClient(id: var_id, channel: var_channel, name: var_name); + var var_isServerQuery = sse_decode_bool(deserializer); + return BridgeClient( + id: var_id, + channel: var_channel, + name: var_name, + isServerQuery: var_isServerQuery, + ); } @protected @@ -1799,6 +1806,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_64(self.id, serializer); sse_encode_u_64(self.channel, serializer); sse_encode_String(self.name, serializer); + sse_encode_bool(self.isServerQuery, serializer); } @protected diff --git a/apps/chanora_flutter/linux/runner/my_application.cc b/apps/chanora_flutter/linux/runner/my_application.cc index f84c598..ec1f808 100644 --- a/apps/chanora_flutter/linux/runner/my_application.cc +++ b/apps/chanora_flutter/linux/runner/my_application.cc @@ -19,6 +19,10 @@ static void first_frame_cb(MyApplication* self, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); } +static void window_destroy_cb(GtkWidget* widget, gpointer user_data) { + g_application_quit(G_APPLICATION(user_data)); +} + // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); @@ -45,13 +49,15 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "chanora_flutter"); + gtk_header_bar_set_title(header_bar, "Chanora"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "chanora_flutter"); + gtk_window_set_title(window, "Chanora"); } + g_signal_connect(window, "destroy", G_CALLBACK(window_destroy_cb), application); + gtk_window_set_default_size(window, 1280, 720); g_autoptr(FlDartProject) project = fl_dart_project_new(); diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index c1db260..a131476 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -1735,6 +1735,7 @@ mod tests { id: chanora_protocol::ClientId(10), channel: chanora_protocol::ChannelId(1), name: "u".into(), + is_server_query: false, }], own_client_id: 10, }; diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml index 64eff23..cad9dd9 100644 --- a/crates/chanora_audio/Cargo.toml +++ b/crates/chanora_audio/Cargo.toml @@ -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. diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index c506b86..4489442 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -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}")) diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index a2135f6..428de0c 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -217,6 +217,7 @@ fn log_file_path() -> Option { /// 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 { 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 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, diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 2768c59..cadcda4 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -1290,10 +1290,12 @@ impl SseDecode for crate::api::BridgeClient { let mut var_id = ::sse_decode(deserializer); let mut var_channel = ::sse_decode(deserializer); let mut var_name = ::sse_decode(deserializer); + let mut var_isServerQuery = ::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 { ::sse_encode(self.id, serializer); ::sse_encode(self.channel, serializer); ::sse_encode(self.name, serializer); + ::sse_encode(self.is_server_query, serializer); } } diff --git a/crates/chanora_protocol/Cargo.toml b/crates/chanora_protocol/Cargo.toml index bb5db53..2c32d0e 100644 --- a/crates/chanora_protocol/Cargo.toml +++ b/crates/chanora_protocol/Cargo.toml @@ -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"] } diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index 16d0b54..0b9883a 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -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 { 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 { }) } +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 diff --git a/crates/chanora_protocol/src/dto.rs b/crates/chanora_protocol/src/dto.rs index 267adda..6ba4e85 100644 --- a/crates/chanora_protocol/src/dto.rs +++ b/crates/chanora_protocol/src/dto.rs @@ -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. diff --git a/crates/chanora_storage/src/lib.rs b/crates/chanora_storage/src/lib.rs index 71bb7db..56eb887 100644 --- a/crates/chanora_storage/src/lib.rs +++ b/crates/chanora_storage/src/lib.rs @@ -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, } diff --git a/tools/build-android-rust.sh b/tools/build-android-rust.sh new file mode 100755 index 0000000..6fe6499 --- /dev/null +++ b/tools/build-android-rust.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")/.." +REPO_ROOT="$(pwd)" + +NDK_ROOT="${ANDROID_NDK_HOME:-}" +if [[ -z "$NDK_ROOT" && -n "${ANDROID_HOME:-}" ]]; then + NDK_ROOT="$(find "$ANDROID_HOME/ndk" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort | tail -1 || true)" +fi +if [[ -z "$NDK_ROOT" && -n "${ANDROID_SDK_ROOT:-}" ]]; then + NDK_ROOT="$(find "$ANDROID_SDK_ROOT/ndk" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort | tail -1 || true)" +fi + +ANDROID_PLATFORM=28 +JNI_LIBS_DIR="$REPO_ROOT/apps/chanora_flutter/android/app/src/main/jniLibs" + +declare -a BUILDS=( + "arm64-v8a;aarch64-linux-android;/tmp/opus-android-arm64-v8a" + "armeabi-v7a;armv7-linux-androideabi;/tmp/opus-android-armeabi-v7a" + "x86_64;x86_64-linux-android;/tmp/opus-android-x86_64" +) + +bar() { printf '%s\n' '========================================================================'; } + +require_cmd() { + local cmd=$1 + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "ERROR: '$cmd' is required but not on PATH." >&2 + exit 1 + fi +} + +main() { + bar + echo "Chanora Android Rust build" + echo "Repo root : $REPO_ROOT" + echo "NDK : $NDK_ROOT" + echo "Android platform: $ANDROID_PLATFORM" + bar + + require_cmd cargo + require_cmd cargo-ndk + + if [[ ! -d "$NDK_ROOT" ]]; then + echo "ERROR: Android NDK not found. Set ANDROID_NDK_HOME or ANDROID_HOME." >&2 + exit 1 + fi + + export ANDROID_NDK_HOME="$NDK_ROOT" + export LIBOPUS_STATIC=1 + export LIBOPUS_NO_PKG=1 + + for build in "${BUILDS[@]}"; do + IFS=';' read -r abi triple opus_dir <<< "$build" + if [[ ! -f "$opus_dir/lib/libopus.a" ]]; then + echo "ERROR: missing $opus_dir/lib/libopus.a" >&2 + echo "Run tools/build-opus-android.sh first." >&2 + exit 1 + fi + + echo "Building Rust bridge for $abi ($triple)" + export LIBOPUS_LIB_DIR="$opus_dir" + cargo ndk --platform "$ANDROID_PLATFORM" -t "$abi" build --release -p chanora_bridge + + mkdir -p "$JNI_LIBS_DIR/$abi" + cp "$REPO_ROOT/target/$triple/release/libchanora_bridge.so" "$JNI_LIBS_DIR/$abi/" + done + + bar + echo "Android Rust libraries copied to $JNI_LIBS_DIR" +} + +main "$@" diff --git a/tools/build-opus-android.sh b/tools/build-opus-android.sh new file mode 100755 index 0000000..7909756 --- /dev/null +++ b/tools/build-opus-android.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +set -euo pipefail + +NDK_ROOT="${ANDROID_NDK_HOME:-}" +if [[ -z "$NDK_ROOT" && -n "${ANDROID_HOME:-}" ]]; then + NDK_ROOT="$(find "$ANDROID_HOME/ndk" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort | tail -1 || true)" +fi +if [[ -z "$NDK_ROOT" && -n "${ANDROID_SDK_ROOT:-}" ]]; then + NDK_ROOT="$(find "$ANDROID_SDK_ROOT/ndk" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort | tail -1 || true)" +fi +OPUS_VERSION="v1.3.1" +OPUS_SRC_DIR="/tmp/opus-1.3.1" +API_LEVEL=21 + +declare -a ABIS=( + "arm64-v8a" + "armeabi-v7a" + "x86_64" +) + +bar() { printf '%s\n' '========================================================================'; } + +require_cmd() { + local cmd=$1 + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "ERROR: '$cmd' is required but not on PATH." >&2 + exit 1 + fi +} + +clone_or_update_opus() { + if [[ ! -d "$OPUS_SRC_DIR/.git" ]]; then + echo "Cloning opus ${OPUS_VERSION} into $OPUS_SRC_DIR" + rm -rf "$OPUS_SRC_DIR" + git clone --branch "$OPUS_VERSION" --depth 1 https://github.com/xiph/opus.git "$OPUS_SRC_DIR" + return + fi + + echo "Reusing existing opus checkout at $OPUS_SRC_DIR" + git -C "$OPUS_SRC_DIR" fetch --tags origin + git -C "$OPUS_SRC_DIR" checkout "$OPUS_VERSION" +} + +build_one() { + local abi=$1 + local build_dir="/tmp/opus-android-${abi}-build" + local install_dir="/tmp/opus-android-${abi}" + + echo "Building opus for ${abi} (API ${API_LEVEL})" + rm -rf "$build_dir" "$install_dir" + mkdir -p "$build_dir" + + # Opus 1.3.1's ARM NEON CMake path fails with recent NDK clang + # (`celt_inner_prod_neon` implicit declaration). Keep this portable + # baseline until we either patch Opus or upgrade the vendored version. + cmake -S "$OPUS_SRC_DIR" -B "$build_dir" \ + -DCMAKE_TOOLCHAIN_FILE="$NDK_ROOT/build/cmake/android.toolchain.cmake" \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DANDROID_ABI="$abi" \ + -DANDROID_PLATFORM="android-${API_LEVEL}" \ + -DCMAKE_INSTALL_PREFIX="$install_dir" \ + -DBUILD_SHARED_LIBS=OFF \ + -DOPUS_BUILD_PROGRAMS=OFF \ + -DOPUS_BUILD_TESTING=OFF \ + -DOPUS_DISABLE_INTRINSICS=ON \ + -DOPUS_USE_NEON=OFF \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON + + cmake --build "$build_dir" --parallel + cmake --install "$build_dir" + + if [[ ! -f "$install_dir/lib/libopus.a" ]]; then + echo "ERROR: expected $install_dir/lib/libopus.a" >&2 + exit 1 + fi +} + +main() { + bar + echo "Chanora Android opus build" + echo "NDK : $NDK_ROOT" + echo "Opus tag : $OPUS_VERSION" + echo "API level : $API_LEVEL" + bar + + require_cmd git + require_cmd cmake + + if [[ ! -d "$NDK_ROOT" ]]; then + echo "ERROR: Android NDK not found at $NDK_ROOT" >&2 + exit 1 + fi + + clone_or_update_opus + + for abi_entry in "${ABIS[@]}"; do + build_one "$abi_entry" + done + + bar + echo "Android opus builds complete." + for abi_entry in "${ABIS[@]}"; do + echo " /tmp/opus-android-${abi_entry}/lib/libopus.a" + done +} + +main "$@"