diff --git a/app/src/main/kotlin/com/privee/app/data/AppContainer.kt b/app/src/main/kotlin/com/privee/app/data/AppContainer.kt index ae06fa1..98800ba 100644 --- a/app/src/main/kotlin/com/privee/app/data/AppContainer.kt +++ b/app/src/main/kotlin/com/privee/app/data/AppContainer.kt @@ -153,13 +153,17 @@ class AppContainer(private val context: Context) { runCatching { session.server.api.registerPush(session.account.token, endpoint) } } - /** Signs out; the keys stay on the device for the next sign-in on the same server. */ + /** + * Signs out; the keys stay on the device for the next sign-in on the same server, + * but the local hints about peers are removed. + */ suspend fun signOut(remote: Boolean = true) { val session = _session.value ?: return backgroundListening.disable() BackgroundMessageService.stop(context) _session.value = null session.stop() + runCatching { session.signal.clearPeerHints() } PushRegistration.unregister(context) val api = session.server.api if (remote) { diff --git a/app/src/main/kotlin/com/privee/app/ui/ChatScreen.kt b/app/src/main/kotlin/com/privee/app/ui/ChatScreen.kt index 20af1b9..9723fcd 100644 --- a/app/src/main/kotlin/com/privee/app/ui/ChatScreen.kt +++ b/app/src/main/kotlin/com/privee/app/ui/ChatScreen.kt @@ -130,6 +130,9 @@ fun ChatScreen(container: AppContainer, session: PriveeSession, peerName: String val deviceState by session.deviceState.collectAsStateWithLifecycle() val connected by session.connected.collectAsStateWithLifecycle() var confirmClear by rememberSaveable { mutableStateOf(false) } + var editHint by rememberSaveable { mutableStateOf(false) } + val signalState by session.signal.changes.collectAsStateWithLifecycle() + val hint = peerId?.let { signalState.peers[it.toString()]?.hint } DisposableEffect(peerName) { container.activeChat.value = peerName @@ -154,8 +157,23 @@ fun ChatScreen(container: AppContainer, session: PriveeSession, peerName: String onApprove = { vm.approve() }, onDismissNotice = c::dismissNotice, onResetIdentity = { vm.viewModelScope.launch(Dispatchers.IO) { runCatching { session.resetIdentity() } } }, + hint = hint, + onEditHint = { editHint = true }, ) + val hintPeer = peerId + if (editHint && hintPeer != null) { + HintDialog( + peerName = peerName, + current = hint, + onSave = { value -> + editHint = false + vm.viewModelScope.launch(Dispatchers.IO) { runCatching { session.signal.setPeerHint(hintPeer, value) } } + }, + onDismiss = { editHint = false }, + ) + } + if (vm.safetyNumberVisible) SafetyNumberDialog(peerName, vm.safetyNumber, vm::hideSafetyNumber) if (confirmClear) { @@ -196,6 +214,8 @@ fun ChatContent( onApprove: () -> Unit, onDismissNotice: () -> Unit, onResetIdentity: () -> Unit, + hint: String? = null, + onEditHint: () -> Unit = {}, ) { var menu by rememberSaveable { mutableStateOf(false) } val listState = rememberLazyListState() @@ -213,6 +233,16 @@ fun ChatContent( Spacer(Modifier.width(12.dp)) Column { Text(peerName, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium) + hint?.let { + Text( + it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag("chat-hint"), + ) + } Row(verticalAlignment = Alignment.CenterVertically) { ConnectionDot(connected) Spacer(Modifier.width(6.dp)) @@ -235,6 +265,15 @@ fun ChatContent( } DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { ProtectedWindow() + DropdownMenuItem( + text = { Text(stringResource(if (hint == null) R.string.hint_add else R.string.hint_edit)) }, + onClick = { + menu = false + onEditHint() + }, + enabled = hasPeer, + modifier = Modifier.testTag("edit-hint"), + ) DropdownMenuItem( text = { Text(stringResource(R.string.safety_number)) }, onClick = { diff --git a/app/src/main/kotlin/com/privee/app/ui/HintDialog.kt b/app/src/main/kotlin/com/privee/app/ui/HintDialog.kt new file mode 100644 index 0000000..abebb72 --- /dev/null +++ b/app/src/main/kotlin/com/privee/app/ui/HintDialog.kt @@ -0,0 +1,91 @@ +package com.privee.app.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardActions +import com.privee.app.R +import com.privee.signal.SignalClient + +/** + * Edits the local hint about who [peerName] is. The advice not to write their name + * is shown every time, because anyone who unlocks the device can read hints. + */ +@Composable +fun HintDialog(peerName: String, current: String?, onSave: (String?) -> Unit, onDismiss: () -> Unit) { + var text by rememberSaveable(peerName) { mutableStateOf(current.orEmpty()) } + PriveeAlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag("hint-dialog"), + title = { Text(stringResource(R.string.hint_title, peerName)) }, + text = { + Column { + Text( + stringResource(R.string.hint_advice), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("hint-advice"), + ) + Spacer(Modifier.height(12.dp)) + NoPersonalizedLearning { + OutlinedTextField( + value = text, + onValueChange = { value -> + text = value.replace('\n', ' ').let { + if (it.codePointCount(0, it.length) > SignalClient.MAX_HINT_LENGTH) { + it.substring(0, it.offsetByCodePoints(0, SignalClient.MAX_HINT_LENGTH)) + } else { + it + } + } + }, + label = { Text(stringResource(R.string.hint_label)) }, + placeholder = { Text(stringResource(R.string.hint_placeholder)) }, + supportingText = { + Text("${text.codePointCount(0, text.length)}/${SignalClient.MAX_HINT_LENGTH}") + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { onSave(SignalClient.normalizeHint(text)) }), + modifier = Modifier.fillMaxWidth().testTag("hint-input"), + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { onSave(SignalClient.normalizeHint(text)) }, + modifier = Modifier.testTag("hint-save"), + ) { Text(stringResource(R.string.save)) } + }, + dismissButton = { + if (current != null) { + TextButton(onClick = { onSave(null) }, modifier = Modifier.testTag("hint-remove")) { + Text(stringResource(R.string.remove)) + } + } else { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + } + }, + ) +} diff --git a/app/src/main/kotlin/com/privee/app/ui/HomeScreen.kt b/app/src/main/kotlin/com/privee/app/ui/HomeScreen.kt index a582f39..c4ced66 100644 --- a/app/src/main/kotlin/com/privee/app/ui/HomeScreen.kt +++ b/app/src/main/kotlin/com/privee/app/ui/HomeScreen.kt @@ -3,7 +3,7 @@ package com.privee.app.ui import android.content.Intent import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -66,15 +66,24 @@ import com.privee.signal.SignalState import kotlinx.coroutines.launch /** A conversation of the local history, for the home screen. */ -data class Recent(val peerName: String, val preview: String?, val ts: Long, val outgoing: Boolean = false) +data class Recent( + val peerName: String, + val preview: String?, + val ts: Long, + val outgoing: Boolean = false, + val peerId: Long? = null, + /** The user's local note about who the peer is. */ + val hint: String? = null, +) fun recents(state: SignalState): List { val last = state.history.values.groupBy { it.peerId }.mapValues { (_, rows) -> rows.maxBy { it.ts } } return state.peers.mapNotNull { (id, meta) -> val name = meta.name ?: return@mapNotNull null - val row = last[id.toLongOrNull()] + val peerId = id.toLongOrNull() + val row = last[peerId] val preview = row?.let { it.plaintext ?: "…" } - Recent(name, preview, row?.ts ?: 0, row?.direction == Direction.Out) + Recent(name, preview, row?.ts ?: 0, row?.direction == Direction.Out, peerId, meta.hint) }.sortedByDescending { it.ts } } @@ -89,6 +98,7 @@ fun HomeScreen(container: AppContainer, session: PriveeSession, onSettings: () - var confirmForget by rememberSaveable { mutableStateOf(false) } var peer by rememberSaveable { mutableStateOf("") } var peerError by rememberSaveable { mutableStateOf(false) } + var editingHint by rememberSaveable { mutableStateOf(null) } val ownName = session.account.session.sessionName val shareText = stringResource(R.string.share_text, session.server.shareLink(ownName), session.server.appLink(ownName)) val shareTitle = stringResource(R.string.share_session) @@ -115,12 +125,13 @@ fun HomeScreen(container: AppContainer, session: PriveeSession, onSettings: () - } } + val recents = recents(state) HomeContent( ownName = ownName, serverLabel = session.server.config.label, connected = connected, deviceState = deviceState, - recents = recents(state), + recents = recents, peer = peer, peerError = peerError, onPeerChange = { @@ -135,8 +146,22 @@ fun HomeScreen(container: AppContainer, session: PriveeSession, onSettings: () - onForgetDevice = { confirmForget = true }, onResetIdentity = { scope.launch { runCatching { session.resetIdentity() } } }, alerts = { BackgroundAlerts(container, session) }, + onEditHint = { editingHint = it.peerName }, ) + recents.firstOrNull { it.peerName == editingHint }?.let { recent -> + val peerId = recent.peerId + HintDialog( + peerName = recent.peerName, + current = recent.hint, + onSave = { hint -> + editingHint = null + if (peerId != null) scope.launch { runCatching { session.signal.setPeerHint(peerId, hint) } } + }, + onDismiss = { editingHint = null }, + ) + } + if (confirmForget) { PriveeAlertDialog( onDismissRequest = { confirmForget = false }, @@ -175,6 +200,7 @@ fun HomeContent( onForgetDevice: () -> Unit, onResetIdentity: () -> Unit, alerts: @Composable () -> Unit, + onEditHint: (Recent) -> Unit = {}, ) { var menu by rememberSaveable { mutableStateOf(false) } Scaffold( @@ -287,10 +313,15 @@ fun HomeContent( if (recents.isNotEmpty()) { item { Text(stringResource(R.string.conversations_device), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) + Text( + stringResource(R.string.hint_long_press), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } items(recents, key = { it.peerName }) { recent -> - RecentRow(recent) { onOpenChat(recent.peerName) } + RecentRow(recent, onClick = { onOpenChat(recent.peerName) }, onLongClick = { onEditHint(recent) }) } } } @@ -337,20 +368,35 @@ fun DeviceBanner(state: DeviceState, onReset: () -> Unit) { } @Composable -private fun RecentRow(recent: Recent, onClick: () -> Unit) { +private fun RecentRow(recent: Recent, onClick: () -> Unit, onLongClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceContainer) - .clickable(onClick = onClick) - .padding(16.dp), + .combinedClickable( + onClick = onClick, + onLongClick = if (recent.peerId != null) onLongClick else null, + onLongClickLabel = stringResource(if (recent.hint == null) R.string.hint_add else R.string.hint_edit), + ) + .padding(16.dp) + .testTag("recent-${recent.peerName}"), verticalAlignment = Alignment.CenterVertically, ) { Avatar(recent.peerName) Spacer(Modifier.width(14.dp)) Column(Modifier.weight(1f)) { Text(recent.peerName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + recent.hint?.let { + Text( + it, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("recent-hint"), + ) + } recent.preview?.let { Text( if (recent.outgoing) stringResource(R.string.outgoing_preview, it) else it, diff --git a/app/src/main/res/values-b+pt+PT/strings.xml b/app/src/main/res/values-b+pt+PT/strings.xml index 5d72c2d..48c40b9 100644 --- a/app/src/main/res/values-b+pt+PT/strings.xml +++ b/app/src/main/res/values-b+pt+PT/strings.xml @@ -120,4 +120,13 @@ O Privee está isento da otimização da bateria do Android. Podem continuar a aplicar-se outras restrições do dispositivo. O Android não conseguiu abrir estas definições. Abra manualmente as definições da aplicação Privee. Não foi possível guardar a definição de escuta em segundo plano. Tente novamente. + Adicionar pista + Editar pista + Pista para %1$s + Só tu vês esta pista, e apenas neste dispositivo. Não escrevas o nome da pessoa: qualquer pessoa que desbloqueie este dispositivo pode lê-la. + Pista + p. ex. clube de leitura + Guardar + Remover + Mantém premida uma conversa para adicionar uma pista privada. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6ab0a31..b284be3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -120,4 +120,13 @@ Privee está exento de la optimización de batería de Android. Pueden seguir aplicándose otras restricciones del dispositivo. Android no ha podido abrir estos ajustes. Abre manualmente los ajustes de la aplicación Privee. No se ha podido guardar el ajuste de escucha en segundo plano. Vuelve a intentarlo. + Añadir pista + Editar pista + Pista para %1$s + Solo tú ves esta pista, y solo en este dispositivo. No escribas su nombre: cualquiera que desbloquee este dispositivo podría leerla. + Pista + p. ej. club de lectura + Guardar + Quitar + Mantén pulsada una conversación para añadir una pista privada. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 99d5049..4856a2e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -120,4 +120,13 @@ Privee est exempté de l\'optimisation de la batterie Android. D\'autres restrictions de l\'appareil peuvent toujours s\'appliquer. Android n\'a pas pu ouvrir ces paramètres. Ouvrez manuellement les paramètres de l\'application Privee. Impossible d\'enregistrer le paramètre d\'écoute en arrière-plan. Réessayez. + Ajouter un indice + Modifier l\'indice + Indice pour %1$s + Vous seul voyez cet indice, et uniquement sur cet appareil. N\'écrivez pas son nom : toute personne qui déverrouille cet appareil pourrait le lire. + Indice + ex. club de lecture + Enregistrer + Supprimer + Appuyez longuement sur une conversation pour ajouter un indice privé. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 299d093..c964ede 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -120,4 +120,13 @@ Privee è esclusa dall\'ottimizzazione della batteria Android. Possono comunque applicarsi altre restrizioni del dispositivo. Android non ha potuto aprire queste impostazioni. Apri manualmente le impostazioni dell\'app Privee. Impossibile salvare l\'impostazione di ascolto in background. Riprova. + Aggiungi nota + Modifica nota + Nota per %1$s + Solo tu vedi questa nota, e solo su questo dispositivo. Non scrivere il suo nome: chiunque sblocchi questo dispositivo potrebbe leggerla. + Nota + es. club del libro + Salva + Rimuovi + Tieni premuta una conversazione per aggiungere una nota privata. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b5dc19..f59bde5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,4 +122,13 @@ Privee is exempt from Android battery optimization. Other device restrictions can still apply. Android could not open these settings. Open Privee\'s app settings manually. Could not save the background listening setting. Please retry. + Add hint + Edit hint + Hint for %1$s + Only you can see this hint, and only on this device. Don\'t write their name: anyone who unlocks this device could read it. + Hint + e.g. book club + Save + Remove + Long-press a conversation to add a private hint. diff --git a/app/src/test/kotlin/com/privee/app/ui/RecentsTest.kt b/app/src/test/kotlin/com/privee/app/ui/RecentsTest.kt index 444dc7c..99bbfb2 100644 --- a/app/src/test/kotlin/com/privee/app/ui/RecentsTest.kt +++ b/app/src/test/kotlin/com/privee/app/ui/RecentsTest.kt @@ -15,7 +15,7 @@ class RecentsTest { fun `lists named peers by their latest message`() { val state = SignalState( peers = mapOf( - "1" to PeerMeta(name = "alice-session"), + "1" to PeerMeta(name = "alice-session", hint = "book club"), "2" to PeerMeta(name = "bob-session"), "3" to PeerMeta(name = "carol-session"), "4" to PeerMeta(), @@ -30,9 +30,9 @@ class RecentsTest { assertEquals( listOf( - Recent("alice-session", "hello", 30, outgoing = true), - Recent("bob-session", "…", 20), - Recent("carol-session", null, 0), + Recent("alice-session", "hello", 30, outgoing = true, peerId = 1, hint = "book club"), + Recent("bob-session", "…", 20, peerId = 2), + Recent("carol-session", null, 0, peerId = 3), ), recents(state), ) diff --git a/app/src/testDebug/kotlin/com/privee/app/ui/HintTest.kt b/app/src/testDebug/kotlin/com/privee/app/ui/HintTest.kt new file mode 100644 index 0000000..bd9b86c --- /dev/null +++ b/app/src/testDebug/kotlin/com/privee/app/ui/HintTest.kt @@ -0,0 +1,121 @@ +package com.privee.app.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.hasScrollToNodeAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.performTouchInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.privee.signal.DeviceState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +// In testDebug: createComposeRule needs the debug-only ui-test-manifest. +@RunWith(AndroidJUnit4::class) +@Config(qualifiers = "en") +class HintTest { + @get:Rule + val compose = createComposeRule() + + private val advice = "Don't write their name" + + @Test + fun `the editor always advises against the name and saves a normalized hint`() { + var saved: String? = "unset" + compose.setContent { HintDialog("amber-falcon", current = null, onSave = { saved = it }, onDismiss = {}) } + + compose.onNodeWithTag("hint-advice").assertIsDisplayed() + compose.onNodeWithText(advice, substring = true).assertIsDisplayed() + compose.onNodeWithTag("hint-input").performTextReplacement(" book club ") + compose.onNodeWithTag("hint-save").performClick() + + assertEquals("book club", saved) + } + + @Test + fun `an existing hint can be removed`() { + var saved: String? = "unset" + compose.setContent { HintDialog("amber-falcon", current = "book club", onSave = { saved = it }, onDismiss = {}) } + + compose.onNodeWithTag("hint-advice").assertIsDisplayed() + compose.onNodeWithTag("hint-remove").performClick() + + assertNull(saved) + } + + @Test + fun `conversations show their hint and a long press asks to edit it`() { + var editing by mutableStateOf(null) + val recent = Recent("amber-falcon", "hello", 1, peerId = 2, hint = "book club") + compose.setContent { + HomeContent( + ownName = "quiet-harbor", + serverLabel = "chat.example.org", + connected = true, + deviceState = DeviceState.Ready, + recents = listOf(recent), + peer = "", + peerError = false, + onPeerChange = {}, + onOpen = {}, + onOpenChat = {}, + onShare = {}, + onSettings = {}, + onLogOut = {}, + onForgetDevice = {}, + onResetIdentity = {}, + alerts = {}, + onEditHint = { editing = it }, + ) + } + + compose.onNode(hasScrollToNodeAction()).performScrollToNode(hasTestTag("recent-amber-falcon")) + compose.onNodeWithTag("recent-hint", useUnmergedTree = true).assertIsDisplayed() + compose.onNodeWithTag("recent-amber-falcon").performTouchInput { longClick() } + + assertEquals(recent, editing) + } + + @Test + fun `the chat header shows the hint`() { + compose.setContent { + ChatContent( + peerName = "amber-falcon", + items = emptyList(), + notFound = false, + notice = null, + identityChanged = false, + hasPeer = true, + deviceState = DeviceState.Ready, + connected = true, + draft = "", + onDraftChange = {}, + onSend = {}, + onBack = {}, + onSettings = {}, + onShowSafetyNumber = {}, + onClearHistory = {}, + onApprove = {}, + onDismissNotice = {}, + onResetIdentity = {}, + hint = "book club", + ) + } + + compose.onNodeWithTag("chat-hint").assertIsDisplayed() + compose.onNodeWithText("book club").assertIsDisplayed() + } +} diff --git a/core/signal/src/main/kotlin/com/privee/signal/SignalClient.kt b/core/signal/src/main/kotlin/com/privee/signal/SignalClient.kt index b0c321e..66077c3 100644 --- a/core/signal/src/main/kotlin/com/privee/signal/SignalClient.kt +++ b/core/signal/src/main/kotlin/com/privee/signal/SignalClient.kt @@ -517,6 +517,23 @@ class SignalClient( state = state.copy(history = state.history.filterValues { it.peerId != peerId }) } + /** + * Sets the local hint of [peerId], trimmed to one line of at most [MAX_HINT_LENGTH] + * characters; a blank hint removes it. Returns the stored hint. + */ + suspend fun setPeerHint(peerId: Long, hint: String?): String? = transaction { + val normalized = normalizeHint(hint) + if (peer(peerId).hint != normalized) putPeer(peerId, peer(peerId).copy(hint = normalized)) + normalized + } + + /** Removes the local hints of every peer. */ + suspend fun clearPeerHints() = transaction { + if (state.peers.values.any { it.hint != null }) { + state = state.copy(peers = state.peers.mapValues { (_, meta) -> meta.copy(hint = null) }) + } + } + // -- Sending ------------------------------------------------------------------ suspend fun openConversation(chat: ServerCall): String { @@ -637,6 +654,19 @@ class SignalClient( private const val PQXDH_VERSION = 4 const val FINGERPRINT_VERSION = 2 const val FINGERPRINT_ITERATIONS = 5200 + const val MAX_HINT_LENGTH = 40 + + /** Collapses whitespace to single spaces and limits the length; blank becomes `null`. */ + fun normalizeHint(hint: String?): String? { + val collapsed = hint?.trim()?.replace(Regex("\\s+"), " ").orEmpty() + if (collapsed.isEmpty()) return null + val end = if (collapsed.codePointCount(0, collapsed.length) > MAX_HINT_LENGTH) { + collapsed.offsetByCodePoints(0, MAX_HINT_LENGTH) + } else { + collapsed.length + } + return collapsed.substring(0, end).trimEnd() + } internal val json = Json { ignoreUnknownKeys = true } diff --git a/core/signal/src/main/kotlin/com/privee/signal/SignalState.kt b/core/signal/src/main/kotlin/com/privee/signal/SignalState.kt index c7b9b55..86e85b0 100644 --- a/core/signal/src/main/kotlin/com/privee/signal/SignalState.kt +++ b/core/signal/src/main/kotlin/com/privee/signal/SignalState.kt @@ -27,6 +27,8 @@ data class PeerMeta( val name: String? = null, val sessionEpoch: String? = null, val cursor: Cursor? = null, + /** Private note of the user about who this peer is. Never leaves the device. */ + val hint: String? = null, ) @Serializable diff --git a/core/signal/src/test/kotlin/com/privee/signal/SignalClientTest.kt b/core/signal/src/test/kotlin/com/privee/signal/SignalClientTest.kt index 632e027..1296174 100644 --- a/core/signal/src/test/kotlin/com/privee/signal/SignalClientTest.kt +++ b/core/signal/src/test/kotlin/com/privee/signal/SignalClientTest.kt @@ -218,6 +218,57 @@ class SignalClientTest { assertEquals(listOf("one", "two"), b.history(alice).map { it.plaintext }) } + @Test + fun `peer hints are local, normalized and survive clearing the history`() = runTest { + val storage = MemoryStorage() + val a = client(alice, storage) + a.ensureKeys() + client(bob).ensureKeys() + a.send(server.chat(alice, bob), bob, "hello") + + val messagesBefore = server.messages.size + assertEquals("from the gym", a.setPeerHint(bob, " from \n the gym ")) + assertEquals("from the gym", a.changes.value.peers[bob.toString()]?.hint) + assertEquals(messagesBefore, server.messages.size) + + a.clearHistory(bob) + assertEquals("from the gym", client(alice, storage).changes.value.peers[bob.toString()]?.hint) + + assertNull(a.setPeerHint(bob, " ")) + assertNull(a.changes.value.peers[bob.toString()]?.hint) + } + + @Test + fun `clearing peer hints keeps the rest of the peer metadata`() = runTest { + val a = client(alice) + a.ensureKeys() + client(bob).ensureKeys() + a.send(server.chat(alice, bob), bob, "hello") + a.setPeerHint(bob, "neighbour") + val before = a.changes.value.peers.getValue(bob.toString()) + + a.clearPeerHints() + + assertEquals(before.copy(hint = null), a.changes.value.peers.getValue(bob.toString())) + a.setPeerHint(bob, "neighbour") + a.wipe() + assertTrue(a.changes.value.peers.isEmpty()) + } + + @Test + fun `normalizeHint limits the length by code points`() { + assertNull(SignalClient.normalizeHint(null)) + assertNull(SignalClient.normalizeHint(" \t\n")) + val long = "😀".repeat(SignalClient.MAX_HINT_LENGTH + 5) + assertEquals("😀".repeat(SignalClient.MAX_HINT_LENGTH), SignalClient.normalizeHint(long)) + } + + @Test + fun `state without hints still decodes`() { + val state = SignalClient.json.decodeFromString(SignalState.serializer(), """{"peers":{"2":{"name":"bob"}}}""") + assertEquals(PeerMeta(name = "bob"), state.peers["2"]) + } + @Test fun `a new epoch rebuilds the session`() = runTest { val a = client(alice) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1871b85..01ce31e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -45,6 +45,12 @@ container: Everything stored is encrypted by `EncryptedFileStorage`, using AES-GCM under a non-exportable Android Keystore key. All of it lives under `noBackupFilesDir`, so it is never included in backups. +Conversation hints (a short private note on who a conversation is with) are +stored only in `PeerMeta.hint` inside the Signal state. They never go to the +server, push payloads or notifications. "Clear history" keeps them; signing +out and "Forget device" drop them. The editor (`HintDialog`) always advises +against writing the other person's name. + `PriveeApplication` also holds two device-local preferences, kept outside the container and outside any account, so logging out or forgetting the device does not reset them. They are never sent to a server, and `data_extraction_rules.xml`