Search inside messages, across every conversation

🔴 There is no index, and that is a decision rather than an omission. An
index derived from decrypted history is plaintext by another name: it
would have to be sealed as carefully as the vault, taken by
forgetEverything, and kept consistent with the threads — a second copy
of everything anybody ever wrote, with its own ways to go wrong. This
reads what already exists and throws the plaintext away.

⚠️ It costs more here than on iOS, and the code says so. The iPhone app
keeps the whole decrypted history in memory; Android decrypts each
thread on demand. Hence a two-character minimum, a bound, a 250ms
debounce, and Dispatchers.IO.

🔴 Folding is length-preserving on purpose: the snippet slices the
ORIGINAL string at an index found in the folded one, so a fold that
dropped characters would cut in the wrong place — and for an accented
character, mid-character. There is a test with accents before the match.

Accent-insensitive because somebody typing "cafe" means "café", and a
search that knows the difference quietly fails in every language with
accents — Spanish is next on the localisation list.

Refs paulhitt/flamenet-messenger-ios#25

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-03 19:09:49 -04:00
co-authored by Claude Opus 4.8
parent fc8d40ecf6
commit 968a88789a
4 changed files with 405 additions and 0 deletions
@@ -19,6 +19,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -131,6 +132,11 @@ fun ColumnScope.MessagesScreen(
)
Spacer(Modifier.height(Space.md))
// ⚠️ Above the conversation list. Somebody searching for a word they
// remember is looking for the message, not the person — name matches are
// already covered by the list underneath.
MessageHits(model, query.value)
BuddyCirclesRow(model, phase)
Spacer(Modifier.height(Space.sm))
Divider()
@@ -294,3 +300,52 @@ private fun UnreadDot() {
.background(MaterialTheme.colorScheme.primary),
)
}
/**
* What a search found inside conversations, rather than in their names.
*
* ⚠️ Debounced. Each keystroke would otherwise decrypt every thread on the
* device — the cost the iPhone app does not pay, because it keeps the whole
* history in memory.
*/
@Composable
private fun MessageHits(model: MessengerViewModel, query: String) {
var hits by remember { mutableStateOf<List<io.flamenet.e2e.MessageSearch.Hit>>(emptyList()) }
LaunchedEffect(query) {
if (query.trim().length < io.flamenet.e2e.MessageSearch.MINIMUM_QUERY) {
hits = emptyList()
return@LaunchedEffect
}
kotlinx.coroutines.delay(250)
hits = model.searchMessages(query)
}
if (hits.isEmpty()) return
Text(
"IN MESSAGES",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Palette.SecondaryText,
letterSpacing = 0.8.sp,
modifier = Modifier.padding(top = Space.md, bottom = Space.xs),
)
for (hit in hits) {
Column(
Modifier.fillMaxWidth()
.padding(horizontal = Space.md, vertical = Space.sm)
.testTag("search-hit")
) {
Text(
hit.snippet,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.height(Space.sm))
Divider()
Spacer(Modifier.height(Space.sm))
}
@@ -2310,6 +2310,29 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch(Dispatchers.IO) { vault.saveDraft(chat.vaultThreadId(), text) }
}
/**
* Messages whose words match, across every conversation on this device.
*
* 🔴 **Scanned, not indexed.** An index over the decrypted history is
* plaintext by another name and would have to be sealed and taken by
* `forgetEverything` like everything else in the vault. See
* [io.flamenet.e2e.MessageSearch] for the condition under which that stops
* being the right call.
*
* ⚠️ Off the main thread, because unlike the iPhone app this decrypts each
* thread on demand rather than reading one already in memory.
*
* ⚠️ The query is never persisted. What somebody searched for is often more
* revealing than what they found.
*/
suspend fun searchMessages(query: String): List<io.flamenet.e2e.MessageSearch.Hit> {
val vault = vault ?: return emptyList()
return withContext(Dispatchers.IO) {
runCatching { io.flamenet.e2e.MessageSearch.hits(query, vault) }
.getOrDefault(emptyList())
}
}
fun sendMessage(text: String, mentions: List<Int> = emptyList()) = viewModelScope.launch {
val current = session ?: return@launch
val chat = signedIn()?.openChat ?: return@launch