From a9adb5cfd7a098005737442fb63cf909f12951d1 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Mon, 6 Apr 2026 22:37:46 +0400 Subject: [PATCH] feat: identicons, tap-to-copy fingerprint, recent rooms (Phase 1 backport) Backport from desktop client to Android: Identicons: - New Identicon.kt composable: deterministic 5x5 symmetric Canvas pattern from fingerprint hash (same algorithm as desktop identicon.ts) - Participant list shows identicon + name + tappable fingerprint - Settings page shows identicon next to fingerprint CopyableFingerprint: - Tap any fingerprint text to copy to clipboard with Toast feedback - Used in participant list and settings page Recent rooms: - SettingsRepository: persists last 5 (relay, room) pairs - CallViewModel: saves on startCall, exposes as StateFlow - InCallScreen: clickable chips that fill room + select matching server Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/wzp/data/SettingsRepository.kt | 30 ++++ .../java/com/wzp/ui/call/CallViewModel.kt | 6 + .../main/java/com/wzp/ui/call/InCallScreen.kt | 63 +++++++- .../java/com/wzp/ui/components/Identicon.kt | 141 ++++++++++++++++++ .../com/wzp/ui/settings/SettingsScreen.kt | 26 +++- 5 files changed, 253 insertions(+), 13 deletions(-) create mode 100644 android/app/src/main/java/com/wzp/ui/components/Identicon.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 index 28c41e9..421a3d5 100644 --- a/android/app/src/main/java/com/wzp/data/SettingsRepository.kt +++ b/android/app/src/main/java/com/wzp/data/SettingsRepository.kt @@ -28,6 +28,7 @@ class SettingsRepository(context: Context) { private const val KEY_PREFER_IPV6 = "prefer_ipv6" private const val KEY_IDENTITY_SEED = "identity_seed_hex" private const val KEY_AEC_ENABLED = "aec_enabled" + private const val KEY_RECENT_ROOMS = "recent_rooms" } // --- Servers --- @@ -138,4 +139,33 @@ class SettingsRepository(context: Context) { fun saveSeedHex(hex: String) { prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply() } + + // --- Recent rooms --- + + data class RecentRoom(val relay: String, val room: String) + + fun addRecentRoom(relay: String, room: String) { + val rooms = loadRecentRooms().toMutableList() + rooms.removeAll { it.relay == relay && it.room == room } + rooms.add(0, RecentRoom(relay, room)) + if (rooms.size > 5) rooms.subList(5, rooms.size).clear() + val arr = JSONArray() + rooms.forEach { arr.put(JSONObject().apply { put("relay", it.relay); put("room", it.room) }) } + prefs.edit().putString(KEY_RECENT_ROOMS, arr.toString()).apply() + } + + fun loadRecentRooms(): List { + val json = prefs.getString(KEY_RECENT_ROOMS, null) ?: return emptyList() + return try { + val arr = JSONArray(json) + (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + RecentRoom(o.getString("relay"), o.getString("room")) + } + } catch (_: Exception) { emptyList() } + } + + fun clearRecentRooms() { + prefs.edit().remove(KEY_RECENT_ROOMS).apply() + } } 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 9cf1534..4ff1819 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 @@ -70,6 +70,9 @@ class CallViewModel : ViewModel(), WzpCallback { private val _preferIPv6 = MutableStateFlow(false) val preferIPv6: StateFlow = _preferIPv6.asStateFlow() + private val _recentRooms = MutableStateFlow>(emptyList()) + val recentRooms: StateFlow> = _recentRooms.asStateFlow() + private val _playoutGainDb = MutableStateFlow(0f) val playoutGainDb: StateFlow = _playoutGainDb.asStateFlow() @@ -139,6 +142,7 @@ class CallViewModel : ViewModel(), WzpCallback { _captureGainDb.value = s.loadCaptureGain() _seedHex.value = s.getOrCreateSeedHex() _aecEnabled.value = s.loadAecEnabled() + _recentRooms.value = s.loadRecentRooms() } fun selectServer(index: Int) { @@ -287,6 +291,8 @@ class CallViewModel : ViewModel(), WzpCallback { _debugReportAvailable.value = false _debugReportStatus.value = null lastCallServer = serverEntry.address + settings?.addRecentRoom(serverEntry.address, room) + _recentRooms.value = settings?.loadRecentRooms() ?: emptyList() debugReporter?.prepareForCall() try { // Teardown previous call but don't stop the service (we're about to restart it) 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 0bf6260..8356965 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 @@ -42,6 +42,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -200,6 +201,36 @@ fun InCallScreen( modifier = Modifier.fillMaxWidth(0.6f) ) + // Recent rooms + val recentRooms by viewModel.recentRooms.collectAsState() + if (recentRooms.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + recentRooms.forEach { recent -> + Surface( + onClick = { + viewModel.setRoomName(recent.room) + // Select matching server + val idx = servers.indexOfFirst { it.address == recent.relay } + if (idx >= 0) viewModel.selectServer(idx) + }, + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.padding(2.dp) + ) { + Text( + text = recent.room, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + } + } + } + Spacer(modifier = Modifier.height(24.dp)) Button( @@ -262,11 +293,33 @@ fun InCallScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) unique.forEach { member -> - Text( - text = member.displayName, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 2.dp) + ) { + com.wzp.ui.components.Identicon( + fingerprint = member.fingerprint.ifEmpty { member.displayName }, + size = 28.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = member.displayName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (member.fingerprint.isNotEmpty()) { + com.wzp.ui.components.CopyableFingerprint( + fingerprint = member.fingerprint.take(16), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 9.sp, + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + } } } diff --git a/android/app/src/main/java/com/wzp/ui/components/Identicon.kt b/android/app/src/main/java/com/wzp/ui/components/Identicon.kt new file mode 100644 index 0000000..32f9958 --- /dev/null +++ b/android/app/src/main/java/com/wzp/ui/components/Identicon.kt @@ -0,0 +1,141 @@ +package com.wzp.ui.components + +import android.widget.Toast +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.min + +/** + * Deterministic identicon — generates a unique 5x5 symmetric pattern + * from a hex fingerprint string. Identical algorithm to the desktop + * TypeScript implementation in identicon.ts. + */ +@Composable +fun Identicon( + fingerprint: String, + size: Dp = 36.dp, + clickToCopy: Boolean = true, + modifier: Modifier = Modifier, +) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val bytes = hashBytes(fingerprint) + val (bg, fg) = deriveColors(bytes) + val grid = buildGrid(bytes) + + Canvas( + modifier = modifier + .size(size) + .clip(RoundedCornerShape(size * 0.12f)) + .then( + if (clickToCopy && fingerprint.isNotEmpty()) { + Modifier.clickable { + clipboard.setText(AnnotatedString(fingerprint)) + Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show() + } + } else Modifier + ) + ) { + val cellW = this.size.width / 5f + val cellH = this.size.height / 5f + + // Background + drawRect(color = bg, size = this.size) + + // Foreground cells + for (y in 0 until 5) { + for (x in 0 until 5) { + if (grid[y][x]) { + drawRect( + color = fg, + topLeft = Offset(x * cellW, y * cellH), + size = Size(cellW, cellH), + ) + } + } + } + } +} + +/** + * Fingerprint text that copies to clipboard on tap. + */ +@Composable +fun CopyableFingerprint( + fingerprint: String, + modifier: Modifier = Modifier, + style: androidx.compose.ui.text.TextStyle = androidx.compose.material3.MaterialTheme.typography.bodySmall, + color: Color = Color.Unspecified, +) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + + androidx.compose.material3.Text( + text = fingerprint, + style = style, + color = color, + modifier = modifier.clickable { + if (fingerprint.isNotEmpty()) { + clipboard.setText(AnnotatedString(fingerprint)) + Toast.makeText(context, "Fingerprint copied", Toast.LENGTH_SHORT).show() + } + } + ) +} + +// --- Internal helpers (matching desktop identicon.ts) --- + +private fun hashBytes(hex: String): List { + val clean = hex.filter { it.isLetterOrDigit() } + val bytes = mutableListOf() + var i = 0 + while (i + 1 < clean.length) { + val b = clean.substring(i, i + 2).toIntOrNull(16) ?: 0 + bytes.add(b) + i += 2 + } + // Pad to at least 16 bytes + while (bytes.size < 16) bytes.add(0) + return bytes +} + +private fun deriveColors(bytes: List): Pair { + val hue1 = bytes[0] * 360f / 256f + val hue2 = (bytes[1] * 360f / 256f + 120f) % 360f + val bg = hslToColor(hue1, 0.65f, 0.35f) + val fg = hslToColor(hue2, 0.70f, 0.55f) + return bg to fg +} + +private fun buildGrid(bytes: List): List> { + return (0 until 5).map { y -> + val left = (0 until 3).map { x -> + val idx = 2 + y * 3 + x + bytes[idx % bytes.size] > 128 + } + // Mirror: col3 = col1, col4 = col0 + listOf(left[0], left[1], left[2], left[1], left[0]) + } +} + +private fun hslToColor(h: Float, s: Float, l: Float): Color { + val k = { n: Float -> (n + h / 30f) % 12f } + val a = s * min(l, 1f - l) + val f = { n: Float -> + l - a * maxOf(-1f, minOf(k(n) - 3f, minOf(9f - k(n), 1f))) + } + return Color(f(0f), f(8f), f(4f)) +} 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 index 6a083c2..5b3fdf3 100644 --- a/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt @@ -158,20 +158,30 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(16.dp)) - // Fingerprint display + // Fingerprint display with identicon val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.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 - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp) + ) { + com.wzp.ui.components.Identicon( + fingerprint = draftSeedHex, + size = 40.dp, + ) + Spacer(modifier = Modifier.width(12.dp)) + com.wzp.ui.components.CopyableFingerprint( + fingerprint = fingerprint.chunked(4).joinToString(" "), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = MaterialTheme.colorScheme.onSurface, + ) + } Spacer(modifier = Modifier.height(12.dp))