diff --git a/android/app/src/main/java/com/wzp/WzpApplication.kt b/android/app/src/main/java/com/wzp/WzpApplication.kt new file mode 100644 index 0000000..52f72df --- /dev/null +++ b/android/app/src/main/java/com/wzp/WzpApplication.kt @@ -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" + } +} diff --git a/android/app/src/main/java/com/wzp/audio/AudioRouteManager.kt b/android/app/src/main/java/com/wzp/audio/AudioRouteManager.kt new file mode 100644 index 0000000..b037298 --- /dev/null +++ b/android/app/src/main/java/com/wzp/audio/AudioRouteManager.kt @@ -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) { + 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) { + 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 { + 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 +} diff --git a/android/app/src/main/java/com/wzp/engine/CallStats.kt b/android/app/src/main/java/com/wzp/engine/CallStats.kt new file mode 100644 index 0000000..a2ff79a --- /dev/null +++ b/android/app/src/main/java/com/wzp/engine/CallStats.kt @@ -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() + } + } + } +} diff --git a/android/app/src/main/java/com/wzp/engine/WzpCallback.kt b/android/app/src/main/java/com/wzp/engine/WzpCallback.kt new file mode 100644 index 0000000..8526719 --- /dev/null +++ b/android/app/src/main/java/com/wzp/engine/WzpCallback.kt @@ -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) +} diff --git a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt new file mode 100644 index 0000000..cbc6165 --- /dev/null +++ b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt @@ -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 +} diff --git a/android/app/src/main/java/com/wzp/service/CallService.kt b/android/app/src/main/java/com/wzp/service/CallService.kt new file mode 100644 index 0000000..ea0021d --- /dev/null +++ b/android/app/src/main/java/com/wzp/service/CallService.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt b/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt new file mode 100644 index 0000000..0af9bac --- /dev/null +++ b/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt @@ -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 + ) +} diff --git a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt new file mode 100644 index 0000000..e9bd8ad --- /dev/null +++ b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt @@ -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 = _callState.asStateFlow() + + private val _isMuted = MutableStateFlow(false) + val isMuted: StateFlow = _isMuted.asStateFlow() + + private val _isSpeaker = MutableStateFlow(false) + val isSpeaker: StateFlow = _isSpeaker.asStateFlow() + + private val _stats = MutableStateFlow(CallStats()) + val stats: StateFlow = _stats.asStateFlow() + + private val _qualityTier = MutableStateFlow(0) + val qualityTier: StateFlow = _qualityTier.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _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 + } +} diff --git a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt new file mode 100644 index 0000000..16c3206 --- /dev/null +++ b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt @@ -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 + ) + } +} diff --git a/crates/wzp-android/src/engine.rs b/crates/wzp-android/src/engine.rs index eba351e..1da3d75 100644 --- a/crates/wzp-android/src/engine.rs +++ b/crates/wzp-android/src/engine.rs @@ -46,6 +46,10 @@ struct EngineState { running: AtomicBool, muted: 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, command_tx: std::sync::mpsc::Sender, command_rx: Mutex>>, @@ -76,6 +80,8 @@ impl WzpEngine { running: AtomicBool::new(false), muted: AtomicBool::new(false), speaker: AtomicBool::new(false), + aec_enabled: AtomicBool::new(true), + agc_enabled: AtomicBool::new(true), stats: Mutex::new(CallStats::default()), command_tx: tx, command_rx: Mutex::new(Some(rx)), @@ -182,6 +188,11 @@ impl WzpEngine { 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]; #[allow(unused_assignments)] let mut recv_buf: Vec = 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) { break; } @@ -319,6 +342,16 @@ impl WzpEngine { .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). #[allow(unused)] pub fn force_profile(&self, profile: QualityProfile) { diff --git a/crates/wzp-android/src/jni_bridge.rs b/crates/wzp-android/src/jni_bridge.rs new file mode 100644 index 0000000..5c0989d --- /dev/null +++ b/crates/wzp-android/src/jni_bridge.rs @@ -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___ +// 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"); + })); +} diff --git a/crates/wzp-android/src/lib.rs b/crates/wzp-android/src/lib.rs index 216f0f7..a5c7f2a 100644 --- a/crates/wzp-android/src/lib.rs +++ b/crates/wzp-android/src/lib.rs @@ -14,4 +14,4 @@ pub mod commands; pub mod engine; pub mod pipeline; pub mod stats; -// pub mod jni_bridge; // Added later by Agent 4 +pub mod jni_bridge; diff --git a/crates/wzp-android/src/pipeline.rs b/crates/wzp-android/src/pipeline.rs index ea49223..0ddb7eb 100644 --- a/crates/wzp-android/src/pipeline.rs +++ b/crates/wzp-android/src/pipeline.rs @@ -5,7 +5,7 @@ //! exclusively by the codec thread. use tracing::{debug, warn}; -use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder}; +use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder, AutoGainControl, EchoCanceller}; use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder}; use wzp_proto::jitter::{JitterBuffer, PlayoutResult}; use wzp_proto::quality::AdaptiveQualityController; @@ -38,6 +38,12 @@ pub struct Pipeline { fec_decoder: RaptorQFecDecoder, jitter_buffer: JitterBuffer, 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>, // Pre-allocated scratch buffers capture_buf: Vec, #[allow(dead_code)] @@ -70,6 +76,9 @@ impl Pipeline { fec_decoder, jitter_buffer, quality_ctrl, + aec: EchoCanceller::new(48000, 100), // 100 ms echo tail + agc: AutoGainControl::new(), + last_decoded_farend: None, capture_buf: vec![0i16; FRAME_SAMPLES], playout_buf: vec![0i16; FRAME_SAMPLES], encode_out: vec![0u8; MAX_ENCODED_BYTES], @@ -91,7 +100,17 @@ impl Pipeline { } &self.capture_buf[..] } 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) { @@ -135,8 +154,10 @@ impl Pipeline { /// Decode the next frame from the jitter buffer. /// /// 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> { - match self.jitter_buffer.pop() { + let result = match self.jitter_buffer.pop() { PlayoutResult::Packet(pkt) => { let mut pcm = vec![0i16; FRAME_SAMPLES]; match self.decoder.decode(&pkt.payload, &mut pcm) { @@ -160,7 +181,14 @@ impl Pipeline { self.underruns += 1; 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. @@ -221,4 +249,14 @@ impl Pipeline { 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); + } } diff --git a/crates/wzp-client/src/call.rs b/crates/wzp-client/src/call.rs index 3bd219e..7250a12 100644 --- a/crates/wzp-client/src/call.rs +++ b/crates/wzp-client/src/call.rs @@ -7,7 +7,7 @@ use std::time::{Duration, Instant}; use bytes::Bytes; 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_proto::jitter::{JitterBuffer, PlayoutResult}; use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext}; @@ -207,6 +207,10 @@ pub struct CallEncoder { frame_in_block: u8, /// Timestamp counter (ms). 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: SilenceDetector, /// Whether silence suppression is enabled. @@ -237,6 +241,8 @@ impl CallEncoder { block_id: 0, frame_in_block: 0, timestamp_ms: 0, + aec: EchoCanceller::new(48000, 100), // 100 ms echo tail + agc: AutoGainControl::new(), silence_detector: SilenceDetector::new( config.silence_threshold_rms, 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). /// Output: one or more MediaPackets to send. pub fn encode_frame(&mut self, pcm: &[i16]) -> Result, anyhow::Error> { - // Noise suppression: denoise the PCM before silence detection and encoding. - let pcm = if self.denoiser.is_enabled() { - let mut buf = pcm.to_vec(); - self.denoiser.process(&mut buf); - buf - } else { - pcm.to_vec() - }; - let pcm = &pcm[..]; + // Copy PCM into a mutable buffer for the processing pipeline. + let mut pcm_buf = pcm.to_vec(); + + // Step 1: Echo cancellation (far-end reference must have been fed already). + self.aec.process_frame(&mut pcm_buf); + + // Step 2: Automatic gain control (normalise mic level). + self.agc.process_frame(&mut pcm_buf); + + // 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. if self.suppression_enabled && self.silence_detector.is_silent(pcm) { @@ -400,6 +412,24 @@ impl CallEncoder { self.frame_in_block = 0; 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.