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
Generated
+369 -30
View File
@@ -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"
+1
View File
@@ -12,3 +12,4 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
/app/src/main/jniLibs/
+126 -140
View File
@@ -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<LogicalKeyboardKey> _focusedPttHeldKeys = <LogicalKeyboardKey>{};
List<rust.BridgeBookmark> _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<void> _setPtt(bool active) async {
Future<void> _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<void> _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';
+8 -2
View File
@@ -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
@@ -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<dynamic>;
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
@@ -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();
+1
View File
@@ -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,
};
+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,
}
+75
View File
@@ -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 "$@"
+108
View File
@@ -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 "$@"