feat: settings page with persistence, client alias in handshake, fix null fingerprints
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m42s
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m42s
- 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 room room identifier (used as QUIC SNI)
|
||||||
* @param seedHex 64-char hex-encoded 32-byte identity seed (empty = random)
|
* @param seedHex 64-char hex-encoded 32-byte identity seed (empty = random)
|
||||||
* @param token authentication token (empty = no auth)
|
* @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
|
* @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" }
|
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) {
|
if (result == 0) {
|
||||||
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
||||||
} else {
|
} else {
|
||||||
@@ -120,7 +121,7 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
|
|
||||||
private external fun nativeInit(): Long
|
private external fun nativeInit(): Long
|
||||||
private external fun nativeStartCall(
|
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
|
): Int
|
||||||
private external fun nativeStopCall(handle: Long)
|
private external fun nativeStopCall(handle: Long)
|
||||||
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
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.material3.lightColorScheme
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.Composable
|
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.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.wzp.ui.settings.SettingsScreen
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main activity hosting the in-call Compose UI.
|
* Main activity hosting the in-call Compose UI.
|
||||||
@@ -43,14 +48,21 @@ class CallActivity : ComponentActivity() {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
WzpTheme {
|
WzpTheme {
|
||||||
|
var showSettings by remember { mutableStateOf(false) }
|
||||||
|
if (showSettings) {
|
||||||
|
SettingsScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onBack = { showSettings = false }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
InCallScreen(
|
InCallScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onHangUp = {
|
onHangUp = { viewModel.stopCall() },
|
||||||
viewModel.stopCall()
|
onOpenSettings = { showSettings = true }
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.wzp.audio.AudioPipeline
|
import com.wzp.audio.AudioPipeline
|
||||||
import com.wzp.audio.AudioRouteManager
|
import com.wzp.audio.AudioRouteManager
|
||||||
|
import com.wzp.data.SettingsRepository
|
||||||
import com.wzp.engine.CallStats
|
import com.wzp.engine.CallStats
|
||||||
import com.wzp.service.CallService
|
import com.wzp.service.CallService
|
||||||
import com.wzp.engine.WzpCallback
|
import com.wzp.engine.WzpCallback
|
||||||
@@ -31,6 +32,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private var audioRouteManager: AudioRouteManager? = null
|
private var audioRouteManager: AudioRouteManager? = null
|
||||||
private var audioStarted = false
|
private var audioStarted = false
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
|
private var settings: SettingsRepository? = null
|
||||||
|
|
||||||
private val _callState = MutableStateFlow(0)
|
private val _callState = MutableStateFlow(0)
|
||||||
val callState: StateFlow<Int> get() = _callState.asStateFlow()
|
val callState: StateFlow<Int> get() = _callState.asStateFlow()
|
||||||
@@ -68,6 +70,12 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _captureGainDb = MutableStateFlow(0f)
|
private val _captureGainDb = MutableStateFlow(0f)
|
||||||
val captureGainDb: StateFlow<Float> = _captureGainDb.asStateFlow()
|
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
|
private var statsJob: Job? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -88,20 +96,43 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
if (audioRouteManager == null) {
|
if (audioRouteManager == null) {
|
||||||
audioRouteManager = AudioRouteManager(appCtx)
|
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) {
|
fun selectServer(index: Int) {
|
||||||
if (index in _servers.value.indices) {
|
if (index in _servers.value.indices) {
|
||||||
_selectedServer.value = index
|
_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) {
|
fun addServer(hostPort: String, label: String) {
|
||||||
val current = _servers.value.toMutableList()
|
val current = _servers.value.toMutableList()
|
||||||
current.add(ServerEntry(hostPort, label))
|
current.add(ServerEntry(hostPort, label))
|
||||||
_servers.value = current
|
_servers.value = current
|
||||||
|
settings?.saveServers(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeServer(index: Int) {
|
fun removeServer(index: Int) {
|
||||||
@@ -113,19 +144,36 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
if (_selectedServer.value >= current.size) {
|
if (_selectedServer.value >= current.size) {
|
||||||
_selectedServer.value = 0
|
_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) {
|
fun setPlayoutGainDb(db: Float) {
|
||||||
_playoutGainDb.value = db
|
_playoutGainDb.value = db
|
||||||
audioPipeline?.playoutGainDb = db
|
audioPipeline?.playoutGainDb = db
|
||||||
|
settings?.savePlayoutGain(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCaptureGainDb(db: Float) {
|
fun setCaptureGainDb(db: Float) {
|
||||||
_captureGainDb.value = db
|
_captureGainDb.value = db
|
||||||
audioPipeline?.captureGainDb = 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) {
|
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val relay = resolveToIp(serverEntry.address)
|
val relay = resolveToIp(serverEntry.address)
|
||||||
Log.i(TAG, "startCall: resolved=$relay, calling engine.startCall")
|
val seed = _seedHex.value
|
||||||
val result = engine?.startCall(relay, room) ?: -1
|
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")
|
Log.i(TAG, "startCall: engine returned $result")
|
||||||
// Only wire up notification callback after engine is running
|
// Only wire up notification callback after engine is running
|
||||||
CallService.onStopFromNotification = { stopCall() }
|
CallService.onStopFromNotification = { stopCall() }
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ import kotlin.math.roundToInt
|
|||||||
@Composable
|
@Composable
|
||||||
fun InCallScreen(
|
fun InCallScreen(
|
||||||
viewModel: CallViewModel,
|
viewModel: CallViewModel,
|
||||||
onHangUp: () -> Unit
|
onHangUp: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val callState by viewModel.callState.collectAsState()
|
val callState by viewModel.callState.collectAsState()
|
||||||
val isMuted by viewModel.isMuted.collectAsState()
|
val isMuted by viewModel.isMuted.collectAsState()
|
||||||
@@ -82,7 +83,16 @@ fun InCallScreen(
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
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(
|
||||||
text = "WZ Phone",
|
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.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<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 room: String,
|
||||||
pub auth_token: Vec<u8>,
|
pub auth_token: Vec<u8>,
|
||||||
pub identity_seed: [u8; 32],
|
pub identity_seed: [u8; 32],
|
||||||
|
pub alias: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CallStartConfig {
|
impl Default for CallStartConfig {
|
||||||
@@ -49,6 +50,7 @@ impl Default for CallStartConfig {
|
|||||||
room: String::new(),
|
room: String::new(),
|
||||||
auth_token: Vec::new(),
|
auth_token: Vec::new(),
|
||||||
identity_seed: [0u8; 32],
|
identity_seed: [0u8; 32],
|
||||||
|
alias: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,6 +119,7 @@ impl WzpEngine {
|
|||||||
let room = config.room.clone();
|
let room = config.room.clone();
|
||||||
let identity_seed = config.identity_seed;
|
let identity_seed = config.identity_seed;
|
||||||
let profile = config.profile;
|
let profile = config.profile;
|
||||||
|
let alias = config.alias.clone();
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
|
||||||
self.state.running.store(true, Ordering::Release);
|
self.state.running.store(true, Ordering::Release);
|
||||||
@@ -124,7 +127,7 @@ impl WzpEngine {
|
|||||||
|
|
||||||
let state_clone = state.clone();
|
let state_clone = state.clone();
|
||||||
runtime.block_on(async move {
|
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}");
|
error!("call failed: {e}");
|
||||||
}
|
}
|
||||||
@@ -204,6 +207,7 @@ async fn run_call(
|
|||||||
room: &str,
|
room: &str,
|
||||||
identity_seed: &[u8; 32],
|
identity_seed: &[u8; 32],
|
||||||
profile: QualityProfile,
|
profile: QualityProfile,
|
||||||
|
alias: Option<&str>,
|
||||||
state: Arc<EngineState>,
|
state: Arc<EngineState>,
|
||||||
) -> Result<(), anyhow::Error> {
|
) -> Result<(), anyhow::Error> {
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
@@ -238,6 +242,7 @@ async fn run_call(
|
|||||||
QualityProfile::DEGRADED,
|
QualityProfile::DEGRADED,
|
||||||
QualityProfile::CATASTROPHIC,
|
QualityProfile::CATASTROPHIC,
|
||||||
],
|
],
|
||||||
|
alias: alias.map(|s| s.to_string()),
|
||||||
};
|
};
|
||||||
transport.send_signal(&offer).await?;
|
transport.send_signal(&offer).await?;
|
||||||
info!("CallOffer sent, waiting for CallAnswer...");
|
info!("CallOffer sent, waiting for CallAnswer...");
|
||||||
|
|||||||
@@ -54,12 +54,14 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
|||||||
room_j: JString,
|
room_j: JString,
|
||||||
seed_hex_j: JString,
|
seed_hex_j: JString,
|
||||||
token_j: JString,
|
token_j: JString,
|
||||||
|
alias_j: JString,
|
||||||
) -> jint {
|
) -> jint {
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
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 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 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 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 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) };
|
let h = unsafe { handle_ref(handle) };
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
|||||||
room,
|
room,
|
||||||
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
|
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
|
||||||
identity_seed,
|
identity_seed,
|
||||||
|
alias: if alias.is_empty() { None } else { Some(alias) },
|
||||||
};
|
};
|
||||||
|
|
||||||
match h.engine.start_call(config) {
|
match h.engine.start_call(config) {
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let _crypto_session = wzp_client::handshake::perform_handshake(
|
let _crypto_session = wzp_client::handshake::perform_handshake(
|
||||||
&*transport,
|
&*transport,
|
||||||
&seed.0,
|
&seed.0,
|
||||||
|
None, // alias — desktop client doesn't set one yet
|
||||||
).await?;
|
).await?;
|
||||||
info!("crypto handshake complete");
|
info!("crypto handshake complete");
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
|
|||||||
pub async fn perform_handshake(
|
pub async fn perform_handshake(
|
||||||
transport: &dyn MediaTransport,
|
transport: &dyn MediaTransport,
|
||||||
seed: &[u8; 32],
|
seed: &[u8; 32],
|
||||||
|
alias: Option<&str>,
|
||||||
) -> Result<Box<dyn CryptoSession>, anyhow::Error> {
|
) -> Result<Box<dyn CryptoSession>, anyhow::Error> {
|
||||||
// 1. Create key exchange from identity seed
|
// 1. Create key exchange from identity seed
|
||||||
let mut kx = WarzoneKeyExchange::from_identity_seed(seed);
|
let mut kx = WarzoneKeyExchange::from_identity_seed(seed);
|
||||||
@@ -41,6 +42,7 @@ pub async fn perform_handshake(
|
|||||||
QualityProfile::DEGRADED,
|
QualityProfile::DEGRADED,
|
||||||
QualityProfile::CATASTROPHIC,
|
QualityProfile::CATASTROPHIC,
|
||||||
],
|
],
|
||||||
|
alias: alias.map(|s| s.to_string()),
|
||||||
};
|
};
|
||||||
transport.send_signal(&offer).await?;
|
transport.send_signal(&offer).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -548,6 +548,9 @@ pub enum SignalMessage {
|
|||||||
signature: Vec<u8>,
|
signature: Vec<u8>,
|
||||||
/// Supported quality profiles.
|
/// Supported quality profiles.
|
||||||
supported_profiles: Vec<crate::QualityProfile>,
|
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).
|
/// Call acceptance (analogous to Warzone's WireMessage::CallAnswer).
|
||||||
|
|||||||
@@ -15,25 +15,27 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
|
|||||||
/// 5. Derive shared ChaCha20-Poly1305 session
|
/// 5. Derive shared ChaCha20-Poly1305 session
|
||||||
/// 6. Send `CallAnswer` back
|
/// 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(
|
pub async fn accept_handshake(
|
||||||
transport: &dyn MediaTransport,
|
transport: &dyn MediaTransport,
|
||||||
seed: &[u8; 32],
|
seed: &[u8; 32],
|
||||||
) -> Result<(Box<dyn CryptoSession>, QualityProfile), anyhow::Error> {
|
) -> Result<(Box<dyn CryptoSession>, QualityProfile, String, Option<String>), anyhow::Error> {
|
||||||
// 1. Receive CallOffer
|
// 1. Receive CallOffer
|
||||||
let offer = transport
|
let offer = transport
|
||||||
.recv_signal()
|
.recv_signal()
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallOffer"))?;
|
.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 {
|
match offer {
|
||||||
SignalMessage::CallOffer {
|
SignalMessage::CallOffer {
|
||||||
identity_pub,
|
identity_pub,
|
||||||
ephemeral_pub,
|
ephemeral_pub,
|
||||||
signature,
|
signature,
|
||||||
supported_profiles,
|
supported_profiles,
|
||||||
} => (identity_pub, ephemeral_pub, signature, supported_profiles),
|
alias,
|
||||||
|
} => (identity_pub, ephemeral_pub, signature, supported_profiles, alias),
|
||||||
other => {
|
other => {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"expected CallOffer, got {:?}",
|
"expected CallOffer, got {:?}",
|
||||||
@@ -76,7 +78,13 @@ pub async fn accept_handshake(
|
|||||||
};
|
};
|
||||||
transport.send_signal(&answer).await?;
|
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.
|
/// 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
|
// Crypto handshake: verify client identity + negotiate quality profile
|
||||||
let handshake_start = std::time::Instant::now();
|
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,
|
&*transport,
|
||||||
&relay_seed_bytes,
|
&relay_seed_bytes,
|
||||||
).await {
|
).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
|
// Register in presence registry
|
||||||
if let Some(ref fp) = authenticated_fp {
|
{
|
||||||
let mut reg = presence.lock().await;
|
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");
|
info!(%addr, room = %room_name, "client joining");
|
||||||
@@ -506,8 +509,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
&room_name,
|
&room_name,
|
||||||
addr,
|
addr,
|
||||||
room::ParticipantSender::Quic(transport.clone()),
|
room::ParticipantSender::Quic(transport.clone()),
|
||||||
authenticated_fp.as_deref(),
|
Some(&participant_fp),
|
||||||
None, // alias — TODO: accept from client
|
caller_alias.as_deref(),
|
||||||
) {
|
) {
|
||||||
Ok((id, update, senders)) => {
|
Ok((id, update, senders)) => {
|
||||||
metrics.active_rooms.set(mgr.list().len() as i64);
|
metrics.active_rooms.set(mgr.list().len() as i64);
|
||||||
|
|||||||
Reference in New Issue
Block a user