diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0e65563..e412d5e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -38,7 +38,7 @@ android { } release { signingConfig = signingConfigs.getByName("release") - isMinifyEnabled = true + isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt index cbc6165..e67ce73 100644 --- a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt +++ b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt @@ -72,7 +72,12 @@ class WzpEngine(private val callback: WzpCallback) { * @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised. */ fun getStats(): String { - return if (nativeHandle != 0L) nativeGetStats(nativeHandle) else "{}" + if (nativeHandle == 0L) return "{}" + return try { + nativeGetStats(nativeHandle) ?: "{}" + } catch (_: Exception) { + "{}" + } } /** @@ -101,7 +106,7 @@ class WzpEngine(private val callback: WzpCallback) { 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 nativeGetStats(handle: Long): String? private external fun nativeForceProfile(handle: Long, profile: Int) private external fun nativeDestroy(handle: Long) 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 index 0af9bac..eef64c0 100644 --- a/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt +++ b/android/app/src/main/java/com/wzp/ui/call/CallActivity.kt @@ -1,7 +1,6 @@ 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 @@ -18,33 +17,25 @@ 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]. + * Shows the call screen. Does NOT auto-start a call — the user must + * tap "Connect" in the UI. */ class CallActivity : ComponentActivity() { private val viewModel: CallViewModel by viewModels() - // -- Permission request --------------------------------------------------- - private val audioPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> - if (granted) { - startCallFlow() - } else { + if (!granted) { Toast.makeText(this, "Microphone permission is required for calls", Toast.LENGTH_LONG).show() - finish() } } - // -- Lifecycle ------------------------------------------------------------ - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -54,19 +45,16 @@ class CallActivity : ComponentActivity() { viewModel = viewModel, onHangUp = { viewModel.stopCall() - CallService.stop(this@CallActivity) finish() } ) } } - // Check audio permission + // Request audio permission proactively but don't start a call if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) - == PackageManager.PERMISSION_GRANTED + != PackageManager.PERMISSION_GRANTED ) { - startCallFlow() - } else { audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } } @@ -75,52 +63,16 @@ class CallActivity : ComponentActivity() { 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) } 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 index e9bd8ad..54ec612 100644 --- a/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt +++ b/android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt @@ -13,21 +13,13 @@ 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 var engine: WzpEngine? = null + private var engineInitialized = false - private val engine = WzpEngine(this) - - // -- Observable state ----------------------------------------------------- - - private val _callState = MutableStateFlow(0) // CallStateConstants.IDLE + // Observable state + private val _callState = MutableStateFlow(0) val callState: StateFlow = _callState.asStateFlow() private val _isMuted = MutableStateFlow(false) @@ -45,79 +37,68 @@ class CallViewModel : ViewModel(), WzpCallback { 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() + try { + if (engine == null) { + engine = WzpEngine(this) + } + if (!engineInitialized) { + engine?.init() + engineInitialized = true + } + val result = engine?.startCall(relayAddr, room, seedHex, token) ?: -1 + if (result == 0) { + _callState.value = 1 // Connecting + startStatsPolling() + } else { + _errorMessage.value = "Failed to start call (code $result)" + } + } catch (e: Exception) { + _errorMessage.value = "Engine error: ${e.message}" } } - /** End the current call and clean up resources. */ fun stopCall() { stopStatsPolling() - engine.stopCall() + try { + engine?.stopCall() + } catch (_: Exception) {} + _callState.value = 0 } - /** Toggle microphone mute. */ fun toggleMute() { val newMuted = !_isMuted.value _isMuted.value = newMuted - engine.setMute(newMuted) + try { engine?.setMute(newMuted) } catch (_: Exception) {} } - /** Toggle speaker (loudspeaker) mode. */ fun toggleSpeaker() { val newSpeaker = !_isSpeaker.value _isSpeaker.value = newSpeaker - engine.setSpeaker(newSpeaker) + try { engine?.setSpeaker(newSpeaker) } catch (_: Exception) {} } - /** Clear the current error message. */ - fun clearError() { - _errorMessage.value = null - } + 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 -------------------------------------------------------- + // 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" } 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) + try { + val json = engine?.getStats() ?: "{}" + if (json.isNotEmpty()) { + val parsed = CallStats.fromJson(json) + _stats.value = parsed + } + } catch (_: Exception) {} + delay(500L) } } } @@ -127,16 +108,14 @@ class CallViewModel : ViewModel(), WzpCallback { statsJob = null } - // -- Cleanup -------------------------------------------------------------- - override fun onCleared() { super.onCleared() stopStatsPolling() - engine.stopCall() - engine.destroy() - } - - companion object { - private const val STATS_POLL_INTERVAL_MS = 500L + try { + engine?.stopCall() + engine?.destroy() + } catch (_: Exception) {} + engine = null + engineInitialized = false } } diff --git a/crates/wzp-android/src/jni_bridge.rs b/crates/wzp-android/src/jni_bridge.rs index 5c0989d..7fa5c3b 100644 --- a/crates/wzp-android/src/jni_bridge.rs +++ b/crates/wzp-android/src/jni_bridge.rs @@ -109,13 +109,9 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit( _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(); - } + // Note: tracing on Android requires android_logger or similar. + // fmt() subscriber writes to stdout which doesn't exist on Android. + // Skip tracing init here — add android_logger later. let handle = Box::new(EngineHandle { engine: WzpEngine::new(), diff --git a/wzp-debug.apk b/wzp-debug.apk new file mode 100644 index 0000000..58147dc Binary files /dev/null and b/wzp-debug.apk differ diff --git a/wzp-release.apk b/wzp-release.apk new file mode 100644 index 0000000..63a234b Binary files /dev/null and b/wzp-release.apk differ