e7a64bcf8792

feat: Rename paired devices locally (v2.14.16)

- Preserve bounded aliases across peer and manifest refreshes - Add inline rename controls to the Sync settings device list - Keep aliases out of device identity, manifests, and relay traffic

time
2026-08-11 20:56 UTC · 2 months ago
machine
koh2
agent
gpt-5.6-sol
session
paired device aliases
parent
90fb2daaf151
6 files VERSION+1 −1app/src/main/java/com/peachpasswords/sync/PairedDevice.kt+1 −0app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+25 −2app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+36 −1app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+4 −0app/src/test/java/com/peachpasswords/sync/PairedDeviceAliasTest.kt+36 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.151+2.14.16

view file

app/src/main/java/com/peachpasswords/sync/PairedDevice.kt+1 −0
@@ -6,6 +6,7 @@66 data class PairedDevice(77     val deviceId: String,88     val deviceName: String? = null,9+    val localAlias: String? = null,910     val deviceType: String? = null,1011     val longTermPublicKeyHex: String? = null,1112     val signingPublicKeyHex: String? = null,

view file

app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+25 −2
@@ -69,11 +69,18 @@6969     override fun upsert(device: PairedDevice) {7070         // Peer-supplied names arrive capped so a hostile peer cannot grow the7171         // registry without limit (adoption + direct-pair seams land here).72-        val device = device.copy(deviceName = device.deviceName?.let(::capDeviceName))72+        val device = device.copy(73+            deviceName = device.deviceName?.let(::capDeviceName),74+            localAlias = get(device.deviceId)?.localAlias,75+        )7376         val next = getAll().filterNot { it.deviceId == device.deviceId } + device7477         persist(next)7578     }7679 80+    fun rename(deviceId: String, name: String) {81+        persist(withLocalAlias(getAll(), deviceId, name))82+    }83+7784     override fun remove(deviceId: String) {7885         persist(getAll().filterNot { it.deviceId == deviceId })7986     }@@ -97,7 +104,11 @@97104             .filterNot { removed.contains(it.deviceId) }98105             .toMutableList()99106         batch.upserts.forEach { device ->100-            val device = device.copy(deviceName = device.deviceName?.let(::capDeviceName))107+            val alias = nextDevices.firstOrNull { it.deviceId == device.deviceId }?.localAlias108+            val device = device.copy(109+                deviceName = device.deviceName?.let(::capDeviceName),110+                localAlias = alias,111+            )101112             nextDevices.removeAll { it.deviceId == device.deviceId }102113             nextDevices += device103114         }@@ -258,6 +269,18 @@258269         const val MAX_REVOCATIONS = 500259270         val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }260271     }272+}273+274+internal fun withLocalAlias(275+    devices: List<PairedDevice>,276+    deviceId: String,277+    name: String,278+): List<PairedDevice> {279+    val alias = name.trim()280+    require(alias.isNotEmpty()) { "device name must not be empty" }281+    require(alias.length <= MAX_DEVICE_NAME_CHARS) { "device name must be 64 chars or fewer" }282+    require(devices.any { it.deviceId == deviceId }) { "device_not_found" }283+    return devices.map { if (it.deviceId == deviceId) it.copy(localAlias = alias) else it }261284 }262285 263286 internal data class DeviceRevocationMutation(

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+36 −1
@@ -827,6 +827,8 @@827827     var showBackgroundSyncPrompt by remember { mutableStateOf(false) }828828     val scope = rememberCoroutineScope()829829     var confirmUnpair by remember { mutableStateOf<String?>(null) }830+    var renamingDeviceId by remember { mutableStateOf<String?>(null) }831+    var renameDraft by remember { mutableStateOf("") }830832     var rotateConfirmOpen by remember { mutableStateOf(false) }831833     var rotating by remember { mutableStateOf(false) }832834     var rotateError by remember { mutableStateOf<String?>(null) }@@ -889,7 +891,24 @@889891         pairedDevices.forEach { d ->890892             RowLine(fg) {891893                 Column(Modifier.weight(1f)) {892-                    Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)894+                    if (renamingDeviceId == d.deviceId) {895+                        Box(896+                            Modifier.fillMaxWidth()897+                                .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))898+                                .padding(horizontal = 10.dp, vertical = 8.dp),899+                        ) {900+                            BasicTextField(901+                                value = renameDraft,902+                                onValueChange = { renameDraft = it.take(64) },903+                                singleLine = true,904+                                textStyle = TextStyle(fontFamily = PeachType.Body, fontSize = 13.sp, color = fg),905+                                cursorBrush = SolidColor(fg),906+                                modifier = Modifier.fillMaxWidth(),907+                            )908+                        }909+                    } else {910+                        Text(d.localAlias ?: d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)911+                    }893912                     Text(894913                         "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",895914                         color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,@@ -897,6 +916,22 @@897916                     )898917                 }899918                 if (confirmUnpair != d.deviceId) {919+                    if (renamingDeviceId == d.deviceId) {920+                        SubtleButton("cancel", fg) { renamingDeviceId = null }921+                        Spacer(Modifier.width(PeachSpacing.xs))922+                        SubtleButton("save", fg) {923+                            if (renameDraft.isNotBlank()) {924+                                syncVm.renameDevice(d.deviceId, renameDraft)925+                                renamingDeviceId = null926+                            }927+                        }928+                    } else {929+                        SubtleButton("rename", fg) {930+                            renameDraft = d.localAlias ?: d.deviceName ?: ""931+                            renamingDeviceId = d.deviceId932+                        }933+                    }934+                    Spacer(Modifier.width(PeachSpacing.xs))900935                     SubtleButton("remove", fg) { confirmUnpair = d.deviceId }901936                 }902937             }

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+4 −0
@@ -136,6 +136,10 @@136136         PeachSyncService.start(context)137137     }138138 139+    fun renameDevice(id: String, name: String) = viewModelScope.launch {140+        deviceStore.rename(id, name)141+    }142+139143     fun confirmVouchedDevice(id: String) = viewModelScope.launch {140144         val pending = pendingVouchedStore.get(id) ?: return@launch141145         // Last gate before a vouched device becomes a sync peer. A queue entry

view file

app/src/test/java/com/peachpasswords/sync/PairedDeviceAliasTest.kt+36 −0
@@ -0,0 +1,36 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertThrows5+import org.junit.Test6+7+class PairedDeviceAliasTest {8+    private val device = PairedDevice(9+        deviceId = "phone-1",10+        deviceName = "Pixel",11+        longTermPublicKeyHex = "a".repeat(64),12+        pairedAt = 1,13+    )14+15+    @Test16+    fun `rename stores a trimmed local alias without changing peer identity`() {17+        val renamed = withLocalAlias(listOf(device), device.deviceId, "  Travel phone  ").single()18+19+        assertEquals("Travel phone", renamed.localAlias)20+        assertEquals(device.deviceName, renamed.deviceName)21+        assertEquals(device.longTermPublicKeyHex, renamed.longTermPublicKeyHex)22+    }23+24+    @Test25+    fun `rename rejects blank oversized and unknown aliases`() {26+        assertThrows(IllegalArgumentException::class.java) {27+            withLocalAlias(listOf(device), device.deviceId, " ")28+        }29+        assertThrows(IllegalArgumentException::class.java) {30+            withLocalAlias(listOf(device), device.deviceId, "x".repeat(65))31+        }32+        assertThrows(IllegalArgumentException::class.java) {33+            withLocalAlias(listOf(device), "missing", "Phone")34+        }35+    }36+}

view file