From 357b6409ed92f90eacd92c97678c7a0af83302c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 03:49:32 +0000 Subject: [PATCH] feat: settings page with persistence, client alias in handshake, fix null fingerprints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SettingsScreen with identity (alias, key backup/restore), audio defaults, server management, network prefs, and default room - SettingsRepository persists all settings via SharedPreferences - Auto-generate random display names on first launch (e.g. "Swift Wolf") - Thread alias through CallOffer → relay handshake → RoomUpdate broadcast - Derive caller fingerprint from identity key in relay handshake (fixes null fingerprints when --auth-url is not set) - Persist identity seed for stable fingerprints across reconnects - Add alias field to SignalMessage::CallOffer (serde default for backward compat) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/wzp/data/SettingsRepository.kt | 135 ++++++ .../src/main/java/com/wzp/engine/WzpEngine.kt | 7 +- .../main/java/com/wzp/ui/call/CallActivity.kt | 24 +- .../java/com/wzp/ui/call/CallViewModel.kt | 58 ++- .../main/java/com/wzp/ui/call/InCallScreen.kt | 14 +- .../com/wzp/ui/settings/SettingsScreen.kt | 437 ++++++++++++++++++ crates/wzp-android/src/engine.rs | 7 +- crates/wzp-android/src/jni_bridge.rs | 3 + crates/wzp-client/src/cli.rs | 1 + crates/wzp-client/src/handshake.rs | 2 + crates/wzp-proto/src/packet.rs | 3 + crates/wzp-relay/src/handshake.rs | 18 +- crates/wzp-relay/src/main.rs | 13 +- 13 files changed, 696 insertions(+), 26 deletions(-) create mode 100644 android/app/src/main/java/com/wzp/data/SettingsRepository.kt create mode 100644 android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt diff --git a/android/app/src/main/java/com/wzp/data/SettingsRepository.kt b/android/app/src/main/java/com/wzp/data/SettingsRepository.kt new file mode 100644 index 0000000..2d2162c --- /dev/null +++ b/android/app/src/main/java/com/wzp/data/SettingsRepository.kt @@ -0,0 +1,135 @@ +package com.wzp.data + +import android.content.Context +import android.content.SharedPreferences +import com.wzp.ui.call.ServerEntry +import org.json.JSONArray +import org.json.JSONObject +import java.security.SecureRandom + +/** + * Persists user settings via SharedPreferences. + * + * Stores: servers, default server index, room name, alias, gain values, + * IPv6 preference, and the identity seed (hex-encoded 32 bytes). + */ +class SettingsRepository(context: Context) { + + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences("wzp_settings", Context.MODE_PRIVATE) + + companion object { + private const val KEY_SERVERS = "servers_json" + private const val KEY_SELECTED_SERVER = "selected_server" + private const val KEY_ROOM = "room_name" + private const val KEY_ALIAS = "alias" + private const val KEY_PLAYOUT_GAIN = "playout_gain_db" + private const val KEY_CAPTURE_GAIN = "capture_gain_db" + private const val KEY_PREFER_IPV6 = "prefer_ipv6" + private const val KEY_IDENTITY_SEED = "identity_seed_hex" + } + + // --- Servers --- + + fun saveServers(servers: List) { + val arr = JSONArray() + servers.forEach { entry -> + arr.put(JSONObject().apply { + put("address", entry.address) + put("label", entry.label) + }) + } + prefs.edit().putString(KEY_SERVERS, arr.toString()).apply() + } + + fun loadServers(): List? { + val json = prefs.getString(KEY_SERVERS, null) ?: return null + return try { + val arr = JSONArray(json) + (0 until arr.length()).map { i -> + val obj = arr.getJSONObject(i) + ServerEntry(obj.getString("address"), obj.getString("label")) + } + } catch (_: Exception) { null } + } + + fun saveSelectedServer(index: Int) { + prefs.edit().putInt(KEY_SELECTED_SERVER, index).apply() + } + + fun loadSelectedServer(): Int = prefs.getInt(KEY_SELECTED_SERVER, 0) + + // --- Room --- + + fun saveRoom(name: String) { prefs.edit().putString(KEY_ROOM, name).apply() } + fun loadRoom(): String = prefs.getString(KEY_ROOM, "android") ?: "android" + + // --- Alias --- + + fun saveAlias(alias: String) { prefs.edit().putString(KEY_ALIAS, alias).apply() } + + /** + * Load alias, generating a random name on first launch. + */ + fun getOrCreateAlias(): String { + val existing = prefs.getString(KEY_ALIAS, null) + if (!existing.isNullOrEmpty()) return existing + val name = generateRandomName() + prefs.edit().putString(KEY_ALIAS, name).apply() + return name + } + + private fun generateRandomName(): String { + val adjectives = listOf( + "Swift", "Silent", "Brave", "Calm", "Dark", "Fierce", "Ghost", + "Iron", "Lucky", "Noble", "Quick", "Sharp", "Storm", "Wild", + "Cold", "Bright", "Lone", "Red", "Grey", "Frosty", "Dusty", + "Rusty", "Neon", "Void", "Solar", "Lunar", "Cyber", "Pixel", + "Sonic", "Hyper", "Turbo", "Nano", "Mega", "Ultra", "Zinc" + ) + val nouns = listOf( + "Wolf", "Hawk", "Fox", "Bear", "Lynx", "Crow", "Viper", + "Cobra", "Tiger", "Eagle", "Shark", "Raven", "Falcon", "Otter", + "Mantis", "Panda", "Jackal", "Badger", "Heron", "Bison", + "Condor", "Coyote", "Gecko", "Hornet", "Marten", "Osprey", + "Parrot", "Puma", "Raptor", "Stork", "Toucan", "Walrus" + ) + val adj = adjectives.random() + val noun = nouns.random() + return "$adj $noun" + } + + // --- Gain --- + + fun savePlayoutGain(db: Float) { prefs.edit().putFloat(KEY_PLAYOUT_GAIN, db).apply() } + fun loadPlayoutGain(): Float = prefs.getFloat(KEY_PLAYOUT_GAIN, 0f) + + fun saveCaptureGain(db: Float) { prefs.edit().putFloat(KEY_CAPTURE_GAIN, db).apply() } + fun loadCaptureGain(): Float = prefs.getFloat(KEY_CAPTURE_GAIN, 0f) + + // --- IPv6 --- + + fun savePreferIPv6(prefer: Boolean) { prefs.edit().putBoolean(KEY_PREFER_IPV6, prefer).apply() } + fun loadPreferIPv6(): Boolean = prefs.getBoolean(KEY_PREFER_IPV6, false) + + // --- Identity seed --- + + /** + * Get or generate the identity seed. On first call, generates a random + * 32-byte seed and persists it. Subsequent calls return the same seed. + */ + fun getOrCreateSeedHex(): String { + val existing = prefs.getString(KEY_IDENTITY_SEED, null) + if (!existing.isNullOrEmpty()) return existing + val seed = ByteArray(32).also { SecureRandom().nextBytes(it) } + val hex = seed.joinToString("") { "%02x".format(it) } + prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply() + return hex + } + + fun loadSeedHex(): String = prefs.getString(KEY_IDENTITY_SEED, "") ?: "" + + fun saveSeedHex(hex: String) { + prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply() + } +} 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 4966d4a..6e863df 100644 --- a/android/app/src/main/java/com/wzp/engine/WzpEngine.kt +++ b/android/app/src/main/java/com/wzp/engine/WzpEngine.kt @@ -35,11 +35,12 @@ class WzpEngine(private val callback: WzpCallback) { * @param room room identifier (used as QUIC SNI) * @param seedHex 64-char hex-encoded 32-byte identity seed (empty = random) * @param token authentication token (empty = no auth) + * @param alias display name sent to relay for room participant list * @return 0 on success, negative error code on failure */ - fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = ""): Int { + fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = "", alias: String = ""): Int { check(nativeHandle != 0L) { "Engine not initialized" } - val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token) + val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias) if (result == 0) { callback.onCallStateChanged(CallStateConstants.CONNECTING) } else { @@ -120,7 +121,7 @@ class WzpEngine(private val callback: WzpCallback) { private external fun nativeInit(): Long private external fun nativeStartCall( - handle: Long, relay: String, room: String, seed: String, token: String + handle: Long, relay: String, room: String, seed: String, token: String, alias: String ): Int private external fun nativeStopCall(handle: Long) private external fun nativeSetMute(handle: Long, muted: Boolean) 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 b0623ad..a2e46a9 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 @@ -15,8 +15,13 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat +import com.wzp.ui.settings.SettingsScreen /** * Main activity hosting the in-call Compose UI. @@ -43,12 +48,19 @@ class CallActivity : ComponentActivity() { setContent { WzpTheme { - InCallScreen( - viewModel = viewModel, - onHangUp = { - viewModel.stopCall() - } - ) + var showSettings by remember { mutableStateOf(false) } + if (showSettings) { + SettingsScreen( + viewModel = viewModel, + onBack = { showSettings = false } + ) + } else { + InCallScreen( + viewModel = viewModel, + onHangUp = { viewModel.stopCall() }, + onOpenSettings = { showSettings = true } + ) + } } } 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 048b135..e2b28f2 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 @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wzp.audio.AudioPipeline import com.wzp.audio.AudioRouteManager +import com.wzp.data.SettingsRepository import com.wzp.engine.CallStats import com.wzp.service.CallService import com.wzp.engine.WzpCallback @@ -31,6 +32,7 @@ class CallViewModel : ViewModel(), WzpCallback { private var audioRouteManager: AudioRouteManager? = null private var audioStarted = false private var appContext: Context? = null + private var settings: SettingsRepository? = null private val _callState = MutableStateFlow(0) val callState: StateFlow get() = _callState.asStateFlow() @@ -68,6 +70,12 @@ class CallViewModel : ViewModel(), WzpCallback { private val _captureGainDb = MutableStateFlow(0f) val captureGainDb: StateFlow = _captureGainDb.asStateFlow() + private val _alias = MutableStateFlow("") + val alias: StateFlow = _alias.asStateFlow() + + private val _seedHex = MutableStateFlow("") + val seedHex: StateFlow = _seedHex.asStateFlow() + private var statsJob: Job? = null companion object { @@ -88,20 +96,43 @@ class CallViewModel : ViewModel(), WzpCallback { if (audioRouteManager == null) { audioRouteManager = AudioRouteManager(appCtx) } + if (settings == null) { + settings = SettingsRepository(appCtx) + loadSettings() + } + } + + private fun loadSettings() { + val s = settings ?: return + s.loadServers()?.let { saved -> + if (saved.isNotEmpty()) _servers.value = saved + } + _selectedServer.value = s.loadSelectedServer().coerceIn(0, _servers.value.lastIndex) + _roomName.value = s.loadRoom() + _alias.value = s.getOrCreateAlias() + _preferIPv6.value = s.loadPreferIPv6() + _playoutGainDb.value = s.loadPlayoutGain() + _captureGainDb.value = s.loadCaptureGain() + _seedHex.value = s.getOrCreateSeedHex() } fun selectServer(index: Int) { if (index in _servers.value.indices) { _selectedServer.value = index + settings?.saveSelectedServer(index) } } - fun setPreferIPv6(prefer: Boolean) { _preferIPv6.value = prefer } + fun setPreferIPv6(prefer: Boolean) { + _preferIPv6.value = prefer + settings?.savePreferIPv6(prefer) + } fun addServer(hostPort: String, label: String) { val current = _servers.value.toMutableList() current.add(ServerEntry(hostPort, label)) _servers.value = current + settings?.saveServers(current) } fun removeServer(index: Int) { @@ -113,19 +144,36 @@ class CallViewModel : ViewModel(), WzpCallback { if (_selectedServer.value >= current.size) { _selectedServer.value = 0 } + settings?.saveServers(current) + settings?.saveSelectedServer(_selectedServer.value) } } - fun setRoomName(name: String) { _roomName.value = name } + fun setRoomName(name: String) { + _roomName.value = name + settings?.saveRoom(name) + } fun setPlayoutGainDb(db: Float) { _playoutGainDb.value = db audioPipeline?.playoutGainDb = db + settings?.savePlayoutGain(db) } fun setCaptureGainDb(db: Float) { _captureGainDb.value = db audioPipeline?.captureGainDb = db + settings?.saveCaptureGain(db) + } + + fun setAlias(alias: String) { + _alias.value = alias + settings?.saveAlias(alias) + } + + fun restoreSeed(hex: String) { + _seedHex.value = hex + settings?.saveSeedHex(hex) } /** @@ -203,8 +251,10 @@ class CallViewModel : ViewModel(), WzpCallback { viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { try { val relay = resolveToIp(serverEntry.address) - Log.i(TAG, "startCall: resolved=$relay, calling engine.startCall") - val result = engine?.startCall(relay, room) ?: -1 + val seed = _seedHex.value + val name = _alias.value + Log.i(TAG, "startCall: resolved=$relay, alias=$name, calling engine.startCall") + val result = engine?.startCall(relay, room, seedHex = seed, alias = name) ?: -1 Log.i(TAG, "startCall: engine returned $result") // Only wire up notification callback after engine is running CallService.onStopFromNotification = { stopCall() } 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 index 649199a..66311e3 100644 --- a/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt +++ b/android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt @@ -54,7 +54,8 @@ import kotlin.math.roundToInt @Composable fun InCallScreen( viewModel: CallViewModel, - onHangUp: () -> Unit + onHangUp: () -> Unit, + onOpenSettings: () -> Unit = {} ) { val callState by viewModel.callState.collectAsState() val isMuted by viewModel.isMuted.collectAsState() @@ -82,7 +83,16 @@ fun InCallScreen( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(48.dp)) + // Settings button (top-right) + if (callState == 0) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onOpenSettings) { + Text("Settings") + } + } + } + + Spacer(modifier = Modifier.height(if (callState == 0) 16.dp else 48.dp)) Text( text = "WZ Phone", diff --git a/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..1b4313d --- /dev/null +++ b/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt @@ -0,0 +1,437 @@ +package com.wzp.ui.settings + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.wzp.ui.call.CallViewModel + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun SettingsScreen( + viewModel: CallViewModel, + onBack: () -> Unit +) { + val context = LocalContext.current + val servers by viewModel.servers.collectAsState() + val selectedServer by viewModel.selectedServer.collectAsState() + val roomName by viewModel.roomName.collectAsState() + val preferIPv6 by viewModel.preferIPv6.collectAsState() + val playoutGainDb by viewModel.playoutGainDb.collectAsState() + val captureGainDb by viewModel.captureGainDb.collectAsState() + val alias by viewModel.alias.collectAsState() + val seedHex by viewModel.seedHex.collectAsState() + + var showAddServerDialog by remember { mutableStateOf(false) } + var showRestoreKeyDialog by remember { mutableStateOf(false) } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onBack) { + Text("< Back") + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "Settings", + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.weight(1f)) + // Balance the back button + Spacer(modifier = Modifier.width(64.dp)) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // --- Identity --- + SectionHeader("Identity") + + OutlinedTextField( + value = alias, + onValueChange = { viewModel.setAlias(it) }, + label = { Text("Display Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Fingerprint display + val fingerprint = if (seedHex.length >= 16) seedHex.take(16).uppercase() else "Not generated" + Text( + text = "Fingerprint", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = fingerprint.chunked(4).joinToString(" "), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Key backup/restore + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", seedHex)) + Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show() + }) { + Text("Copy Key") + } + OutlinedButton(onClick = { showRestoreKeyDialog = true }) { + Text("Restore Key") + } + } + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // --- Audio --- + SectionHeader("Audio Defaults") + + GainSlider( + label = "Voice Volume", + gainDb = playoutGainDb, + onGainChange = { viewModel.setPlayoutGainDb(it) } + ) + Spacer(modifier = Modifier.height(4.dp)) + GainSlider( + label = "Mic Gain", + gainDb = captureGainDb, + onGainChange = { viewModel.setCaptureGainDb(it) } + ) + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // --- Servers --- + SectionHeader("Servers") + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + servers.forEachIndexed { idx, entry -> + val isSelected = selectedServer == idx + Row(verticalAlignment = Alignment.CenterVertically) { + FilledTonalIconButton( + onClick = { viewModel.selectServer(idx) }, + modifier = Modifier + .padding(end = 2.dp) + .height(36.dp) + .width(140.dp), + shape = RoundedCornerShape(8.dp), + colors = if (isSelected) { + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + } else { + IconButtonDefaults.filledTonalIconButtonColors() + } + ) { + Text( + text = entry.label, + style = MaterialTheme.typography.labelSmall, + maxLines = 1 + ) + } + // Show remove button for non-default servers + if (idx >= 2) { + TextButton( + onClick = { viewModel.removeServer(idx) }, + modifier = Modifier.height(36.dp) + ) { + Text("X", color = MaterialTheme.colorScheme.error) + } + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { showAddServerDialog = true }, + shape = RoundedCornerShape(8.dp) + ) { + Text("+ Add Server") + } + + // Show selected server address + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Default: ${servers.getOrNull(selectedServer)?.address ?: "none"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // --- Network --- + SectionHeader("Network") + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Prefer IPv6", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Switch( + checked = preferIPv6, + onCheckedChange = { viewModel.setPreferIPv6(it) } + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // --- Room --- + SectionHeader("Room") + + OutlinedTextField( + value = roomName, + onValueChange = { viewModel.setRoomName(it) }, + label = { Text("Default Room") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } + + if (showAddServerDialog) { + AddServerDialog( + onDismiss = { showAddServerDialog = false }, + onAdd = { host, port, label -> + viewModel.addServer("$host:$port", label) + showAddServerDialog = false + } + ) + } + + if (showRestoreKeyDialog) { + RestoreKeyDialog( + onDismiss = { showRestoreKeyDialog = false }, + onRestore = { hex -> + viewModel.restoreSeed(hex) + showRestoreKeyDialog = false + Toast.makeText(context, "Key restored", Toast.LENGTH_SHORT).show() + } + ) + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) +} + +@Composable +private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Unit) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val sign = if (gainDb >= 0) "+" else "" + Text( + text = "$label: ${sign}${"%.0f".format(gainDb)} dB", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Slider( + value = gainDb, + onValueChange = { onGainChange(Math.round(it).toFloat()) }, + valueRange = -20f..20f, + steps = 0, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun AddServerDialog( + onDismiss: () -> Unit, + onAdd: (host: String, port: String, label: String) -> Unit +) { + var host by remember { mutableStateOf("") } + var port by remember { mutableStateOf("4433") } + var label by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Server") }, + text = { + Column { + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text("Host (IP or domain)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = port, + onValueChange = { port = it }, + label = { Text("Port") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = label, + onValueChange = { label = it }, + label = { Text("Label (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (host.isNotBlank()) { + val displayLabel = label.ifBlank { host } + onAdd(host.trim(), port.trim(), displayLabel) + } + } + ) { Text("Add") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +@Composable +private fun RestoreKeyDialog( + onDismiss: () -> Unit, + onRestore: (hex: String) -> Unit +) { + var keyInput by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Restore Identity Key") }, + text = { + Column { + Text( + text = "Paste your 64-character hex key below. This will replace your current identity.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = keyInput, + onValueChange = { + keyInput = it.trim().lowercase() + error = null + }, + label = { Text("Identity Key (hex)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + isError = error != null + ) + error?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + val cleaned = keyInput.replace("\\s".toRegex(), "") + if (cleaned.length != 64 || !cleaned.all { it in '0'..'9' || it in 'a'..'f' }) { + error = "Key must be exactly 64 hex characters" + } else { + onRestore(cleaned) + } + } + ) { Text("Restore") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} diff --git a/crates/wzp-android/src/engine.rs b/crates/wzp-android/src/engine.rs index 00dfb8d..08ec63e 100644 --- a/crates/wzp-android/src/engine.rs +++ b/crates/wzp-android/src/engine.rs @@ -39,6 +39,7 @@ pub struct CallStartConfig { pub room: String, pub auth_token: Vec, pub identity_seed: [u8; 32], + pub alias: Option, } impl Default for CallStartConfig { @@ -49,6 +50,7 @@ impl Default for CallStartConfig { room: String::new(), auth_token: Vec::new(), identity_seed: [0u8; 32], + alias: None, } } } @@ -117,6 +119,7 @@ impl WzpEngine { let room = config.room.clone(); let identity_seed = config.identity_seed; let profile = config.profile; + let alias = config.alias.clone(); let state = self.state.clone(); self.state.running.store(true, Ordering::Release); @@ -124,7 +127,7 @@ impl WzpEngine { let state_clone = state.clone(); runtime.block_on(async move { - if let Err(e) = run_call(relay_addr, &room, &identity_seed, profile, state_clone).await + if let Err(e) = run_call(relay_addr, &room, &identity_seed, profile, alias.as_deref(), state_clone).await { error!("call failed: {e}"); } @@ -204,6 +207,7 @@ async fn run_call( room: &str, identity_seed: &[u8; 32], profile: QualityProfile, + alias: Option<&str>, state: Arc, ) -> Result<(), anyhow::Error> { let _ = rustls::crypto::ring::default_provider().install_default(); @@ -238,6 +242,7 @@ async fn run_call( QualityProfile::DEGRADED, QualityProfile::CATASTROPHIC, ], + alias: alias.map(|s| s.to_string()), }; transport.send_signal(&offer).await?; info!("CallOffer sent, waiting for CallAnswer..."); diff --git a/crates/wzp-android/src/jni_bridge.rs b/crates/wzp-android/src/jni_bridge.rs index 33c4a50..2c728fc 100644 --- a/crates/wzp-android/src/jni_bridge.rs +++ b/crates/wzp-android/src/jni_bridge.rs @@ -54,12 +54,14 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall( room_j: JString, seed_hex_j: JString, token_j: JString, + alias_j: JString, ) -> jint { let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default(); let room: String = env.get_string(&room_j).map(|s| s.into()).unwrap_or_default(); let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default(); let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default(); + let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default(); let h = unsafe { handle_ref(handle) }; @@ -83,6 +85,7 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall( room, auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() }, identity_seed, + alias: if alias.is_empty() { None } else { Some(alias) }, }; match h.engine.start_call(config) { diff --git a/crates/wzp-client/src/cli.rs b/crates/wzp-client/src/cli.rs index 6ab3451..3b1df69 100644 --- a/crates/wzp-client/src/cli.rs +++ b/crates/wzp-client/src/cli.rs @@ -287,6 +287,7 @@ async fn main() -> anyhow::Result<()> { let _crypto_session = wzp_client::handshake::perform_handshake( &*transport, &seed.0, + None, // alias — desktop client doesn't set one yet ).await?; info!("crypto handshake complete"); diff --git a/crates/wzp-client/src/handshake.rs b/crates/wzp-client/src/handshake.rs index b6e92f4..7a83edc 100644 --- a/crates/wzp-client/src/handshake.rs +++ b/crates/wzp-client/src/handshake.rs @@ -17,6 +17,7 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage}; pub async fn perform_handshake( transport: &dyn MediaTransport, seed: &[u8; 32], + alias: Option<&str>, ) -> Result, anyhow::Error> { // 1. Create key exchange from identity seed let mut kx = WarzoneKeyExchange::from_identity_seed(seed); @@ -41,6 +42,7 @@ pub async fn perform_handshake( QualityProfile::DEGRADED, QualityProfile::CATASTROPHIC, ], + alias: alias.map(|s| s.to_string()), }; transport.send_signal(&offer).await?; diff --git a/crates/wzp-proto/src/packet.rs b/crates/wzp-proto/src/packet.rs index 807efab..1e3909e 100644 --- a/crates/wzp-proto/src/packet.rs +++ b/crates/wzp-proto/src/packet.rs @@ -548,6 +548,9 @@ pub enum SignalMessage { signature: Vec, /// Supported quality profiles. supported_profiles: Vec, + /// Optional display name set by the caller. + #[serde(default)] + alias: Option, }, /// Call acceptance (analogous to Warzone's WireMessage::CallAnswer). diff --git a/crates/wzp-relay/src/handshake.rs b/crates/wzp-relay/src/handshake.rs index 4248b5b..1c8cb29 100644 --- a/crates/wzp-relay/src/handshake.rs +++ b/crates/wzp-relay/src/handshake.rs @@ -15,25 +15,27 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage}; /// 5. Derive shared ChaCha20-Poly1305 session /// 6. Send `CallAnswer` back /// -/// Returns the derived `CryptoSession` and the chosen `QualityProfile`. +/// Returns the derived `CryptoSession`, the chosen `QualityProfile`, the caller's fingerprint, +/// and the caller's alias (if provided in CallOffer). pub async fn accept_handshake( transport: &dyn MediaTransport, seed: &[u8; 32], -) -> Result<(Box, QualityProfile), anyhow::Error> { +) -> Result<(Box, QualityProfile, String, Option), anyhow::Error> { // 1. Receive CallOffer let offer = transport .recv_signal() .await? .ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallOffer"))?; - let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles) = + let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles, caller_alias) = match offer { SignalMessage::CallOffer { identity_pub, ephemeral_pub, signature, supported_profiles, - } => (identity_pub, ephemeral_pub, signature, supported_profiles), + alias, + } => (identity_pub, ephemeral_pub, signature, supported_profiles, alias), other => { return Err(anyhow::anyhow!( "expected CallOffer, got {:?}", @@ -76,7 +78,13 @@ pub async fn accept_handshake( }; transport.send_signal(&answer).await?; - Ok((session, chosen_profile)) + // Derive caller fingerprint from their identity public key (first 8 bytes as hex) + let caller_fp = caller_identity_pub[..8] + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + + Ok((session, chosen_profile, caller_fp, caller_alias)) } /// Select the best quality profile from those the caller supports. diff --git a/crates/wzp-relay/src/main.rs b/crates/wzp-relay/src/main.rs index 9fc7612..878c2b4 100644 --- a/crates/wzp-relay/src/main.rs +++ b/crates/wzp-relay/src/main.rs @@ -431,7 +431,7 @@ async fn main() -> anyhow::Result<()> { // Crypto handshake: verify client identity + negotiate quality profile let handshake_start = std::time::Instant::now(); - let (_crypto_session, _chosen_profile) = match wzp_relay::handshake::accept_handshake( + let (_crypto_session, _chosen_profile, caller_fp, caller_alias) = match wzp_relay::handshake::accept_handshake( &*transport, &relay_seed_bytes, ).await { @@ -448,10 +448,13 @@ async fn main() -> anyhow::Result<()> { } }; + // Use the caller's identity fingerprint from the handshake + let participant_fp = authenticated_fp.clone().unwrap_or(caller_fp); + // Register in presence registry - if let Some(ref fp) = authenticated_fp { + { let mut reg = presence.lock().await; - reg.register_local(fp, None, Some(room_name.clone())); + reg.register_local(&participant_fp, None, Some(room_name.clone())); } info!(%addr, room = %room_name, "client joining"); @@ -506,8 +509,8 @@ async fn main() -> anyhow::Result<()> { &room_name, addr, room::ParticipantSender::Quic(transport.clone()), - authenticated_fp.as_deref(), - None, // alias — TODO: accept from client + Some(&participant_fp), + caller_alias.as_deref(), ) { Ok((id, update, senders)) => { metrics.active_rooms.set(mgr.list().len() as i64);