feat: settings page with persistence, client alias in handshake, fix null fingerprints
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m34s
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m34s
- 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) <noreply@anthropic.com>
This commit is contained in:
135
android/app/src/main/java/com/wzp/data/SettingsRepository.kt
Normal file
135
android/app/src/main/java/com/wzp/data/SettingsRepository.kt
Normal file
@@ -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<ServerEntry>) {
|
||||
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<ServerEntry>? {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Int> get() = _callState.asStateFlow()
|
||||
@@ -68,6 +70,12 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _captureGainDb = MutableStateFlow(0f)
|
||||
val captureGainDb: StateFlow<Float> = _captureGainDb.asStateFlow()
|
||||
|
||||
private val _alias = MutableStateFlow("")
|
||||
val alias: StateFlow<String> = _alias.asStateFlow()
|
||||
|
||||
private val _seedHex = MutableStateFlow("")
|
||||
val seedHex: StateFlow<String> = _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() }
|
||||
|
||||
@@ -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",
|
||||
|
||||
437
android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt
Normal file
437
android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt
Normal file
@@ -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.Divider
|
||||
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))
|
||||
Divider()
|
||||
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))
|
||||
Divider()
|
||||
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))
|
||||
Divider()
|
||||
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))
|
||||
Divider()
|
||||
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<String?>(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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ pub struct CallStartConfig {
|
||||
pub room: String,
|
||||
pub auth_token: Vec<u8>,
|
||||
pub identity_seed: [u8; 32],
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
|
||||
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<EngineState>,
|
||||
) -> 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...");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<Box<dyn CryptoSession>, 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?;
|
||||
|
||||
|
||||
@@ -548,6 +548,9 @@ pub enum SignalMessage {
|
||||
signature: Vec<u8>,
|
||||
/// Supported quality profiles.
|
||||
supported_profiles: Vec<crate::QualityProfile>,
|
||||
/// Optional display name set by the caller.
|
||||
#[serde(default)]
|
||||
alias: Option<String>,
|
||||
},
|
||||
|
||||
/// Call acceptance (analogous to Warzone's WireMessage::CallAnswer).
|
||||
|
||||
@@ -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<dyn CryptoSession>, QualityProfile), anyhow::Error> {
|
||||
) -> Result<(Box<dyn CryptoSession>, QualityProfile, String, Option<String>), 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::<String>();
|
||||
|
||||
Ok((session, chosen_profile, caller_fp, caller_alias))
|
||||
}
|
||||
|
||||
/// Select the best quality profile from those the caller supports.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user