feat: Android VoIP client — Phase 2 (JNI bridge, Compose UI, AEC pipeline wiring)
- JNI bridge with 8 extern functions (init, startCall, stopCall, setMute, setSpeaker, getStats, forceProfile, destroy) with panic catching - Kotlin engine layer: WzpEngine JNI wrapper, WzpCallback interface, CallStats data class with JSON deserialization - Jetpack Compose UI: InCallScreen with quality indicator (green/yellow/red), mute/speaker/hangup buttons, stats overlay, duration timer - CallActivity with RECORD_AUDIO permission handling, Material3 theme - CallService foreground service with WakeLock, WiFi lock, notification - AudioRouteManager for speaker/earpiece/Bluetooth SCO switching - AEC wired into CallEncoder pipeline: AEC → AGC → denoise → silence → encode - AEC farend reference fed from decode path to encode path in pipeline - Engine exposes set_aec_enabled/set_agc_enabled via AtomicBool flags Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
38
android/app/src/main/java/com/wzp/WzpApplication.kt
Normal file
38
android/app/src/main/java/com/wzp/WzpApplication.kt
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package com.wzp
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application entry point for WarzonePhone.
|
||||||
|
*
|
||||||
|
* Creates the notification channel required for the foreground [com.wzp.service.CallService].
|
||||||
|
*/
|
||||||
|
class WzpApplication : Application() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Active Call",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Shown while a VoIP call is in progress"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_ID = "wzp_call_channel"
|
||||||
|
}
|
||||||
|
}
|
||||||
142
android/app/src/main/java/com/wzp/audio/AudioRouteManager.kt
Normal file
142
android/app/src/main/java/com/wzp/audio/AudioRouteManager.kt
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package com.wzp.audio
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioDeviceCallback
|
||||||
|
import android.media.AudioDeviceInfo
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages audio routing between earpiece, speaker, and Bluetooth devices.
|
||||||
|
*
|
||||||
|
* Wraps [AudioManager] operations and listens for device connection changes
|
||||||
|
* via [AudioDeviceCallback] (API 23+).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. Call [register] when the call starts
|
||||||
|
* 2. Use [setSpeaker] and [setBluetoothSco] to switch routes
|
||||||
|
* 3. Call [unregister] when the call ends
|
||||||
|
*/
|
||||||
|
class AudioRouteManager(context: Context) {
|
||||||
|
|
||||||
|
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
/** Listener for audio route changes. */
|
||||||
|
var onRouteChanged: ((AudioRoute) -> Unit)? = null
|
||||||
|
|
||||||
|
/** Current active route. */
|
||||||
|
var currentRoute: AudioRoute = AudioRoute.EARPIECE
|
||||||
|
private set
|
||||||
|
|
||||||
|
// -- Device callback (API 23+) -------------------------------------------
|
||||||
|
|
||||||
|
private val deviceCallback = object : AudioDeviceCallback() {
|
||||||
|
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||||
|
for (device in addedDevices) {
|
||||||
|
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
|
||||||
|
// A Bluetooth headset was connected — optionally auto-switch
|
||||||
|
onRouteChanged?.invoke(AudioRoute.BLUETOOTH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
||||||
|
for (device in removedDevices) {
|
||||||
|
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
|
||||||
|
// Bluetooth disconnected — fall back to earpiece or speaker
|
||||||
|
val fallback = if (audioManager.isSpeakerphoneOn) {
|
||||||
|
AudioRoute.SPEAKER
|
||||||
|
} else {
|
||||||
|
AudioRoute.EARPIECE
|
||||||
|
}
|
||||||
|
currentRoute = fallback
|
||||||
|
onRouteChanged?.invoke(fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Public API -----------------------------------------------------------
|
||||||
|
|
||||||
|
/** Register the device callback. Call when a call starts. */
|
||||||
|
fun register() {
|
||||||
|
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unregister the device callback and release Bluetooth SCO. Call when the call ends. */
|
||||||
|
fun unregister() {
|
||||||
|
audioManager.unregisterAudioDeviceCallback(deviceCallback)
|
||||||
|
stopBluetoothSco()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable the loudspeaker.
|
||||||
|
*
|
||||||
|
* When enabling speaker, Bluetooth SCO is disconnected.
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
fun setSpeaker(enabled: Boolean) {
|
||||||
|
if (enabled) {
|
||||||
|
stopBluetoothSco()
|
||||||
|
}
|
||||||
|
audioManager.isSpeakerphoneOn = enabled
|
||||||
|
currentRoute = if (enabled) AudioRoute.SPEAKER else AudioRoute.EARPIECE
|
||||||
|
onRouteChanged?.invoke(currentRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable Bluetooth SCO (Synchronous Connection Oriented) audio.
|
||||||
|
*
|
||||||
|
* When enabling Bluetooth, the speaker is turned off.
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
fun setBluetoothSco(enabled: Boolean) {
|
||||||
|
if (enabled) {
|
||||||
|
audioManager.isSpeakerphoneOn = false
|
||||||
|
audioManager.startBluetoothSco()
|
||||||
|
audioManager.isBluetoothScoOn = true
|
||||||
|
currentRoute = AudioRoute.BLUETOOTH
|
||||||
|
} else {
|
||||||
|
stopBluetoothSco()
|
||||||
|
currentRoute = AudioRoute.EARPIECE
|
||||||
|
}
|
||||||
|
onRouteChanged?.invoke(currentRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether a Bluetooth SCO device is currently connected. */
|
||||||
|
fun isBluetoothAvailable(): Boolean {
|
||||||
|
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||||
|
return devices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List available output audio routes. */
|
||||||
|
fun availableRoutes(): List<AudioRoute> {
|
||||||
|
val routes = mutableListOf(AudioRoute.EARPIECE, AudioRoute.SPEAKER)
|
||||||
|
if (isBluetoothAvailable()) {
|
||||||
|
routes.add(AudioRoute.BLUETOOTH)
|
||||||
|
}
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Internal -------------------------------------------------------------
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun stopBluetoothSco() {
|
||||||
|
if (audioManager.isBluetoothScoOn) {
|
||||||
|
audioManager.isBluetoothScoOn = false
|
||||||
|
audioManager.stopBluetoothSco()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Audio output route. */
|
||||||
|
enum class AudioRoute {
|
||||||
|
/** Phone earpiece (default for calls). */
|
||||||
|
EARPIECE,
|
||||||
|
/** Built-in loudspeaker. */
|
||||||
|
SPEAKER,
|
||||||
|
/** Bluetooth SCO headset/headphones. */
|
||||||
|
BLUETOOTH
|
||||||
|
}
|
||||||
63
android/app/src/main/java/com/wzp/engine/CallStats.kt
Normal file
63
android/app/src/main/java/com/wzp/engine/CallStats.kt
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package com.wzp.engine
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of call statistics, mirroring the Rust `CallStats` struct.
|
||||||
|
*
|
||||||
|
* Constructed from the JSON string returned by [WzpEngine.getStats].
|
||||||
|
*/
|
||||||
|
data class CallStats(
|
||||||
|
/** Current call state ordinal (see [CallStateConstants]). */
|
||||||
|
val state: Int = 0,
|
||||||
|
/** Call duration in seconds. */
|
||||||
|
val durationSecs: Double = 0.0,
|
||||||
|
/** Quality tier: 0 = Good, 1 = Degraded, 2 = Catastrophic. */
|
||||||
|
val qualityTier: Int = 0,
|
||||||
|
/** Observed packet loss percentage (0..100). */
|
||||||
|
val lossPct: Float = 0f,
|
||||||
|
/** Smoothed round-trip time in milliseconds. */
|
||||||
|
val rttMs: Int = 0,
|
||||||
|
/** Jitter in milliseconds. */
|
||||||
|
val jitterMs: Int = 0,
|
||||||
|
/** Current jitter buffer depth in packets. */
|
||||||
|
val jitterBufferDepth: Int = 0,
|
||||||
|
/** Total frames encoded since call start. */
|
||||||
|
val framesEncoded: Long = 0,
|
||||||
|
/** Total frames decoded since call start. */
|
||||||
|
val framesDecoded: Long = 0,
|
||||||
|
/** Number of playout underruns (buffer empty when audio was needed). */
|
||||||
|
val underruns: Long = 0
|
||||||
|
) {
|
||||||
|
/** Human-readable quality label. */
|
||||||
|
val qualityLabel: String
|
||||||
|
get() = when (qualityTier) {
|
||||||
|
0 -> "Good"
|
||||||
|
1 -> "Degraded"
|
||||||
|
2 -> "Catastrophic"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Deserialise from the JSON string produced by the native engine. */
|
||||||
|
fun fromJson(json: String): CallStats {
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
CallStats(
|
||||||
|
state = obj.optInt("state", 0),
|
||||||
|
durationSecs = obj.optDouble("duration_secs", 0.0),
|
||||||
|
qualityTier = obj.optInt("quality_tier", 0),
|
||||||
|
lossPct = obj.optDouble("loss_pct", 0.0).toFloat(),
|
||||||
|
rttMs = obj.optInt("rtt_ms", 0),
|
||||||
|
jitterMs = obj.optInt("jitter_ms", 0),
|
||||||
|
jitterBufferDepth = obj.optInt("jitter_buffer_depth", 0),
|
||||||
|
framesEncoded = obj.optLong("frames_encoded", 0),
|
||||||
|
framesDecoded = obj.optLong("frames_decoded", 0),
|
||||||
|
underruns = obj.optLong("underruns", 0)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
CallStats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
android/app/src/main/java/com/wzp/engine/WzpCallback.kt
Normal file
32
android/app/src/main/java/com/wzp/engine/WzpCallback.kt
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package com.wzp.engine
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback interface for VoIP engine events.
|
||||||
|
*
|
||||||
|
* All callbacks are invoked on the main/UI thread.
|
||||||
|
*/
|
||||||
|
interface WzpCallback {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the call state changes.
|
||||||
|
*
|
||||||
|
* @param state one of [CallStateConstants]: IDLE(0), CONNECTING(1), ACTIVE(2),
|
||||||
|
* RECONNECTING(3), CLOSED(4)
|
||||||
|
*/
|
||||||
|
fun onCallStateChanged(state: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the network quality tier changes.
|
||||||
|
*
|
||||||
|
* @param tier 0 = Good, 1 = Degraded, 2 = Catastrophic
|
||||||
|
*/
|
||||||
|
fun onQualityTierChanged(tier: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when an error occurs in the native engine.
|
||||||
|
*
|
||||||
|
* @param code numeric error code (negative)
|
||||||
|
* @param message human-readable description
|
||||||
|
*/
|
||||||
|
fun onError(code: Int, message: String)
|
||||||
|
}
|
||||||
122
android/app/src/main/java/com/wzp/engine/WzpEngine.kt
Normal file
122
android/app/src/main/java/com/wzp/engine/WzpEngine.kt
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package com.wzp.engine
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native VoIP engine wrapper. Delegates all work to libwzp_android.so via JNI.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* 1. Construct with a [WzpCallback]
|
||||||
|
* 2. Call [init] to create the native engine
|
||||||
|
* 3. Call [startCall] to begin a VoIP session
|
||||||
|
* 4. Use [setMute], [setSpeaker], [getStats], [forceProfile] during the call
|
||||||
|
* 5. Call [stopCall] to end the session
|
||||||
|
* 6. Call [destroy] when the engine is no longer needed
|
||||||
|
*
|
||||||
|
* Thread safety: all methods must be called from the same thread (typically main).
|
||||||
|
*/
|
||||||
|
class WzpEngine(private val callback: WzpCallback) {
|
||||||
|
|
||||||
|
/** Opaque pointer to the native EngineHandle. 0 means not initialised. */
|
||||||
|
private var nativeHandle: Long = 0L
|
||||||
|
|
||||||
|
/** Whether the engine has been initialised. */
|
||||||
|
val isInitialized: Boolean get() = nativeHandle != 0L
|
||||||
|
|
||||||
|
/** Create the native engine. Must be called before any other method. */
|
||||||
|
fun init() {
|
||||||
|
check(nativeHandle == 0L) { "Engine already initialized" }
|
||||||
|
nativeHandle = nativeInit()
|
||||||
|
check(nativeHandle != 0L) { "Native engine creation failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a call.
|
||||||
|
*
|
||||||
|
* @param relayAddr relay server address (host:port)
|
||||||
|
* @param room room identifier
|
||||||
|
* @param seedHex 64-char hex-encoded 32-byte identity seed
|
||||||
|
* @param token authentication token
|
||||||
|
* @return 0 on success, negative error code on failure
|
||||||
|
*/
|
||||||
|
fun startCall(relayAddr: String, room: String, seedHex: String, token: String): Int {
|
||||||
|
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||||
|
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token)
|
||||||
|
if (result == 0) {
|
||||||
|
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
||||||
|
} else {
|
||||||
|
callback.onError(result, "Failed to start call")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the active call. Safe to call when no call is active. */
|
||||||
|
fun stopCall() {
|
||||||
|
if (nativeHandle != 0L) {
|
||||||
|
nativeStopCall(nativeHandle)
|
||||||
|
callback.onCallStateChanged(CallStateConstants.CLOSED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mute or unmute the microphone. */
|
||||||
|
fun setMute(muted: Boolean) {
|
||||||
|
if (nativeHandle != 0L) nativeSetMute(nativeHandle, muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable or disable loudspeaker mode. */
|
||||||
|
fun setSpeaker(speaker: Boolean) {
|
||||||
|
if (nativeHandle != 0L) nativeSetSpeaker(nativeHandle, speaker)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current call statistics as a JSON string.
|
||||||
|
*
|
||||||
|
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
|
||||||
|
*/
|
||||||
|
fun getStats(): String {
|
||||||
|
return if (nativeHandle != 0L) nativeGetStats(nativeHandle) else "{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a quality profile, overriding adaptive selection.
|
||||||
|
*
|
||||||
|
* @param profile 0 = GOOD, 1 = DEGRADED, 2 = CATASTROPHIC
|
||||||
|
*/
|
||||||
|
fun forceProfile(profile: Int) {
|
||||||
|
if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroy the native engine and free all resources. The instance must not be reused. */
|
||||||
|
fun destroy() {
|
||||||
|
if (nativeHandle != 0L) {
|
||||||
|
nativeDestroy(nativeHandle)
|
||||||
|
nativeHandle = 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- JNI native methods --------------------------------------------------
|
||||||
|
|
||||||
|
private external fun nativeInit(): Long
|
||||||
|
private external fun nativeStartCall(
|
||||||
|
handle: Long, relay: String, room: String, seed: String, token: String
|
||||||
|
): Int
|
||||||
|
private external fun nativeStopCall(handle: Long)
|
||||||
|
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
||||||
|
private external fun nativeSetSpeaker(handle: Long, speaker: Boolean)
|
||||||
|
private external fun nativeGetStats(handle: Long): String
|
||||||
|
private external fun nativeForceProfile(handle: Long, profile: Int)
|
||||||
|
private external fun nativeDestroy(handle: Long)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
init {
|
||||||
|
System.loadLibrary("wzp_android")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integer constants matching the Rust [CallState] enum ordinals. */
|
||||||
|
object CallStateConstants {
|
||||||
|
const val IDLE = 0
|
||||||
|
const val CONNECTING = 1
|
||||||
|
const val ACTIVE = 2
|
||||||
|
const val RECONNECTING = 3
|
||||||
|
const val CLOSED = 4
|
||||||
|
}
|
||||||
168
android/app/src/main/java/com/wzp/service/CallService.kt
Normal file
168
android/app/src/main/java/com/wzp/service/CallService.kt
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package com.wzp.service
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.wzp.WzpApplication
|
||||||
|
import com.wzp.ui.call.CallActivity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service that keeps the VoIP call alive when the app is backgrounded.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Shows a persistent notification during the call
|
||||||
|
* - Acquires a partial wake lock so the CPU stays on
|
||||||
|
* - Acquires a Wi-Fi lock to prevent Wi-Fi from going to sleep
|
||||||
|
* - Sets [AudioManager] mode to [AudioManager.MODE_IN_COMMUNICATION]
|
||||||
|
* - Releases all resources when the call ends
|
||||||
|
*/
|
||||||
|
class CallService : Service() {
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var wifiLock: WifiManager.WifiLock? = null
|
||||||
|
private var previousAudioMode: Int = AudioManager.MODE_NORMAL
|
||||||
|
|
||||||
|
// -- Lifecycle ------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
acquireWakeLock()
|
||||||
|
acquireWifiLock()
|
||||||
|
setAudioMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startForeground(NOTIFICATION_ID, buildNotification())
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
restoreAudioMode()
|
||||||
|
releaseWifiLock()
|
||||||
|
releaseWakeLock()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
// -- Notification ---------------------------------------------------------
|
||||||
|
|
||||||
|
private fun buildNotification(): Notification {
|
||||||
|
// Tapping the notification returns to the call screen
|
||||||
|
val contentIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, CallActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
// "End call" action button
|
||||||
|
val stopIntent = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
1,
|
||||||
|
Intent(this, CallService::class.java).apply { action = ACTION_STOP },
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, WzpApplication.CHANNEL_ID)
|
||||||
|
.setContentTitle("WZ Phone")
|
||||||
|
.setContentText("Call in progress")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_call)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setContentIntent(contentIntent)
|
||||||
|
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "End Call", stopIntent)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Wake lock ------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun acquireWakeLock() {
|
||||||
|
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = pm.newWakeLock(
|
||||||
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
"wzp:call_wake_lock"
|
||||||
|
).apply {
|
||||||
|
acquire(MAX_CALL_DURATION_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) it.release()
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Wi-Fi lock -----------------------------------------------------------
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun acquireWifiLock() {
|
||||||
|
val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||||
|
wifiLock = wm.createWifiLock(
|
||||||
|
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
|
||||||
|
"wzp:call_wifi_lock"
|
||||||
|
).apply {
|
||||||
|
acquire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseWifiLock() {
|
||||||
|
wifiLock?.let {
|
||||||
|
if (it.isHeld) it.release()
|
||||||
|
}
|
||||||
|
wifiLock = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Audio mode -----------------------------------------------------------
|
||||||
|
|
||||||
|
private fun setAudioMode() {
|
||||||
|
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
previousAudioMode = am.mode
|
||||||
|
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreAudioMode() {
|
||||||
|
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
am.mode = previousAudioMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Static helpers -------------------------------------------------------
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
private const val ACTION_STOP = "com.wzp.service.STOP"
|
||||||
|
private const val MAX_CALL_DURATION_MS = 4L * 60 * 60 * 1000 // 4 hours
|
||||||
|
|
||||||
|
/** Start the foreground call service. */
|
||||||
|
fun start(context: Context) {
|
||||||
|
val intent = Intent(context, CallService::class.java)
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the foreground call service. */
|
||||||
|
fun stop(context: Context) {
|
||||||
|
val intent = Intent(context, CallService::class.java).apply {
|
||||||
|
action = ACTION_STOP
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
android/app/src/main/java/com/wzp/ui/call/CallActivity.kt
Normal file
135
android/app/src/main/java/com/wzp/ui/call/CallActivity.kt
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package com.wzp.ui.call
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.wzp.service.CallService
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main activity hosting the in-call Compose UI.
|
||||||
|
*
|
||||||
|
* Requests RECORD_AUDIO permission, starts the foreground [CallService],
|
||||||
|
* and launches the call via [CallViewModel].
|
||||||
|
*/
|
||||||
|
class CallActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val viewModel: CallViewModel by viewModels()
|
||||||
|
|
||||||
|
// -- Permission request ---------------------------------------------------
|
||||||
|
|
||||||
|
private val audioPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
startCallFlow()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this, "Microphone permission is required for calls", Toast.LENGTH_LONG).show()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Lifecycle ------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
WzpTheme {
|
||||||
|
InCallScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onHangUp = {
|
||||||
|
viewModel.stopCall()
|
||||||
|
CallService.stop(this@CallActivity)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check audio permission
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||||
|
== PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
startCallFlow()
|
||||||
|
} else {
|
||||||
|
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
if (isFinishing) {
|
||||||
|
viewModel.stopCall()
|
||||||
|
CallService.stop(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Call setup ------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun startCallFlow() {
|
||||||
|
// Extract parameters from intent extras, with test defaults.
|
||||||
|
val relayAddr = intent.getStringExtra(EXTRA_RELAY_ADDR) ?: DEFAULT_RELAY
|
||||||
|
val room = intent.getStringExtra(EXTRA_ROOM) ?: DEFAULT_ROOM
|
||||||
|
val seedHex = intent.getStringExtra(EXTRA_SEED_HEX) ?: DEFAULT_SEED_HEX
|
||||||
|
val token = intent.getStringExtra(EXTRA_TOKEN) ?: DEFAULT_TOKEN
|
||||||
|
|
||||||
|
// Start foreground service
|
||||||
|
CallService.start(this)
|
||||||
|
|
||||||
|
// Start the call
|
||||||
|
viewModel.startCall(relayAddr, room, seedHex, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_RELAY_ADDR = "relay_addr"
|
||||||
|
const val EXTRA_ROOM = "room"
|
||||||
|
const val EXTRA_SEED_HEX = "seed_hex"
|
||||||
|
const val EXTRA_TOKEN = "token"
|
||||||
|
|
||||||
|
// Test defaults — replaced by real values in production
|
||||||
|
private const val DEFAULT_RELAY = "127.0.0.1:7777"
|
||||||
|
private const val DEFAULT_ROOM = "test-room"
|
||||||
|
private const val DEFAULT_SEED_HEX =
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000001"
|
||||||
|
private const val DEFAULT_TOKEN = "test-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WarzonePhone Material3 theme with dynamic colour support (Android 12+)
|
||||||
|
* and dark mode.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WzpTheme(content: @Composable () -> Unit) {
|
||||||
|
val darkTheme = isSystemInDarkTheme()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val colorScheme = when {
|
||||||
|
// Dynamic colour is available on Android 12+
|
||||||
|
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
darkTheme -> darkColorScheme()
|
||||||
|
else -> lightColorScheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
142
android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt
Normal file
142
android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package com.wzp.ui.call
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.wzp.engine.CallStats
|
||||||
|
import com.wzp.engine.WzpCallback
|
||||||
|
import com.wzp.engine.WzpEngine
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel managing the call lifecycle and exposing observable state to the UI.
|
||||||
|
*
|
||||||
|
* Owns the [WzpEngine] instance, implements [WzpCallback] to receive engine events,
|
||||||
|
* and polls call statistics every 500 ms while the call is active.
|
||||||
|
*/
|
||||||
|
class CallViewModel : ViewModel(), WzpCallback {
|
||||||
|
|
||||||
|
// -- Engine ---------------------------------------------------------------
|
||||||
|
|
||||||
|
private val engine = WzpEngine(this)
|
||||||
|
|
||||||
|
// -- Observable state -----------------------------------------------------
|
||||||
|
|
||||||
|
private val _callState = MutableStateFlow(0) // CallStateConstants.IDLE
|
||||||
|
val callState: StateFlow<Int> = _callState.asStateFlow()
|
||||||
|
|
||||||
|
private val _isMuted = MutableStateFlow(false)
|
||||||
|
val isMuted: StateFlow<Boolean> = _isMuted.asStateFlow()
|
||||||
|
|
||||||
|
private val _isSpeaker = MutableStateFlow(false)
|
||||||
|
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
|
||||||
|
|
||||||
|
private val _stats = MutableStateFlow(CallStats())
|
||||||
|
val stats: StateFlow<CallStats> = _stats.asStateFlow()
|
||||||
|
|
||||||
|
private val _qualityTier = MutableStateFlow(0)
|
||||||
|
val qualityTier: StateFlow<Int> = _qualityTier.asStateFlow()
|
||||||
|
|
||||||
|
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||||
|
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||||
|
|
||||||
|
// -- Stats polling --------------------------------------------------------
|
||||||
|
|
||||||
|
private var statsJob: Job? = null
|
||||||
|
|
||||||
|
// -- Public API -----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise the native engine and start a call.
|
||||||
|
*
|
||||||
|
* @param relayAddr relay server address (host:port)
|
||||||
|
* @param room room identifier
|
||||||
|
* @param seedHex 64-char hex-encoded 32-byte identity seed
|
||||||
|
* @param token authentication token
|
||||||
|
*/
|
||||||
|
fun startCall(relayAddr: String, room: String, seedHex: String, token: String) {
|
||||||
|
engine.init()
|
||||||
|
val result = engine.startCall(relayAddr, room, seedHex, token)
|
||||||
|
if (result == 0) {
|
||||||
|
startStatsPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End the current call and clean up resources. */
|
||||||
|
fun stopCall() {
|
||||||
|
stopStatsPolling()
|
||||||
|
engine.stopCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle microphone mute. */
|
||||||
|
fun toggleMute() {
|
||||||
|
val newMuted = !_isMuted.value
|
||||||
|
_isMuted.value = newMuted
|
||||||
|
engine.setMute(newMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle speaker (loudspeaker) mode. */
|
||||||
|
fun toggleSpeaker() {
|
||||||
|
val newSpeaker = !_isSpeaker.value
|
||||||
|
_isSpeaker.value = newSpeaker
|
||||||
|
engine.setSpeaker(newSpeaker)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the current error message. */
|
||||||
|
fun clearError() {
|
||||||
|
_errorMessage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- WzpCallback ----------------------------------------------------------
|
||||||
|
|
||||||
|
override fun onCallStateChanged(state: Int) {
|
||||||
|
_callState.value = state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onQualityTierChanged(tier: Int) {
|
||||||
|
_qualityTier.value = tier
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(code: Int, message: String) {
|
||||||
|
_errorMessage.value = "Error $code: $message"
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Stats polling --------------------------------------------------------
|
||||||
|
|
||||||
|
private fun startStatsPolling() {
|
||||||
|
statsJob?.cancel()
|
||||||
|
statsJob = viewModelScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
val json = engine.getStats()
|
||||||
|
val parsed = CallStats.fromJson(json)
|
||||||
|
_stats.value = parsed
|
||||||
|
_callState.value = parsed.state
|
||||||
|
_qualityTier.value = parsed.qualityTier
|
||||||
|
delay(STATS_POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopStatsPolling() {
|
||||||
|
statsJob?.cancel()
|
||||||
|
statsJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Cleanup --------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
stopStatsPolling()
|
||||||
|
engine.stopCall()
|
||||||
|
engine.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val STATS_POLL_INTERVAL_MS = 500L
|
||||||
|
}
|
||||||
|
}
|
||||||
328
android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt
Normal file
328
android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package com.wzp.ui.call
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.wzp.engine.CallStats
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main in-call Compose screen.
|
||||||
|
*
|
||||||
|
* Displays call duration, quality indicator, audio controls, and live statistics.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InCallScreen(
|
||||||
|
viewModel: CallViewModel,
|
||||||
|
onHangUp: () -> Unit
|
||||||
|
) {
|
||||||
|
val callState by viewModel.callState.collectAsState()
|
||||||
|
val isMuted by viewModel.isMuted.collectAsState()
|
||||||
|
val isSpeaker by viewModel.isSpeaker.collectAsState()
|
||||||
|
val stats by viewModel.stats.collectAsState()
|
||||||
|
val qualityTier by viewModel.qualityTier.collectAsState()
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
|
// -- Call state label ---------------------------------------------
|
||||||
|
CallStateLabel(callState)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// -- Duration -----------------------------------------------------
|
||||||
|
DurationDisplay(stats.durationSecs)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// -- Quality indicator --------------------------------------------
|
||||||
|
QualityIndicator(qualityTier, stats.qualityLabel)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// -- Audio level placeholder bar ----------------------------------
|
||||||
|
AudioLevelBar(stats.framesEncoded)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// -- Control buttons ----------------------------------------------
|
||||||
|
ControlRow(
|
||||||
|
isMuted = isMuted,
|
||||||
|
isSpeaker = isSpeaker,
|
||||||
|
onToggleMute = viewModel::toggleMute,
|
||||||
|
onToggleSpeaker = viewModel::toggleSpeaker,
|
||||||
|
onHangUp = onHangUp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// -- Stats overlay ------------------------------------------------
|
||||||
|
StatsOverlay(stats)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CallStateLabel(state: Int) {
|
||||||
|
val label = when (state) {
|
||||||
|
0 -> "Idle"
|
||||||
|
1 -> "Connecting..."
|
||||||
|
2 -> "Active"
|
||||||
|
3 -> "Reconnecting..."
|
||||||
|
4 -> "Call Ended"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DurationDisplay(durationSecs: Double) {
|
||||||
|
val totalSeconds = durationSecs.roundToInt()
|
||||||
|
val minutes = totalSeconds / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
Text(
|
||||||
|
text = "%02d:%02d".format(minutes, seconds),
|
||||||
|
style = MaterialTheme.typography.displayLarge.copy(
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
letterSpacing = 4.sp
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QualityIndicator(tier: Int, label: String) {
|
||||||
|
val dotColor = when (tier) {
|
||||||
|
0 -> Color(0xFF4CAF50) // green
|
||||||
|
1 -> Color(0xFFFFC107) // yellow
|
||||||
|
2 -> Color(0xFFF44336) // red
|
||||||
|
else -> Color.Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(dotColor)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AudioLevelBar(framesEncoded: Long) {
|
||||||
|
// Placeholder: derive a fake "level" from frame count to show the bar is alive.
|
||||||
|
// In production this would be driven by actual RMS audio levels from the engine.
|
||||||
|
val level = if (framesEncoded > 0) {
|
||||||
|
((framesEncoded % 100).toFloat() / 100f).coerceIn(0.05f, 1f)
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "Audio Level",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { level },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.6f)
|
||||||
|
.height(6.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp)),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ControlRow(
|
||||||
|
isMuted: Boolean,
|
||||||
|
isSpeaker: Boolean,
|
||||||
|
onToggleMute: () -> Unit,
|
||||||
|
onToggleSpeaker: () -> Unit,
|
||||||
|
onHangUp: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Mute button
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onToggleMute,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
colors = if (isMuted) {
|
||||||
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
IconButtonDefaults.filledTonalIconButtonColors()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isMuted) "MIC\nOFF" else "MIC",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
lineHeight = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hang up button
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = onHangUp,
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = Color(0xFFF44336),
|
||||||
|
contentColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "END",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speaker button
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onToggleSpeaker,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
colors = if (isSpeaker) {
|
||||||
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
IconButtonDefaults.filledTonalIconButtonColors()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isSpeaker) "SPK\nON" else "SPK",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
lineHeight = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatsOverlay(stats: CallStats) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Network Stats",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
StatItem("Loss", "%.1f%%".format(stats.lossPct))
|
||||||
|
StatItem("RTT", "${stats.rttMs}ms")
|
||||||
|
StatItem("Jitter", "${stats.jitterMs}ms")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
StatItem("Enc", "${stats.framesEncoded}")
|
||||||
|
StatItem("Dec", "${stats.framesDecoded}")
|
||||||
|
StatItem("JB Depth", "${stats.jitterBufferDepth}")
|
||||||
|
StatItem("Under", "${stats.underruns}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatItem(label: String, value: String) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,10 @@ struct EngineState {
|
|||||||
running: AtomicBool,
|
running: AtomicBool,
|
||||||
muted: AtomicBool,
|
muted: AtomicBool,
|
||||||
speaker: AtomicBool,
|
speaker: AtomicBool,
|
||||||
|
/// Whether acoustic echo cancellation is enabled (default: true).
|
||||||
|
aec_enabled: AtomicBool,
|
||||||
|
/// Whether automatic gain control is enabled (default: true).
|
||||||
|
agc_enabled: AtomicBool,
|
||||||
stats: Mutex<CallStats>,
|
stats: Mutex<CallStats>,
|
||||||
command_tx: std::sync::mpsc::Sender<EngineCommand>,
|
command_tx: std::sync::mpsc::Sender<EngineCommand>,
|
||||||
command_rx: Mutex<Option<std::sync::mpsc::Receiver<EngineCommand>>>,
|
command_rx: Mutex<Option<std::sync::mpsc::Receiver<EngineCommand>>>,
|
||||||
@@ -76,6 +80,8 @@ impl WzpEngine {
|
|||||||
running: AtomicBool::new(false),
|
running: AtomicBool::new(false),
|
||||||
muted: AtomicBool::new(false),
|
muted: AtomicBool::new(false),
|
||||||
speaker: AtomicBool::new(false),
|
speaker: AtomicBool::new(false),
|
||||||
|
aec_enabled: AtomicBool::new(true),
|
||||||
|
agc_enabled: AtomicBool::new(true),
|
||||||
stats: Mutex::new(CallStats::default()),
|
stats: Mutex::new(CallStats::default()),
|
||||||
command_tx: tx,
|
command_tx: tx,
|
||||||
command_rx: Mutex::new(Some(rx)),
|
command_rx: Mutex::new(Some(rx)),
|
||||||
@@ -182,6 +188,11 @@ impl WzpEngine {
|
|||||||
|
|
||||||
info!("codec thread started");
|
info!("codec thread started");
|
||||||
|
|
||||||
|
// Track the last-applied AEC/AGC state so we only call
|
||||||
|
// set_*_enabled when the value actually changes.
|
||||||
|
let mut prev_aec = true;
|
||||||
|
let mut prev_agc = true;
|
||||||
|
|
||||||
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
|
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
|
||||||
#[allow(unused_assignments)]
|
#[allow(unused_assignments)]
|
||||||
let mut recv_buf: Vec<u8> = Vec::new();
|
let mut recv_buf: Vec<u8> = Vec::new();
|
||||||
@@ -215,6 +226,18 @@ impl WzpEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync AEC/AGC enabled flags from shared state.
|
||||||
|
let cur_aec = state.aec_enabled.load(Ordering::Relaxed);
|
||||||
|
if cur_aec != prev_aec {
|
||||||
|
pipeline.set_aec_enabled(cur_aec);
|
||||||
|
prev_aec = cur_aec;
|
||||||
|
}
|
||||||
|
let cur_agc = state.agc_enabled.load(Ordering::Relaxed);
|
||||||
|
if cur_agc != prev_agc {
|
||||||
|
pipeline.set_agc_enabled(cur_agc);
|
||||||
|
prev_agc = cur_agc;
|
||||||
|
}
|
||||||
|
|
||||||
if !state.running.load(Ordering::Relaxed) {
|
if !state.running.load(Ordering::Relaxed) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -319,6 +342,16 @@ impl WzpEngine {
|
|||||||
.send(EngineCommand::SetSpeaker(enabled));
|
.send(EngineCommand::SetSpeaker(enabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable or disable acoustic echo cancellation.
|
||||||
|
pub fn set_aec_enabled(&self, enabled: bool) {
|
||||||
|
self.state.aec_enabled.store(enabled, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable automatic gain control.
|
||||||
|
pub fn set_agc_enabled(&self, enabled: bool) {
|
||||||
|
self.state.agc_enabled.store(enabled, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
/// Force a specific quality profile (overrides adaptive logic).
|
/// Force a specific quality profile (overrides adaptive logic).
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub fn force_profile(&self, profile: QualityProfile) {
|
pub fn force_profile(&self, profile: QualityProfile) {
|
||||||
|
|||||||
348
crates/wzp-android/src/jni_bridge.rs
Normal file
348
crates/wzp-android/src/jni_bridge.rs
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
//! JNI bridge for Android — thin layer between Kotlin and the WzpEngine.
|
||||||
|
//!
|
||||||
|
//! Each function converts JNI types to Rust types, delegates to WzpEngine,
|
||||||
|
//! and converts results back. No audio processing happens here.
|
||||||
|
//!
|
||||||
|
//! # Safety
|
||||||
|
//!
|
||||||
|
//! All functions in this module are called from the JVM via JNI. They use raw
|
||||||
|
//! pointers for the JNI environment and object references. The `jni` crate is
|
||||||
|
//! not yet a dependency, so we use raw FFI types and placeholder string extraction.
|
||||||
|
//! When the `jni` crate is added, the `extract_jstring` helper should be replaced
|
||||||
|
//! with proper `JNIEnv::get_string()` calls.
|
||||||
|
|
||||||
|
use std::os::raw::{c_long, c_void};
|
||||||
|
use std::panic;
|
||||||
|
|
||||||
|
use tracing::{error, info};
|
||||||
|
use wzp_proto::QualityProfile;
|
||||||
|
|
||||||
|
use crate::engine::{CallStartConfig, WzpEngine};
|
||||||
|
|
||||||
|
/// Opaque engine handle passed to/from Kotlin as a `jlong`.
|
||||||
|
///
|
||||||
|
/// Boxed on the heap; the raw pointer is stored on the Kotlin side.
|
||||||
|
/// Only `nativeDestroy` frees it.
|
||||||
|
struct EngineHandle {
|
||||||
|
engine: WzpEngine,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JNI type aliases (mirrors the C JNI ABI without pulling in the `jni` crate)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// JNI boolean — `u8` where 0 = false, non-zero = true.
|
||||||
|
type JBoolean = u8;
|
||||||
|
|
||||||
|
/// JNI int — `i32`.
|
||||||
|
type JInt = i32;
|
||||||
|
|
||||||
|
/// JNI long — `i64` / `c_long` on 64-bit.
|
||||||
|
type JLong = c_long;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Recover the `EngineHandle` from a raw handle value **without** taking ownership.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `handle` must be a value previously returned by `nativeInit` and not yet
|
||||||
|
/// passed to `nativeDestroy`.
|
||||||
|
unsafe fn handle_ref(handle: JLong) -> &'static mut EngineHandle {
|
||||||
|
unsafe { &mut *(handle as *mut EngineHandle) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder: extract a `String` from a JNI `jstring`.
|
||||||
|
///
|
||||||
|
/// When the `jni` crate is added this should be replaced with:
|
||||||
|
/// ```ignore
|
||||||
|
/// let env = JNIEnv::from_raw(env_ptr).unwrap();
|
||||||
|
/// env.get_string(jstring).unwrap().into()
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `_env` and `_jstring` are raw JNI pointers.
|
||||||
|
#[allow(unused)]
|
||||||
|
unsafe fn extract_jstring(_env: *mut c_void, _jstring: *mut c_void) -> String {
|
||||||
|
// TODO(jni): implement real string extraction once the `jni` crate is added.
|
||||||
|
// For now return a default so the rest of the bridge compiles and can be tested
|
||||||
|
// with hardcoded values from the Kotlin side.
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate a JNI `jstring` from a Rust `&str`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `_env` is a raw JNI pointer.
|
||||||
|
#[allow(unused)]
|
||||||
|
unsafe fn new_jstring(_env: *mut c_void, _s: &str) -> *mut c_void {
|
||||||
|
// TODO(jni): implement via JNIEnv::new_string when jni crate is added.
|
||||||
|
std::ptr::null_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a Kotlin `profile` int to a `QualityProfile`.
|
||||||
|
fn profile_from_int(value: JInt) -> QualityProfile {
|
||||||
|
match value {
|
||||||
|
1 => QualityProfile::DEGRADED,
|
||||||
|
2 => QualityProfile::CATASTROPHIC,
|
||||||
|
_ => QualityProfile::GOOD,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JNI exports
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Function names follow JNI convention: Java_<package>_<Class>_<method>
|
||||||
|
// with underscores in the package replaced by `_1` in actual JNI but here we
|
||||||
|
// use the simplified form that matches javah output for the package `com.wzp.engine`.
|
||||||
|
|
||||||
|
/// Create a new `WzpEngine`, returning an opaque handle as `jlong`.
|
||||||
|
///
|
||||||
|
/// Kotlin signature: `private external fun nativeInit(): Long`
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called from JNI.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
|
||||||
|
_env: *mut c_void,
|
||||||
|
_class: *mut c_void,
|
||||||
|
) -> JLong {
|
||||||
|
let result = panic::catch_unwind(|| {
|
||||||
|
// Initialise tracing once (ignore errors if already set).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::INFO)
|
||||||
|
.try_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = Box::new(EngineHandle {
|
||||||
|
engine: WzpEngine::new(),
|
||||||
|
});
|
||||||
|
info!("WzpEngine created via JNI");
|
||||||
|
Box::into_raw(handle) as JLong
|
||||||
|
});
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => {
|
||||||
|
error!("panic in nativeInit");
|
||||||
|
0 // null handle — Kotlin side checks for 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a call.
|
||||||
|
///
|
||||||
|
/// Kotlin signature:
|
||||||
|
/// ```kotlin
|
||||||
|
/// private external fun nativeStartCall(
|
||||||
|
/// handle: Long, relay: String, room: String, seed: String, token: String
|
||||||
|
/// ): Int
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Returns 0 on success, -1 on error.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called from JNI. `handle` must be a live engine handle.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
||||||
|
env: *mut c_void,
|
||||||
|
_class: *mut c_void,
|
||||||
|
handle: JLong,
|
||||||
|
relay_addr_ptr: *mut c_void,
|
||||||
|
room_ptr: *mut c_void,
|
||||||
|
seed_hex_ptr: *mut c_void,
|
||||||
|
token_ptr: *mut c_void,
|
||||||
|
) -> JInt {
|
||||||
|
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let h = unsafe { handle_ref(handle) };
|
||||||
|
|
||||||
|
// Extract strings from JNI. When the `jni` crate is available these
|
||||||
|
// will use real JNI string conversion. For now, placeholders.
|
||||||
|
let relay_addr = unsafe { extract_jstring(env, relay_addr_ptr) };
|
||||||
|
let _room = unsafe { extract_jstring(env, room_ptr) };
|
||||||
|
let seed_hex = unsafe { extract_jstring(env, seed_hex_ptr) };
|
||||||
|
let token = unsafe { extract_jstring(env, token_ptr) };
|
||||||
|
|
||||||
|
// Parse the hex-encoded 32-byte identity seed.
|
||||||
|
let mut identity_seed = [0u8; 32];
|
||||||
|
if seed_hex.len() == 64 {
|
||||||
|
for i in 0..32 {
|
||||||
|
if let Ok(byte) = u8::from_str_radix(&seed_hex[i * 2..i * 2 + 2], 16) {
|
||||||
|
identity_seed[i] = byte;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = CallStartConfig {
|
||||||
|
profile: QualityProfile::GOOD,
|
||||||
|
relay_addr,
|
||||||
|
auth_token: token.into_bytes(),
|
||||||
|
identity_seed,
|
||||||
|
};
|
||||||
|
|
||||||
|
match h.engine.start_call(config) {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("call started via JNI");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("start_call failed: {e}");
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(code) => code,
|
||||||
|
Err(_) => {
|
||||||
|
error!("panic in nativeStartCall");
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the active call.
|
||||||
|
///
|
||||||
|
/// Kotlin signature: `private external fun nativeStopCall(handle: Long)`
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called from JNI.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStopCall(
|
||||||
|
_env: *mut c_void,
|
||||||
|
_class: *mut c_void,
|
||||||
|
handle: JLong,
|
||||||
|
) {
|
||||||
|
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let h = unsafe { handle_ref(handle) };
|
||||||
|
h.engine.stop_call();
|
||||||
|
info!("call stopped via JNI");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set microphone mute state.
|
||||||
|
///
|
||||||
|
/// Kotlin signature: `private external fun nativeSetMute(handle: Long, muted: Boolean)`
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called from JNI.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetMute(
|
||||||
|
_env: *mut c_void,
|
||||||
|
_class: *mut c_void,
|
||||||
|
handle: JLong,
|
||||||
|
muted: JBoolean,
|
||||||
|
) {
|
||||||
|
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let h = unsafe { handle_ref(handle) };
|
||||||
|
let muted = muted != 0;
|
||||||
|
h.engine.set_mute(muted);
|
||||||
|
info!(muted, "mute set via JNI");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set speaker (loudspeaker) mode.
|
||||||
|
///
|
||||||
|
/// Kotlin signature: `private external fun nativeSetSpeaker(handle: Long, speaker: Boolean)`
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called from JNI.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetSpeaker(
|
||||||
|
_env: *mut c_void,
|
||||||
|
_class: *mut c_void,
|
||||||
|
handle: JLong,
|
||||||
|
speaker: JBoolean,
|
||||||
|
) {
|
||||||
|
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let h = unsafe { handle_ref(handle) };
|
||||||
|
let speaker = speaker != 0;
|
||||||
|
h.engine.set_speaker(speaker);
|
||||||
|
info!(speaker, "speaker set via JNI");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get call statistics as a JSON string.
|
||||||
|
///
|
||||||
|
/// Kotlin signature: `private external fun nativeGetStats(handle: Long): String`
|
||||||
|
///
|
||||||
|
/// Returns a JSON-serialized `CallStats` struct, or `"{}"` on error.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called from JNI.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetStats(
|
||||||
|
env: *mut c_void,
|
||||||
|
_class: *mut c_void,
|
||||||
|
handle: JLong,
|
||||||
|
) -> *mut c_void {
|
||||||
|
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let h = unsafe { handle_ref(handle) };
|
||||||
|
let stats = h.engine.get_stats();
|
||||||
|
match serde_json::to_string(&stats) {
|
||||||
|
Ok(json) => unsafe { new_jstring(env, &json) },
|
||||||
|
Err(e) => {
|
||||||
|
error!("failed to serialize stats: {e}");
|
||||||
|
unsafe { new_jstring(env, "{}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(ptr) => ptr,
|
||||||
|
Err(_) => {
|
||||||
|
error!("panic in nativeGetStats");
|
||||||
|
unsafe { new_jstring(env, "{}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force a specific quality profile, overriding adaptive logic.
|
||||||
|
///
|
||||||
|
/// Kotlin signature: `private external fun nativeForceProfile(handle: Long, profile: Int)`
|
||||||
|
///
|
||||||
|
/// Profile values: 0 = GOOD, 1 = DEGRADED, 2 = CATASTROPHIC.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called from JNI.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeForceProfile(
|
||||||
|
_env: *mut c_void,
|
||||||
|
_class: *mut c_void,
|
||||||
|
handle: JLong,
|
||||||
|
profile: JInt,
|
||||||
|
) {
|
||||||
|
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let h = unsafe { handle_ref(handle) };
|
||||||
|
let qp = profile_from_int(profile);
|
||||||
|
h.engine.force_profile(qp);
|
||||||
|
info!(?qp, "profile forced via JNI");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destroy the engine and free all associated memory.
|
||||||
|
///
|
||||||
|
/// After this call the handle is invalid and must not be reused.
|
||||||
|
///
|
||||||
|
/// Kotlin signature: `private external fun nativeDestroy(handle: Long)`
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called from JNI. `handle` must be a live engine handle. After this call
|
||||||
|
/// the handle is dangling.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
||||||
|
_env: *mut c_void,
|
||||||
|
_class: *mut c_void,
|
||||||
|
handle: JLong,
|
||||||
|
) {
|
||||||
|
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
// Retake ownership of the Box and drop it, which calls WzpEngine::drop()
|
||||||
|
// and in turn stop_call().
|
||||||
|
let h = unsafe { Box::from_raw(handle as *mut EngineHandle) };
|
||||||
|
drop(h);
|
||||||
|
info!("engine destroyed via JNI");
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -14,4 +14,4 @@ pub mod commands;
|
|||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
// pub mod jni_bridge; // Added later by Agent 4
|
pub mod jni_bridge;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! exclusively by the codec thread.
|
//! exclusively by the codec thread.
|
||||||
|
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder};
|
use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder, AutoGainControl, EchoCanceller};
|
||||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||||
use wzp_proto::quality::AdaptiveQualityController;
|
use wzp_proto::quality::AdaptiveQualityController;
|
||||||
@@ -38,6 +38,12 @@ pub struct Pipeline {
|
|||||||
fec_decoder: RaptorQFecDecoder,
|
fec_decoder: RaptorQFecDecoder,
|
||||||
jitter_buffer: JitterBuffer,
|
jitter_buffer: JitterBuffer,
|
||||||
quality_ctrl: AdaptiveQualityController,
|
quality_ctrl: AdaptiveQualityController,
|
||||||
|
/// Acoustic echo canceller applied before encoding.
|
||||||
|
aec: EchoCanceller,
|
||||||
|
/// Automatic gain control applied before encoding.
|
||||||
|
agc: AutoGainControl,
|
||||||
|
/// Last decoded PCM frame, used as the AEC far-end reference.
|
||||||
|
last_decoded_farend: Option<Vec<i16>>,
|
||||||
// Pre-allocated scratch buffers
|
// Pre-allocated scratch buffers
|
||||||
capture_buf: Vec<i16>,
|
capture_buf: Vec<i16>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -70,6 +76,9 @@ impl Pipeline {
|
|||||||
fec_decoder,
|
fec_decoder,
|
||||||
jitter_buffer,
|
jitter_buffer,
|
||||||
quality_ctrl,
|
quality_ctrl,
|
||||||
|
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
||||||
|
agc: AutoGainControl::new(),
|
||||||
|
last_decoded_farend: None,
|
||||||
capture_buf: vec![0i16; FRAME_SAMPLES],
|
capture_buf: vec![0i16; FRAME_SAMPLES],
|
||||||
playout_buf: vec![0i16; FRAME_SAMPLES],
|
playout_buf: vec![0i16; FRAME_SAMPLES],
|
||||||
encode_out: vec![0u8; MAX_ENCODED_BYTES],
|
encode_out: vec![0u8; MAX_ENCODED_BYTES],
|
||||||
@@ -91,7 +100,17 @@ impl Pipeline {
|
|||||||
}
|
}
|
||||||
&self.capture_buf[..]
|
&self.capture_buf[..]
|
||||||
} else {
|
} else {
|
||||||
pcm
|
// Feed the last decoded playout as AEC far-end reference.
|
||||||
|
if let Some(ref farend) = self.last_decoded_farend {
|
||||||
|
self.aec.feed_farend(farend);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply AEC + AGC to the captured PCM.
|
||||||
|
let len = pcm.len().min(self.capture_buf.len());
|
||||||
|
self.capture_buf[..len].copy_from_slice(&pcm[..len]);
|
||||||
|
self.aec.process_frame(&mut self.capture_buf[..len]);
|
||||||
|
self.agc.process_frame(&mut self.capture_buf[..len]);
|
||||||
|
&self.capture_buf[..len]
|
||||||
};
|
};
|
||||||
|
|
||||||
match self.encoder.encode(input, &mut self.encode_out) {
|
match self.encoder.encode(input, &mut self.encode_out) {
|
||||||
@@ -135,8 +154,10 @@ impl Pipeline {
|
|||||||
/// Decode the next frame from the jitter buffer.
|
/// Decode the next frame from the jitter buffer.
|
||||||
///
|
///
|
||||||
/// Returns decoded PCM samples, or `None` if the buffer is not ready.
|
/// Returns decoded PCM samples, or `None` if the buffer is not ready.
|
||||||
|
/// Decoded PCM is also stored as the AEC far-end reference for the next
|
||||||
|
/// encode cycle.
|
||||||
pub fn decode_frame(&mut self) -> Option<Vec<i16>> {
|
pub fn decode_frame(&mut self) -> Option<Vec<i16>> {
|
||||||
match self.jitter_buffer.pop() {
|
let result = match self.jitter_buffer.pop() {
|
||||||
PlayoutResult::Packet(pkt) => {
|
PlayoutResult::Packet(pkt) => {
|
||||||
let mut pcm = vec![0i16; FRAME_SAMPLES];
|
let mut pcm = vec![0i16; FRAME_SAMPLES];
|
||||||
match self.decoder.decode(&pkt.payload, &mut pcm) {
|
match self.decoder.decode(&pkt.payload, &mut pcm) {
|
||||||
@@ -160,7 +181,14 @@ impl Pipeline {
|
|||||||
self.underruns += 1;
|
self.underruns += 1;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save decoded PCM as far-end reference for AEC.
|
||||||
|
if let Some(ref pcm) = result {
|
||||||
|
self.last_decoded_farend = Some(pcm.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate packet loss concealment output.
|
/// Generate packet loss concealment output.
|
||||||
@@ -221,4 +249,14 @@ impl Pipeline {
|
|||||||
quality_tier: self.quality_ctrl.tier() as u8,
|
quality_tier: self.quality_ctrl.tier() as u8,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable or disable acoustic echo cancellation.
|
||||||
|
pub fn set_aec_enabled(&mut self, enabled: bool) {
|
||||||
|
self.aec.set_enabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable automatic gain control.
|
||||||
|
pub fn set_agc_enabled(&mut self, enabled: bool) {
|
||||||
|
self.agc.set_enabled(enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use wzp_codec::{ComfortNoise, NoiseSupressor, SilenceDetector};
|
use wzp_codec::{AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector};
|
||||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||||
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
||||||
@@ -207,6 +207,10 @@ pub struct CallEncoder {
|
|||||||
frame_in_block: u8,
|
frame_in_block: u8,
|
||||||
/// Timestamp counter (ms).
|
/// Timestamp counter (ms).
|
||||||
timestamp_ms: u32,
|
timestamp_ms: u32,
|
||||||
|
/// Acoustic echo canceller (removes speaker echo from mic signal).
|
||||||
|
aec: EchoCanceller,
|
||||||
|
/// Automatic gain control (normalises mic level).
|
||||||
|
agc: AutoGainControl,
|
||||||
/// Silence detector for suppression.
|
/// Silence detector for suppression.
|
||||||
silence_detector: SilenceDetector,
|
silence_detector: SilenceDetector,
|
||||||
/// Whether silence suppression is enabled.
|
/// Whether silence suppression is enabled.
|
||||||
@@ -237,6 +241,8 @@ impl CallEncoder {
|
|||||||
block_id: 0,
|
block_id: 0,
|
||||||
frame_in_block: 0,
|
frame_in_block: 0,
|
||||||
timestamp_ms: 0,
|
timestamp_ms: 0,
|
||||||
|
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
||||||
|
agc: AutoGainControl::new(),
|
||||||
silence_detector: SilenceDetector::new(
|
silence_detector: SilenceDetector::new(
|
||||||
config.silence_threshold_rms,
|
config.silence_threshold_rms,
|
||||||
config.silence_hangover_frames,
|
config.silence_hangover_frames,
|
||||||
@@ -274,15 +280,21 @@ impl CallEncoder {
|
|||||||
/// Input: 48kHz mono PCM, frame size depends on profile (960 for 20ms, 1920 for 40ms).
|
/// Input: 48kHz mono PCM, frame size depends on profile (960 for 20ms, 1920 for 40ms).
|
||||||
/// Output: one or more MediaPackets to send.
|
/// Output: one or more MediaPackets to send.
|
||||||
pub fn encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error> {
|
pub fn encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error> {
|
||||||
// Noise suppression: denoise the PCM before silence detection and encoding.
|
// Copy PCM into a mutable buffer for the processing pipeline.
|
||||||
let pcm = if self.denoiser.is_enabled() {
|
let mut pcm_buf = pcm.to_vec();
|
||||||
let mut buf = pcm.to_vec();
|
|
||||||
self.denoiser.process(&mut buf);
|
// Step 1: Echo cancellation (far-end reference must have been fed already).
|
||||||
buf
|
self.aec.process_frame(&mut pcm_buf);
|
||||||
} else {
|
|
||||||
pcm.to_vec()
|
// Step 2: Automatic gain control (normalise mic level).
|
||||||
};
|
self.agc.process_frame(&mut pcm_buf);
|
||||||
let pcm = &pcm[..];
|
|
||||||
|
// Step 3: Noise suppression (RNNoise).
|
||||||
|
if self.denoiser.is_enabled() {
|
||||||
|
self.denoiser.process(&mut pcm_buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pcm = &pcm_buf[..];
|
||||||
|
|
||||||
// Silence suppression: skip encoding silent frames, periodically send CN.
|
// Silence suppression: skip encoding silent frames, periodically send CN.
|
||||||
if self.suppression_enabled && self.silence_detector.is_silent(pcm) {
|
if self.suppression_enabled && self.silence_detector.is_silent(pcm) {
|
||||||
@@ -400,6 +412,24 @@ impl CallEncoder {
|
|||||||
self.frame_in_block = 0;
|
self.frame_in_block = 0;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feed decoded playout audio as the echo reference signal.
|
||||||
|
///
|
||||||
|
/// Must be called with each decoded frame BEFORE the corresponding
|
||||||
|
/// microphone frame is processed.
|
||||||
|
pub fn feed_aec_farend(&mut self, farend: &[i16]) {
|
||||||
|
self.aec.feed_farend(farend);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable acoustic echo cancellation.
|
||||||
|
pub fn set_aec_enabled(&mut self, enabled: bool) {
|
||||||
|
self.aec.set_enabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable automatic gain control.
|
||||||
|
pub fn set_agc_enabled(&mut self, enabled: bool) {
|
||||||
|
self.agc.set_enabled(enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manages the recv/decode side of a call.
|
/// Manages the recv/decode side of a call.
|
||||||
|
|||||||
Reference in New Issue
Block a user