feat: integrate chat voice and diagnostics client

This commit is contained in:
Edison Jwa
2026-05-23 06:51:55 +09:00
parent 7e28791ec2
commit 7d5d8c2c90
93 changed files with 10273 additions and 3347 deletions
@@ -69,8 +69,8 @@ android {
// Per-ABI delivery is handled by the AAB bundle splits below
// (SDD-109 item 2) rather than a fat APK.
ndk {
// DEC-032 RESOLVED (2026-05-18): canonical three-ABI set
// restored after the audiopus_sys + cmake-rs + NDK toolchain-file
// DEC-032 RESOLVED (2026-05-18): canonical two-ABI set
// (arm64-v8a + x86_64) restored after the audiopus_sys
// ANDROID_ABI propagation gap was closed by the workspace
// [patch.crates-io] override pinning `cmake` to the fork
// carrying cmake-rs PR #257 (forwards ANDROID_ABI /
@@ -138,9 +138,10 @@ android {
}
// SDD-109 item 2: Android App Bundle (.aab) split configuration.
// Combined with the SDD-073 abiFilters set, this yields three
// native splits (arm64-v8a, armeabi-v7a, x86_64), per-language
// resource delivery, and per-density resource delivery.
// Combined with the SDD-073 abiFilters set, this yields two
// native splits (arm64-v8a, x86_64), per-language resource
// delivery, and per-density resource delivery. Each device
// receives only its matching ABI .so — no cross-ABI bundling.
bundle {
language {
enableSplit = true
@@ -221,7 +222,6 @@ val chanoraAndroidMinSdk: Provider<String> =
// for the per-ABI copy and for the inputs/outputs declaration.
val abiToRustTriple: Map<String, String> = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "armv7-linux-androideabi",
"x86_64" to "x86_64-linux-android",
)
@@ -278,7 +278,7 @@ val checkRustBridgeToolchain = tasks.register("checkRustBridgeToolchain") {
if (missing.isNotEmpty()) {
throw GradleException(
"[SDD-118] Missing Android Rust target(s): ${missing.joinToString(", ")}. " +
"Install: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android"
"Install: rustup target add aarch64-linux-android x86_64-linux-android"
)
}
}
@@ -312,6 +312,15 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
.takeUnless { it.isNullOrBlank() }
?: android.ndkDirectory.absolutePath
val cmakeToolchainFile = "$effectiveNdkPath/build/cmake/android.toolchain.cmake"
val homeDir = System.getenv("HOME") ?: System.getProperty("user.home")
val rustupBinDir = listOf(
"$homeDir/.cargo/bin",
"/opt/homebrew/opt/rustup/bin",
"/usr/local/opt/rustup/bin"
).firstOrNull { File(it).resolve("cargo").exists() }
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: "stable-aarch64-apple-darwin"
// SDD-118 item 13 (corrected): per-ABI Exec sub-tasks. Each runs an
// isolated `cargo ndk -t <abi> ... -- build ...` so ANDROID_ABI is set
@@ -361,15 +370,18 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
// Any CARGO_TARGET_* env vars set by an outer build wrapper
val cleanEnv = buildMap<String, String> {
// Pass-through from the calling environment, only the safe set.
System.getenv("PATH")?.let { put("PATH", it) }
System.getenv("HOME")?.let { put("HOME", it) }
System.getenv("PATH")?.let { path ->
put("PATH", listOfNotNull(rustupBinDir, path).joinToString(":"))
}
put("HOME", homeDir)
System.getenv("USER")?.let { put("USER", it) }
System.getenv("TMPDIR")?.let { put("TMPDIR", it) }
System.getenv("TEMP")?.let { put("TEMP", it) }
System.getenv("LANG")?.let { put("LANG", it) }
System.getenv("LC_ALL")?.let { put("LC_ALL", it) }
System.getenv("CARGO_HOME")?.let { put("CARGO_HOME", it) }
System.getenv("RUSTUP_HOME")?.let { put("RUSTUP_HOME", it) }
put("CARGO_HOME", cargoHome)
put("RUSTUP_HOME", rustupHome)
put("RUSTUP_TOOLCHAIN", rustupToolchain)
System.getenv("JAVA_HOME")?.let { put("JAVA_HOME", it) }
// Per-task explicit settings (override any pass-through).
// SDD-118 item 5: audiopus_sys needs libopus built from source
@@ -444,7 +456,6 @@ val buildRustBridgeRelease = registerCargoNdkBuildTask("release")
// $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/.
val abiToNdkSysrootTriple: Map<String, String> = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "arm-linux-androideabi",
"x86_64" to "x86_64-linux-android",
)
fun registerJniLibsCopyTask(profile: String): TaskProvider<Copy> {
+10 -13
View File
@@ -11,8 +11,8 @@
# the app module these are looked up by signature from C/Rust.
# 3. The Android voice foreground service (SDD-107) referenced from
# the manifest by FQN and from JNI via static start/stop helpers.
# 4. The Android audio mode controller (SDD-108) referenced from
# JNI via GlobalRef.
# 4. The Android audio mode controller (SDD-108) audio mode is
# managed in Rust via direct JNI (engine.rs), not a Kotlin class.
# 5. Kotlin metadata required by FRB-generated bindings (SDD-079).
# --- 1. chanora_bridge / flutter_rust_bridge JNI surface (SDD-105) -----------
@@ -46,21 +46,18 @@
}
# --- 3. AndroidVoiceForegroundService (SDD-107) ------------------------------
# The service is referenced by FQN from AndroidManifest.xml and by JNI from
# the Rust audio engine (start/stop helpers per SDD-107 item 10). Keep the
# class and its public/static members.
-keep class app.chanora.chanora_flutter.AndroidVoiceForegroundService { *; }
# Covered by the JNI-surface keep block above.
# --- 4. AndroidAudioModeController (SDD-108) ---------------------------------
# Referenced from Rust via GlobalRef + static method invocation.
# Wave 2B-2 owns the class; keep rule is forward-looking but harmless if
# the class does not yet exist (R8 ignores keep rules for missing classes).
-keep class app.chanora.chanora_flutter.AndroidAudioModeController { *; }
# --- 4. Android Audio Mode Controller (SDD-108) -------------------------
# SDD-108 in-call audio mode (AudioManager.setMode) is implemented in the
# Rust audio engine (crates/chanora_audio/src/engine.rs) via direct JNI,
# not through a Kotlin controller class. No keep rule is needed; the
# engine.rs android_set_audio_mode / android_get_audio_mode helpers
# resolve AudioManager at runtime via ndk_context.
# --- 5. Kotlin metadata + serialisation (SDD-079) ----------------------------
-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod
-keep class kotlin.Metadata { *; }
# Defer to Flutter's own keep rules for the embedding layer; the Flutter
# Gradle plugin contributes those automatically. If a future SDD revision
# disables R8 entirely, this file can be reduced to a single TODO comment.
# Gradle plugin contributes those automatically.
@@ -5,6 +5,18 @@
protocol layer to dial a TeamSpeak-compatible server over UDP. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- SRS-100: Network diagnostics. Allows the core to query active
network type and state for the diagnostic export. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- SDD-115: Prevent CPU sleep during active voice sessions.
Keeps the audio pipeline running when the screen locks. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- SRS-025: Haptic feedback on PTT key press. Enables
VibrationEffect for transmit-mode tactile confirmation. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- SDD-trace: SDD-106 AndroidPermissionRequester.
Required for the audio engine (chanora_audio) to open the
capture stream for voice transmission. The runtime grant
@@ -56,7 +68,8 @@
<application
android:label="Chanora"
android:name="app.chanora.chanora_flutter.ChanoraApplication"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -324,13 +324,11 @@ class AndroidPermissionRequester {
callback: ((PermissionState) -> Unit)?,
) {
callback?.invoke(state)
// The stateChangeListener is wired by MainActivity to invoke
// METHOD_PERMISSION_STATE_CHANGED on ANDROID_PERMISSIONS, which
// publishes BridgeEvent::PermissionState to the bridge event
// stream. See MainActivity.configureFlutterEngine (SDD-106 §5).
stateChangeListener?.invoke(permission, state)
// TODO(Wave 2B follow-up): wire this into a Flutter MethodChannel
// named MethodChannels.ANDROID_PERMISSIONS, invoking method
// MethodChannels.METHOD_PERMISSION_STATE_CHANGED. The Dart
// handler then publishes the corresponding
// BridgeEvent::PermissionState onto the bridge event stream
// (SDD-106 §5).
}
/**
@@ -71,24 +71,9 @@ class AndroidVoiceForegroundService : Service() {
*/
private const val CHANNEL_ID = "chanora.voice.session"
/**
* Notification channel user-visible name.
*
* TODO(localization): SDD-107 §4 requires this to be sourced
* from a product string resource. Hard-coded here for the
* Wave 2B implementation slice; localised strings land
* alongside the broader Android string-resource pass.
*/
private const val CHANNEL_NAME = "Voice session"
/**
* Notification channel description (product copy only, no
* server-supplied content per SDD-107 §5 privacy note).
*
* TODO(localization): see [CHANNEL_NAME].
*/
private const val CHANNEL_DESCRIPTION =
"Shown while a Chanora voice session is active."
// SDD-107 §4: notification strings are sourced from
// res/values/strings.xml and accessed via the service context
// in instance methods below (ensureChannel, buildNotification).
/**
* Stable notification id ("CHAN") per SDD-107 §5. Must remain
@@ -298,10 +283,10 @@ class AndroidVoiceForegroundService : Service() {
if (existing != null) return
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
getString(R.string.voice_session_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = CHANNEL_DESCRIPTION
description = getString(R.string.voice_session_channel_description)
setShowBadge(false)
}
nm.createNotificationChannel(channel)
@@ -329,14 +314,9 @@ class AndroidVoiceForegroundService : Service() {
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_speakerphone)
// TODO(SDD-107 §5): replace stat_sys_speakerphone with the
// product icon `ic_chanora_voice` once the drawable lands
// in res/drawable. Using a platform-provided icon as the
// interim placeholder keeps the build green without
// touching res/* (out of this slice's file set).
.setContentTitle("Chanora — Voice session active")
.setContentText("Microphone may be in use.")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.voice_session_notification_title))
.setContentText(getString(R.string.voice_session_notification_text))
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_LOW)
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- SDD-107 §4: Chinese (Simplified) localized notification strings.
Product-owned copy; no server-supplied content per SDD-107 §5. -->
<string name="voice_session_channel_name">语音会话</string>
<string name="voice_session_channel_description">Chanora 语音会话处于活动状态时显示。</string>
<string name="voice_session_notification_title">Chanora — 语音会话进行中</string>
<string name="voice_session_notification_text">麦克风可能正在使用中。</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- SDD-107 §4: Voice session foreground service notification strings.
These are product-owned strings; server-supplied content is never
placed in notification copy per SDD-107 §5 privacy note. -->
<string name="voice_session_channel_name">Voice session</string>
<string name="voice_session_channel_description">Shown while a Chanora voice session is active.</string>
<string name="voice_session_notification_title">Chanora — Voice session active</string>
<string name="voice_session_notification_text">Microphone may be in use.</string>
</resources>