Add chat UX improvements and fix DM naming sync

This commit is contained in:
ltadeu6 2026-05-16 16:24:32 -03:00
parent 700289fef9
commit b30e83898c
10 changed files with 704 additions and 102 deletions

View file

@ -9,10 +9,13 @@ Progress log and architecture notes for AI agents working on this project.
| Component | Status | | Component | Status |
|---|---| |---|---|
| Build infrastructure (Gradle, version catalog) | done | | Build infrastructure (Gradle, version catalog) | done |
| NixOS CLI dev shell / Android SDK toolchain | done |
| Matrix networking (Retrofit API + Session) | done | | Matrix networking (Retrofit API + Session) | done |
| SSO / password login flow | done | | SSO / password login flow | done |
| Room list screen | done | | Room list screen | done |
| Chat screen (send + receive) | done | | Chat screen (send + receive) | done |
| E2EE via local Olm wrapper | done |
| Device verification (SAS) | done |
| Terminal theme (green on black, monospace) | done | | Terminal theme (green on black, monospace) | done |
| Launcher icon (vector adaptive) | done | | Launcher icon (vector adaptive) | done |
| README | done | | README | done |
@ -24,6 +27,23 @@ Progress log and architecture notes for AI agents working on this project.
### No heavy Matrix SDK ### No heavy Matrix SDK
Instead of importing the full `matrix-android-sdk2` (used by Element), we call the Matrix REST API directly with Retrofit. This keeps the APK small and the code easy to follow. The trade-off is no E2E encryption support. Instead of importing the full `matrix-android-sdk2` (used by Element), we call the Matrix REST API directly with Retrofit. This keeps the APK small and the code easy to follow. The trade-off is no E2E encryption support.
### Local Olm build instead of Maven-native `olm-sdk`
The upstream `org.matrix.android:olm-sdk` Maven artifact shipped a `libolm.so` that was only 4 KB ELF-aligned on arm64, which triggered Android's 16 KB page-size compatibility warning on modern devices. The app now vendors:
- `app/libs/olm-sdk-3.2.16.jar`
- `app/src/main/jniLibs/*/libolm.so`
Those JNI binaries were rebuilt locally from the Matrix `olm` Android wrapper with linker flags that produce 16 KB-aligned `LOAD` segments on arm64.
### Nix-first CLI builds
The repo includes a `flake.nix` dev shell for NixOS. It provisions:
- JDK 17
- Gradle
- Android SDK platforms 31 and 35
- Build Tools 30.0.2, 34.0.0, and 35.0.0
- Android NDK
The shell rewrites `local.properties` on entry so CLI builds use the Nix-provided SDK path.
### SSO via Custom Tabs ### SSO via Custom Tabs
Login opens the homeserver's `/login/sso/redirect` endpoint in a Chrome Custom Tab. After auth the homeserver redirects to `matrixandroid://sso?loginToken=<token>`. The app intercepts this URI via an `<intent-filter>` in `AndroidManifest.xml` and exchanges the token for an access token. Login opens the homeserver's `/login/sso/redirect` endpoint in a Chrome Custom Tab. After auth the homeserver redirects to `matrixandroid://sso?loginToken=<token>`. The app intercepts this URI via an `<intent-filter>` in `AndroidManifest.xml` and exchanges the token for an access token.
@ -33,6 +53,14 @@ Login opens the homeserver's `/login/sso/redirect` endpoint in a Chrome Custom T
### MatrixSession singleton ### MatrixSession singleton
`MatrixSession` is a process-scoped singleton (companion object). It holds the Retrofit client, persists the access token in `SharedPreferences`, and runs the `/sync` long-polling loop in a coroutine scope. All screens observe `syncState: StateFlow<SyncState>`. `MatrixSession` is a process-scoped singleton (companion object). It holds the Retrofit client, persists the access token in `SharedPreferences`, and runs the `/sync` long-polling loop in a coroutine scope. All screens observe `syncState: StateFlow<SyncState>`.
### Timeline is event-cache driven
`MatrixSession` now keeps a per-room in-memory `RoomEvent` cache and rebuilds the visible `Message` list from those events. This allows:
- local filtering/search over loaded events
- message edits (`m.replace`)
- reactions (`m.annotation`)
- incremental history pagination
- retrying decryption after keys arrive from another device
### Sync filter ### Sync filter
Initial and incremental syncs use a compact filter: Initial and incremental syncs use a compact filter:
- Timeline limited to 50 events per room - Timeline limited to 50 events per room
@ -45,13 +73,14 @@ This prevents the first sync from being huge on accounts with many rooms.
## Known issues / future work ## Known issues / future work
- **E2E encryption**: Matrix Olm/Megolm is not implemented. Encrypted rooms will show blank messages. - **Olm is still legacy crypto**: The app uses a locally rebuilt Olm wrapper. Matrix has deprecated libolm in favor of vodozemac. A future migration should replace the current JNI/JAR setup.
- **SAS verification edge case**: A handshake-ordering fix is currently in progress. The known symptom was that self-verification could fail depending on which device confirmed matching emojis first.
- **Push notifications**: Not implemented. The app only receives messages while open. - **Push notifications**: Not implemented. The app only receives messages while open.
- **Pagination**: No scroll-back pagination for old messages. - **History is partial**: Pagination exists only while the room exposes a `prev_batch` token and only for message history. There is still no durable local database.
- **Room member display names**: Members are shown as their MXID, not display name.
- **Image/file messages**: Silently dropped (only `m.text` is rendered). - **Image/file messages**: Silently dropped (only `m.text` is rendered).
- **Error handling**: Network errors on send are silently swallowed; add a snackbar or retry UI. - **Rich message support is partial**: Reactions and edits are handled for loaded events, but there is no reaction picker, no redactions, and no full relation aggregation from the homeserver.
- **Direct messages**: DMs appear in the room list using the room ID; should resolve the other user's display name. - **Search is in-memory only**: Chat search only scans events already loaded into memory for the current room.
- **Error handling**: Basic inline send/load errors are shown, but there is still no global offline state, retry queue, or structured error UX.
--- ---
@ -75,18 +104,27 @@ matrix-android/
│ │ ├── navigation/AppNavigation.kt │ │ ├── navigation/AppNavigation.kt
│ │ ├── auth/LoginScreen.kt │ │ ├── auth/LoginScreen.kt
│ │ ├── auth/LoginViewModel.kt │ │ ├── auth/LoginViewModel.kt
│ │ ├── verification/VerificationScreen.kt
│ │ ├── rooms/RoomListScreen.kt │ │ ├── rooms/RoomListScreen.kt
│ │ ├── chat/ChatScreen.kt │ │ ├── chat/ChatScreen.kt
│ │ ├── chat/ChatViewModel.kt │ │ ├── chat/ChatViewModel.kt
│ │ ├── matrix/MatrixApi.kt │ │ ├── matrix/MatrixApi.kt
│ │ ├── matrix/MatrixModels.kt │ │ ├── matrix/MatrixModels.kt
│ │ ├── matrix/MatrixSession.kt │ │ ├── matrix/MatrixSession.kt
│ │ ├── matrix/CryptoService.kt
│ │ ├── matrix/VerificationService.kt
│ │ └── ui/theme/Theme.kt │ │ └── ui/theme/Theme.kt
│ └── res/ │ └── res/
│ ├── drawable/ic_launcher_foreground.xml │ ├── drawable/ic_launcher_foreground.xml
│ ├── mipmap-anydpi-v26/ic_launcher.xml │ ├── mipmap-anydpi-v26/ic_launcher.xml
│ ├── mipmap-anydpi-v26/ic_launcher_round.xml │ ├── mipmap-anydpi-v26/ic_launcher_round.xml
│ └── values/{strings,colors,themes}.xml │ └── values/{strings,colors,themes}.xml
├── flake.nix
├── flake.lock
├── gradlew
├── gradlew.bat
├── app/libs/olm-sdk-3.2.16.jar
├── app/src/main/jniLibs/
├── README.md ├── README.md
└── AGENTS.md └── AGENTS.md
``` ```
@ -104,3 +142,36 @@ matrix-android/
- SSO flow: Custom Tabs → homeserver → `matrixandroid://sso` deep link → token exchange - SSO flow: Custom Tabs → homeserver → `matrixandroid://sso` deep link → token exchange
- Compose UI with monospace font, #00FF41 green, #0A0A0A background - Compose UI with monospace font, #00FF41 green, #0A0A0A background
- All screens text-only, no images/icons/avatars - All screens text-only, no images/icons/avatars
## Session 2 — NixOS CLI + 16 KB Android compatibility (2026-05-16)
- Added `flake.nix` + `flake.lock` for NixOS CLI builds
- Generated and committed Gradle wrapper files
- Discovered Android 16 KB compatibility warning on Pixel 7a
- Confirmed APK ZIP alignment was already correct; root cause was Maven `libolm.so` being ELF-aligned to 4 KB
- Cloned upstream Matrix `olm` Android wrapper, patched NDK build flags for 16 KB page size, and rebuilt `libolm.so`
- Worked around old upstream Android wrapper assumptions:
- required platform 31 + build-tools 30.0.2
- required NDK path/layout adjustments
- required one small C++ compatibility fix in `olm::List` for modern clang/NDK
- Replaced `implementation(libs.olm.sdk)` with:
- local `app/libs/olm-sdk-3.2.16.jar`
- local `app/src/main/jniLibs/*/libolm.so`
- Validated final APK on-device:
- `libolm.so` inside the APK has `LOAD Align = 0x4000`
- APK installed and launched over ADB on the Pixel 7a
## Session 3 — verification and chat UX expansion (2026-05-16, in progress)
- Created checkpoint commit `0ede945` before feature work
- Began fixing SAS verification handshake ordering
- Added event-cache-backed message reconstruction in `MatrixSession`
- Added local support for:
- in-room search over loaded messages
- message edits via `m.replace`
- reactions via `m.annotation`
- older-message pagination via `/rooms/{roomId}/messages`
- room-level encryption/history indicators
- inline chat error feedback
- Added first-pass DM name resolution fallback via joined members lookup
- This session is still dirty in the worktree; verify behavior before treating these changes as final

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
@ -70,6 +71,7 @@ fun LoginScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 24.dp, vertical = 48.dp), .padding(horizontal = 24.dp, vertical = 48.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start horizontalAlignment = Alignment.Start

View file

@ -1,5 +1,7 @@
package com.ltadeu6.matrix.chat package com.ltadeu6.matrix.chat
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -17,14 +19,18 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -41,6 +47,7 @@ import com.ltadeu6.matrix.ui.theme.MatrixGreen
import com.ltadeu6.matrix.ui.theme.MatrixGreenDim import com.ltadeu6.matrix.ui.theme.MatrixGreenDim
import com.ltadeu6.matrix.ui.theme.TerminalFont import com.ltadeu6.matrix.ui.theme.TerminalFont
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ChatScreen( fun ChatScreen(
roomId: String, roomId: String,
@ -55,11 +62,18 @@ fun ChatScreen(
val messages by vm.messages.collectAsState() val messages by vm.messages.collectAsState()
val roomName by vm.roomName.collectAsState() val roomName by vm.roomName.collectAsState()
val isEncrypted by vm.isEncrypted.collectAsState()
val canLoadMore by vm.canLoadMore.collectAsState()
val loadingOlder by vm.loadingOlder.collectAsState()
val inputText by vm.inputText.collectAsState() val inputText by vm.inputText.collectAsState()
val searchQuery by vm.searchQuery.collectAsState()
val sendError by vm.sendError.collectAsState()
val editingEventId by vm.editingEventId.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var selectedMessage by remember { mutableStateOf<Message?>(null) }
LaunchedEffect(messages.size) { LaunchedEffect(messages.size, searchQuery) {
if (messages.isNotEmpty()) { if (messages.isNotEmpty() && searchQuery.isBlank()) {
listState.animateScrollToItem(messages.size - 1) listState.animateScrollToItem(messages.size - 1)
} }
} }
@ -90,12 +104,75 @@ fun ChatScreen(
) )
} }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.End
) {
Text(
text = if (isEncrypted) "[e2ee]" else "[plain]",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixGreenDim)
)
}
Text( Text(
text = "".repeat(50), text = "".repeat(50),
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder), style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder),
modifier = Modifier.padding(horizontal = 8.dp) modifier = Modifier.padding(horizontal = 8.dp)
) )
OutlinedTextField(
value = searchQuery,
onValueChange = vm::setSearchQuery,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
placeholder = {
Text(
"search loaded messages...",
style = TextStyle(fontFamily = TerminalFont, color = MatrixBorder)
)
},
textStyle = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreen),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MatrixGreen,
unfocusedBorderColor = MatrixBorder,
cursorColor = MatrixGreen,
focusedTextColor = MatrixGreen,
unfocusedTextColor = MatrixGreen,
focusedContainerColor = MatrixBackground,
unfocusedContainerColor = MatrixBackground
)
)
if (editingEventId != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "editing message...",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreenDim)
)
Spacer(Modifier.weight(1f))
TextButton(onClick = vm::cancelEditing, contentPadding = PaddingValues(0.dp)) {
Text("cancel", style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixBorder))
}
}
}
sendError?.let { error ->
Text(
text = "! $error",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = androidx.compose.ui.graphics.Color(0xFFFF4444)),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
// Message list // Message list
LazyColumn( LazyColumn(
state = listState, state = listState,
@ -105,11 +182,29 @@ fun ChatScreen(
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
if (canLoadMore) {
item("load-older") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
TextButton(onClick = vm::loadOlderMessages, enabled = !loadingOlder) {
Text(
text = if (loadingOlder) "[loading older...]" else "[load older]",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreenDim)
)
}
}
}
}
items( items(
items = messages, items = messages,
key = { it.eventId.ifEmpty { "${it.sender}:${it.timestamp}" } } key = { it.eventId.ifEmpty { "${it.sender}:${it.timestamp}" } }
) { msg -> ) { msg ->
MessageRow(msg) MessageRow(
msg = msg,
onLongPress = { selectedMessage = msg }
)
} }
} }
@ -161,29 +256,81 @@ fun ChatScreen(
) )
} }
} }
selectedMessage?.let { message ->
AlertDialog(
containerColor = MatrixBackground,
onDismissRequest = { selectedMessage = null },
title = {
Text(
text = "message actions",
style = TextStyle(fontFamily = TerminalFont, fontSize = 16.sp, color = MatrixGreen)
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = {
vm.reactTo(message.eventId, "👍")
selectedMessage = null
}) { Text("react 👍", fontFamily = TerminalFont, color = MatrixGreen) }
TextButton(onClick = {
vm.reactTo(message.eventId, "❤️")
selectedMessage = null
}) { Text("react ❤️", fontFamily = TerminalFont, color = MatrixGreen) }
TextButton(onClick = {
vm.reactTo(message.eventId, "👀")
selectedMessage = null
}) { Text("react 👀", fontFamily = TerminalFont, color = MatrixGreen) }
if (message.isOutgoing) {
TextButton(onClick = {
vm.startEditing(message)
selectedMessage = null
}) { Text("edit", fontFamily = TerminalFont, color = MatrixGreen) }
}
}
},
confirmButton = {
TextButton(onClick = { selectedMessage = null }) {
Text("close", fontFamily = TerminalFont, color = MatrixBorder)
}
}
)
}
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun MessageRow(msg: Message) { private fun MessageRow(msg: Message, onLongPress: () -> Unit) {
val shortSender = msg.sender.removePrefix("@").substringBefore(":") val shortSender = msg.sender.removePrefix("@").substringBefore(":")
if (msg.isOutgoing) { if (msg.isOutgoing) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = {}, onLongClick = onLongPress),
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
Text( Column(
text = "> ${msg.body}", horizontalAlignment = Alignment.End,
style = TextStyle(
fontFamily = TerminalFont,
fontSize = 14.sp,
color = MatrixGreenDim
),
modifier = Modifier.widthIn(max = 300.dp) modifier = Modifier.widthIn(max = 300.dp)
) ) {
Text(
text = "> ${msg.body}",
style = TextStyle(
fontFamily = TerminalFont,
fontSize = 14.sp,
color = MatrixGreenDim
)
)
MessageMeta(msg)
}
} }
} else { } else {
Column(modifier = Modifier.widthIn(max = 300.dp)) { Column(
modifier = Modifier
.widthIn(max = 300.dp)
.combinedClickable(onClick = {}, onLongClick = onLongPress)
) {
Text( Text(
text = "< $shortSender", text = "< $shortSender",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder) style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
@ -192,6 +339,23 @@ private fun MessageRow(msg: Message) {
text = " ${msg.body}", text = " ${msg.body}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen) style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen)
) )
MessageMeta(msg)
} }
} }
} }
@Composable
private fun MessageMeta(msg: Message) {
val pieces = buildList {
if (msg.isEdited) add("edited")
if (msg.reactions.isNotEmpty()) {
add(msg.reactions.entries.joinToString(" ") { "${it.key} ${it.value}" })
}
}
if (pieces.isNotEmpty()) {
Text(
text = pieces.joinToString(" "),
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixBorder)
)
}
}

View file

@ -5,7 +5,9 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.ltadeu6.matrix.matrix.MatrixSession import com.ltadeu6.matrix.matrix.MatrixSession
import com.ltadeu6.matrix.matrix.Message import com.ltadeu6.matrix.matrix.Message
import com.ltadeu6.matrix.matrix.Room
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -18,27 +20,104 @@ class ChatViewModel(
val roomId: String val roomId: String
) : ViewModel() { ) : ViewModel() {
val messages: StateFlow<List<Message>> = session.syncState private val roomState: StateFlow<Room?> = session.syncState
.map { it.rooms[roomId] }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val allMessages: StateFlow<List<Message>> = session.syncState
.map { it.messages[roomId] ?: emptyList() } .map { it.messages[roomId] ?: emptyList() }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val roomName: StateFlow<String> = session.syncState
.map { it.rooms[roomId]?.name ?: roomId }
.stateIn(viewModelScope, SharingStarted.Eagerly, roomId)
private val _inputText = MutableStateFlow("") private val _inputText = MutableStateFlow("")
val inputText: StateFlow<String> = _inputText.asStateFlow() val inputText: StateFlow<String> = _inputText.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _sendError = MutableStateFlow<String?>(null)
val sendError: StateFlow<String?> = _sendError.asStateFlow()
private val _loadingOlder = MutableStateFlow(false)
val loadingOlder: StateFlow<Boolean> = _loadingOlder.asStateFlow()
private val _editingEventId = MutableStateFlow<String?>(null)
val editingEventId: StateFlow<String?> = _editingEventId.asStateFlow()
val roomName: StateFlow<String> = roomState
.map { it?.name ?: roomId }
.stateIn(viewModelScope, SharingStarted.Eagerly, roomId)
val isEncrypted: StateFlow<Boolean> = roomState
.map { it?.isEncrypted == true }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val canLoadMore: StateFlow<Boolean> = roomState
.map { it?.canLoadMore == true }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val messages: StateFlow<List<Message>> = combine(allMessages, searchQuery) { messages, query ->
val term = query.trim()
if (term.isBlank()) messages
else messages.filter {
it.body.contains(term, ignoreCase = true) || it.sender.contains(term, ignoreCase = true)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
fun setInput(text: String) { fun setInput(text: String) {
_sendError.value = null
_inputText.value = text _inputText.value = text
} }
fun setSearchQuery(text: String) {
_searchQuery.value = text
}
fun clearSendError() {
_sendError.value = null
}
fun startEditing(message: Message) {
if (!message.isOutgoing) return
_editingEventId.value = message.eventId
_inputText.value = message.body
_sendError.value = null
}
fun cancelEditing() {
_editingEventId.value = null
_inputText.value = ""
}
fun reactTo(eventId: String, key: String) {
viewModelScope.launch {
session.sendReaction(roomId, eventId, key)
.onFailure { _sendError.value = it.message ?: "failed to send reaction" }
}
}
fun loadOlderMessages() {
if (_loadingOlder.value) return
viewModelScope.launch {
_loadingOlder.value = true
session.loadOlderMessages(roomId)
.onFailure { _sendError.value = it.message ?: "failed to load older messages" }
_loadingOlder.value = false
}
}
fun sendMessage() { fun sendMessage() {
val text = _inputText.value.trim() val text = _inputText.value.trim()
if (text.isBlank()) return if (text.isBlank()) return
_inputText.value = "" val editingEventId = _editingEventId.value
viewModelScope.launch { viewModelScope.launch {
session.sendMessage(roomId, text) val result = if (editingEventId != null) {
session.editMessage(roomId, editingEventId, text)
} else {
session.sendMessage(roomId, text)
}
result.onSuccess {
_inputText.value = ""
_editingEventId.value = null
_sendError.value = null
}.onFailure {
_sendError.value = it.message ?: if (editingEventId != null) "failed to edit message" else "failed to send message"
}
} }
} }
} }

View file

@ -201,18 +201,23 @@ class CryptoService(
// Room message decryption / encryption // Room message decryption / encryption
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
fun decryptRoomEvent(content: JsonObject): String? { fun decryptRoomEventPayload(content: JsonObject): JsonObject? {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
val ciphertext = (content["ciphertext"] as? JsonPrimitive)?.content ?: return null val ciphertext = (content["ciphertext"] as? JsonPrimitive)?.content ?: return null
val session = inboundSessions[sessionId] ?: return null val session = inboundSessions[sessionId] ?: return null
return try { return try {
val result = session.decryptMessage(ciphertext) val result = session.decryptMessage(ciphertext)
val decrypted = json.parseToJsonElement(result.mDecryptedMessage).jsonObject json.parseToJsonElement(result.mDecryptedMessage).jsonObject
(decrypted["content"]?.jsonObject?.get("body") as? JsonPrimitive)?.content
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun decryptRoomEvent(content: JsonObject): String? =
(decryptRoomEventPayload(content)
?.get("content")?.jsonObject
?.get("body") as? JsonPrimitive)
?.content
fun encryptRoomEvent(roomId: String, plaintext: String): JsonObject? { fun encryptRoomEvent(roomId: String, plaintext: String): JsonObject? {
val session = outboundSessions[roomId] ?: return null val session = outboundSessions[roomId] ?: return null
return try { return try {

View file

@ -63,6 +63,12 @@ interface MatrixApi {
@Body request: CreateRoomRequest @Body request: CreateRoomRequest
): CreateRoomResponse ): CreateRoomResponse
@GET("/_matrix/client/v3/user/{userId}/account_data/m.direct")
suspend fun getDirectRooms(
@Header("Authorization") auth: String,
@Path("userId") userId: String
): JsonObject
@POST("/_matrix/client/v3/rooms/{roomId}/leave") @POST("/_matrix/client/v3/rooms/{roomId}/leave")
suspend fun leaveRoom( suspend fun leaveRoom(
@Header("Authorization") auth: String, @Header("Authorization") auth: String,
@ -101,6 +107,15 @@ interface MatrixApi {
@Path("roomId") roomId: String @Path("roomId") roomId: String
): JoinedMembersResponse ): JoinedMembersResponse
@GET("/_matrix/client/v3/rooms/{roomId}/messages")
suspend fun getMessages(
@Header("Authorization") auth: String,
@Path("roomId") roomId: String,
@Query("from") from: String,
@Query("dir") dir: String = "b",
@Query("limit") limit: Int = 50
): MessagesResponse
@PUT("/_matrix/client/v3/sendToDevice/{eventType}/{txnId}") @PUT("/_matrix/client/v3/sendToDevice/{eventType}/{txnId}")
suspend fun sendToDevice( suspend fun sendToDevice(
@Header("Authorization") auth: String, @Header("Authorization") auth: String,

View file

@ -92,7 +92,8 @@ data class JoinedRoomSync(
@Serializable @Serializable
data class Timeline( data class Timeline(
val events: List<RoomEvent> = emptyList(), val events: List<RoomEvent> = emptyList(),
@SerialName("limited") val limited: Boolean = false @SerialName("limited") val limited: Boolean = false,
@SerialName("prev_batch") val prevBatch: String? = null
) )
@Serializable @Serializable
@ -157,11 +158,20 @@ data class MemberInfo(
@SerialName("avatar_url") val avatarUrl: String? = null @SerialName("avatar_url") val avatarUrl: String? = null
) )
@Serializable
data class MessagesResponse(
val chunk: List<RoomEvent> = emptyList(),
val end: String? = null,
val start: String? = null
)
// --- Local UI models --- // --- Local UI models ---
data class Room( data class Room(
val id: String, val id: String,
val name: String val name: String,
val isEncrypted: Boolean = false,
val canLoadMore: Boolean = false
) )
data class Message( data class Message(
@ -169,5 +179,7 @@ data class Message(
val sender: String, val sender: String,
val body: String, val body: String,
val timestamp: Long, val timestamp: Long,
val isOutgoing: Boolean val isOutgoing: Boolean,
val isEdited: Boolean = false,
val reactions: Map<String, Int> = emptyMap()
) )

View file

@ -62,6 +62,9 @@ class MatrixSession private constructor(private val appContext: Context) {
private var syncJob: Job? = null private var syncJob: Job? = null
private var api: MatrixApi? = null private var api: MatrixApi? = null
private var cryptoService: CryptoService? = null private var cryptoService: CryptoService? = null
private val roomEventCache = mutableMapOf<String, MutableList<RoomEvent>>()
private val roomPrevBatches = mutableMapOf<String, String?>()
private val directRoomTargets = mutableMapOf<String, String>()
/** /**
* SAS device verification service. Created once; userId/deviceId/cryptoService * SAS device verification service. Created once; userId/deviceId/cryptoService
@ -176,6 +179,9 @@ class MatrixSession private constructor(private val appContext: Context) {
encryptedRooms.clear() encryptedRooms.clear()
sharedSessionRooms.clear() sharedSessionRooms.clear()
requestedSessions.clear() requestedSessions.clear()
roomEventCache.clear()
roomPrevBatches.clear()
directRoomTargets.clear()
verificationService.dismiss() verificationService.dismiss()
verificationService.userId = "" verificationService.userId = ""
verificationService.deviceId = "" verificationService.deviceId = ""
@ -185,30 +191,66 @@ class MatrixSession private constructor(private val appContext: Context) {
} }
suspend fun sendMessage(roomId: String, body: String): Result<Unit> { suspend fun sendMessage(roomId: String, body: String): Result<Unit> {
return sendTimelineEvent(
roomId = roomId,
eventType = "m.room.message",
content = buildJsonObject {
put("msgtype", "m.text")
put("body", body)
}
)
}
suspend fun editMessage(roomId: String, eventId: String, body: String): Result<Unit> {
return sendTimelineEvent(
roomId = roomId,
eventType = "m.room.message",
content = buildJsonObject {
put("msgtype", "m.text")
put("body", "* $body")
put("m.new_content", buildJsonObject {
put("msgtype", "m.text")
put("body", body)
})
put("m.relates_to", buildJsonObject {
put("rel_type", "m.replace")
put("event_id", eventId)
})
}
)
}
suspend fun sendReaction(roomId: String, eventId: String, key: String): Result<Unit> {
return sendTimelineEvent(
roomId = roomId,
eventType = "m.reaction",
content = buildJsonObject {
put("m.relates_to", buildJsonObject {
put("rel_type", "m.annotation")
put("event_id", eventId)
put("key", key)
})
}
)
}
suspend fun loadOlderMessages(roomId: String, limit: Int = 50): Result<Unit> {
val token = accessToken ?: return Result.failure(Exception("Not logged in")) val token = accessToken ?: return Result.failure(Exception("Not logged in"))
val matrixApi = api ?: return Result.failure(Exception("No API client")) val matrixApi = api ?: return Result.failure(Exception("No API client"))
val from = roomPrevBatches[roomId] ?: return Result.failure(Exception("No older messages"))
return runCatching { return runCatching {
val txnId = "${System.currentTimeMillis()}_${txnCounter.incrementAndGet()}" val response = matrixApi.getMessages("Bearer $token", roomId, from = from, limit = limit)
val crypto = cryptoService mergeRoomEvents(roomId, response.chunk)
if (crypto != null && roomId in encryptedRooms) { roomPrevBatches[roomId] = response.end
// Share our outbound session with room members if we haven't yet
if (roomId !in sharedSessionRooms) { val current = _syncState.value
shareGroupSession(roomId) val rooms = current.rooms.toMutableMap()
sharedSessionRooms.add(roomId) val messages = current.messages.toMutableMap()
} val missingSessions = mutableListOf<MissingSession>()
val eventPayload = buildJsonObject { rebuildRoomMessages(roomId, rooms, messages, missingSessions)
put("room_id", roomId) _syncState.value = SyncState(rooms = rooms, messages = messages)
put("type", "m.room.message") if (missingSessions.isNotEmpty()) {
put("content", buildJsonObject { sendKeyRequests(missingSessions, "Bearer $token", matrixApi)
put("msgtype", "m.text")
put("body", body)
})
}.toString()
val encryptedContent = crypto.encryptRoomEvent(roomId, eventPayload)
?: throw Exception("Encryption failed — outbound session not established")
matrixApi.sendEvent("Bearer $token", roomId, "m.room.encrypted", txnId, encryptedContent)
} else {
matrixApi.sendMessage("Bearer $token", roomId, txnId, SendMessageRequest(body = body))
} }
} }
} }
@ -228,6 +270,8 @@ class MatrixSession private constructor(private val appContext: Context) {
) )
encryptedRooms.remove(roomId) encryptedRooms.remove(roomId)
sharedSessionRooms.remove(roomId) sharedSessionRooms.remove(roomId)
roomEventCache.remove(roomId)
roomPrevBatches.remove(roomId)
} }
} }
@ -295,6 +339,36 @@ class MatrixSession private constructor(private val appContext: Context) {
} }
} }
private suspend fun sendTimelineEvent(
roomId: String,
eventType: String,
content: JsonObject
): Result<Unit> {
val token = accessToken ?: return Result.failure(Exception("Not logged in"))
val matrixApi = api ?: return Result.failure(Exception("No API client"))
return runCatching {
val txnId = "${System.currentTimeMillis()}_${txnCounter.incrementAndGet()}"
val crypto = cryptoService
if (roomId in encryptedRooms) {
if (crypto == null) throw Exception("Encryption unavailable for this room")
if (roomId !in sharedSessionRooms) {
shareGroupSession(roomId)
sharedSessionRooms.add(roomId)
}
val eventPayload = buildJsonObject {
put("room_id", roomId)
put("type", eventType)
put("content", content)
}.toString()
val encryptedContent = crypto.encryptRoomEvent(roomId, eventPayload)
?: throw Exception("Encryption failed — outbound session not established")
matrixApi.sendEvent("Bearer $token", roomId, "m.room.encrypted", txnId, encryptedContent)
} else {
matrixApi.sendEvent("Bearer $token", roomId, eventType, txnId, content)
}
}
}
/** /**
* Sends `m.room_key_request` to-device events to all of our own devices (`*`) for every * Sends `m.room_key_request` to-device events to all of our own devices (`*`) for every
* session we failed to decrypt. Deduplicates by `roomId+sessionId` so we only ask once. * session we failed to decrypt. Deduplicates by `roomId+sessionId` so we only ask once.
@ -490,6 +564,7 @@ class MatrixSession private constructor(private val appContext: Context) {
if (isFirstSync) { if (isFirstSync) {
isFirstSync = false isFirstSync = false
refreshDirectRoomTargets(token, matrixApi)
fetchMissingRoomNames(token, matrixApi) fetchMissingRoomNames(token, matrixApi)
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
@ -501,15 +576,48 @@ class MatrixSession private constructor(private val appContext: Context) {
} }
} }
private suspend fun refreshDirectRoomTargets(token: String, matrixApi: MatrixApi) {
val userId = currentUserId ?: return
val direct = runCatching { matrixApi.getDirectRooms("Bearer $token", userId) }.getOrNull() ?: return
directRoomTargets.clear()
direct.forEach { (mxid, roomIdsJson) ->
val roomIds = roomIdsJson as? JsonArray ?: return@forEach
roomIds.forEach { roomIdJson ->
val roomId = (roomIdJson as? JsonPrimitive)?.content ?: return@forEach
directRoomTargets[roomId] = mxid
}
}
}
private suspend fun fetchMissingRoomNames(token: String, matrixApi: MatrixApi) { private suspend fun fetchMissingRoomNames(token: String, matrixApi: MatrixApi) {
val current = _syncState.value val current = _syncState.value
val updates = mutableMapOf<String, Room>() val updates = mutableMapOf<String, Room>()
current.rooms.forEach { (roomId, room) -> current.rooms.forEach { (roomId, room) ->
if (room.name == roomId) { val directTarget = directRoomTargets[roomId]
val shouldResolve = directTarget != null || room.name == roomId || room.name.isBlank()
if (shouldResolve) {
val name = runCatching { matrixApi.getRoomName("Bearer $token", roomId).name } val name = runCatching { matrixApi.getRoomName("Bearer $token", roomId).name }
.getOrNull()?.takeIf { it.isNotBlank() } .getOrNull()
?.takeIf { it.isNotBlank() && directTarget == null }
?: runCatching { matrixApi.getRoomAlias("Bearer $token", roomId).alias } ?: runCatching { matrixApi.getRoomAlias("Bearer $token", roomId).alias }
.getOrNull()?.takeIf { it.isNotBlank() } .getOrNull()
?.takeIf { it.isNotBlank() && directTarget == null }
?: runCatching {
val joined = matrixApi.getJoinedMembers("Bearer $token", roomId).joined
val preferred = directTarget?.let { target ->
joined[target]?.displayName?.takeIf { it.isNotBlank() } ?: joined[target]?.let { target }
}
if (preferred != null) {
preferred
} else {
val others = joined.entries.filter { it.key != currentUserId }
if (others.size == 1) {
others.first().value.displayName?.takeIf { it.isNotBlank() } ?: others.first().key
} else {
null
}
}
}.getOrNull()
if (name != null) updates[roomId] = room.copy(name = name) if (name != null) updates[roomId] = room.copy(name = name)
} }
} }
@ -518,6 +626,10 @@ class MatrixSession private constructor(private val appContext: Context) {
} }
} }
private fun prefersMemberName(roomId: String): Boolean {
return directRoomTargets.containsKey(roomId)
}
private fun processSyncResponse(response: SyncResponse): List<MissingSession> { private fun processSyncResponse(response: SyncResponse): List<MissingSession> {
val current = _syncState.value val current = _syncState.value
val rooms = current.rooms.toMutableMap() val rooms = current.rooms.toMutableMap()
@ -532,60 +644,173 @@ class MatrixSession private constructor(private val appContext: Context) {
val nameEvent = allStateEvents.lastOrNull { it.type == "m.room.name" } val nameEvent = allStateEvents.lastOrNull { it.type == "m.room.name" }
val aliasEvent = allStateEvents.lastOrNull { it.type == "m.room.canonical_alias" } val aliasEvent = allStateEvents.lastOrNull { it.type == "m.room.canonical_alias" }
val resolvedName = (nameEvent?.content?.get("name") as? JsonPrimitive)?.content val explicitName = (nameEvent?.content?.get("name") as? JsonPrimitive)?.content
?: (aliasEvent?.content?.get("alias") as? JsonPrimitive)?.content ?: (aliasEvent?.content?.get("alias") as? JsonPrimitive)?.content
?: rooms[roomId]?.name val existingName = rooms[roomId]?.name
?: roomId val resolvedName = if (prefersMemberName(roomId)) {
existingName?.takeIf { it.isNotBlank() && it != roomId }
rooms[roomId] = Room(id = roomId, name = resolvedName) ?: explicitName
?: roomId
} else {
explicitName
?: existingName
?: roomId
}
// Track encryption state // Track encryption state
val roomIsEncrypted = (roomData.state?.events ?: emptyList()) val roomIsEncrypted = (roomData.state?.events ?: emptyList())
.any { it.type == "m.room.encryption" } .any { it.type == "m.room.encryption" }
if (roomIsEncrypted) encryptedRooms.add(roomId) if (roomIsEncrypted) encryptedRooms.add(roomId)
roomPrevBatches[roomId] = roomData.timeline?.prevBatch ?: roomPrevBatches[roomId]
val newMsgs = roomData.timeline?.events rooms[roomId] = Room(
?.filter { it.type == "m.room.message" || it.type == "m.room.encrypted" } id = roomId,
?.mapNotNull { event -> name = resolvedName,
val body = when (event.type) { isEncrypted = roomId in encryptedRooms,
"m.room.message" -> canLoadMore = roomPrevBatches[roomId] != null
(event.content?.get("body") as? JsonPrimitive)?.content )
?: return@mapNotNull null mergeRoomEvents(roomId, roomData.timeline?.events ?: emptyList())
"m.room.encrypted" -> tryDecrypt(event, roomId, missingSessions) rebuildRoomMessages(roomId, rooms, messages, missingSessions)
else -> return@mapNotNull null
}
Message(
eventId = event.eventId,
sender = event.sender,
body = body,
timestamp = event.timestamp,
isOutgoing = event.sender == currentUserId
)
} ?: emptyList()
if (newMsgs.isNotEmpty()) {
val existing = messages[roomId] ?: emptyList()
val existingIds = existing.map { it.eventId }.toSet()
val merged = existing + newMsgs.filter { it.eventId !in existingIds }
messages[roomId] = merged.sortedBy { it.timestamp }
}
} }
_syncState.value = SyncState(rooms = rooms, messages = messages) _syncState.value = SyncState(rooms = rooms, messages = messages)
return missingSessions return missingSessions
} }
private fun tryDecrypt(event: RoomEvent, roomId: String, missingSessions: MutableList<MissingSession>): String { private fun mergeRoomEvents(roomId: String, events: List<RoomEvent>) {
val content = event.content ?: return "[encrypted message]" if (events.isEmpty()) return
val result = cryptoService?.decryptRoomEvent(content) val existing = roomEventCache.getOrPut(roomId) { mutableListOf() }
if (result == null) { val knownIds = existing.mapTo(mutableSetOf()) { it.eventId }
val sessionId = (content["session_id"] as? JsonPrimitive)?.content events.forEach { event ->
val senderKey = (content["sender_key"] as? JsonPrimitive)?.content val dedupeKey = event.eventId.ifEmpty { "${event.type}:${event.sender}:${event.timestamp}" }
if (sessionId != null && senderKey != null) { if (dedupeKey !in knownIds) {
missingSessions.add(MissingSession(roomId, senderKey, sessionId)) existing.add(event)
knownIds.add(dedupeKey)
} }
} }
return result ?: "[encrypted message]" }
private fun rebuildRoomMessages(
roomId: String,
rooms: MutableMap<String, Room>,
messages: MutableMap<String, List<Message>>,
missingSessions: MutableList<MissingSession>
) {
val cachedEvents = roomEventCache[roomId].orEmpty().sortedBy { it.timestamp }
val builtMessages = mutableListOf<Message>()
val messageIndexById = mutableMapOf<String, Int>()
val pendingEdits = mutableMapOf<String, String>()
val pendingReactions = mutableMapOf<String, MutableMap<String, Int>>()
cachedEvents.forEach { event ->
val decoded = decodeTimelineEvent(roomId, event, missingSessions) ?: return@forEach
val relatesTo = decoded.content["m.relates_to"]?.jsonObject
val relType = (relatesTo?.get("rel_type") as? JsonPrimitive)?.content
when {
decoded.type == "m.reaction" && relType == "m.annotation" -> {
val targetId = (relatesTo["event_id"] as? JsonPrimitive)?.content ?: return@forEach
val reactionKey = (relatesTo["key"] as? JsonPrimitive)?.content ?: return@forEach
val idx = messageIndexById[targetId]
if (idx != null) {
val current = builtMessages[idx]
val updated = current.reactions.toMutableMap()
updated[reactionKey] = (updated[reactionKey] ?: 0) + 1
builtMessages[idx] = current.copy(reactions = updated.toSortedMap())
} else {
val pending = pendingReactions.getOrPut(targetId) { mutableMapOf() }
pending[reactionKey] = (pending[reactionKey] ?: 0) + 1
}
}
decoded.type == "m.room.message" && relType == "m.replace" -> {
val targetId = (relatesTo["event_id"] as? JsonPrimitive)?.content ?: return@forEach
val newBody = (decoded.content["m.new_content"]?.jsonObject?.get("body") as? JsonPrimitive)?.content
?: (decoded.content["body"] as? JsonPrimitive)?.content
?: return@forEach
val idx = messageIndexById[targetId]
if (idx != null) {
val current = builtMessages[idx]
builtMessages[idx] = current.copy(body = newBody, isEdited = true)
} else {
pendingEdits[targetId] = newBody
}
}
decoded.type == "m.room.message" -> {
val body = (decoded.content["body"] as? JsonPrimitive)?.content ?: return@forEach
val editedBody = pendingEdits.remove(event.eventId)
val message = Message(
eventId = event.eventId,
sender = event.sender,
body = editedBody ?: body,
timestamp = event.timestamp,
isOutgoing = event.sender == currentUserId,
isEdited = editedBody != null,
reactions = pendingReactions.remove(event.eventId)?.toSortedMap() ?: emptyMap()
)
messageIndexById[event.eventId] = builtMessages.size
builtMessages.add(message)
}
}
}
messages[roomId] = builtMessages
rooms[roomId] = rooms[roomId]?.copy(
isEncrypted = roomId in encryptedRooms,
canLoadMore = roomPrevBatches[roomId] != null
) ?: Room(
id = roomId,
name = roomId,
isEncrypted = roomId in encryptedRooms,
canLoadMore = roomPrevBatches[roomId] != null
)
}
private data class DecodedTimelineEvent(
val type: String,
val content: JsonObject
)
private fun decodeTimelineEvent(
roomId: String,
event: RoomEvent,
missingSessions: MutableList<MissingSession>
): DecodedTimelineEvent? {
return when (event.type) {
"m.room.message", "m.reaction" -> {
val content = event.content ?: return null
DecodedTimelineEvent(event.type, content)
}
"m.room.encrypted" -> {
val encryptedContent = event.content ?: return DecodedTimelineEvent(
type = "m.room.message",
content = buildJsonObject {
put("body", "[encrypted message]")
}
)
val decrypted = cryptoService?.decryptRoomEventPayload(encryptedContent)
if (decrypted == null) {
val sessionId = (encryptedContent["session_id"] as? JsonPrimitive)?.content
val senderKey = (encryptedContent["sender_key"] as? JsonPrimitive)?.content
if (sessionId != null && senderKey != null) {
missingSessions.add(MissingSession(roomId, senderKey, sessionId))
}
DecodedTimelineEvent(
type = "m.room.message",
content = buildJsonObject {
put("body", "[encrypted message]")
}
)
} else {
val innerType = (decrypted["type"] as? JsonPrimitive)?.content ?: return null
val innerContent = decrypted["content"]?.jsonObject ?: return null
DecodedTimelineEvent(innerType, innerContent)
}
}
else -> null
}
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View file

@ -61,6 +61,7 @@ class VerificationService(private val scope: CoroutineScope) {
private var pendingTheirMac: JsonObject? = null private var pendingTheirMac: JsonObject? = null
private var selectedMacMethod: String? = null private var selectedMacMethod: String? = null
private var hasConfirmedLocally: Boolean = false private var hasConfirmedLocally: Boolean = false
private var hasVerifiedTheirMac: Boolean = false
fun processToDeviceEvent(event: JsonObject) { fun processToDeviceEvent(event: JsonObject) {
val type = (event["type"] as? JsonPrimitive)?.content ?: return val type = (event["type"] as? JsonPrimitive)?.content ?: return
@ -174,6 +175,7 @@ class VerificationService(private val scope: CoroutineScope) {
val sasBytes = sas.generateShortCode(info, 6) val sasBytes = sas.generateShortCode(info, 6)
val emojis = sasBytes.toSasEmojis() val emojis = sasBytes.toSasEmojis()
hasConfirmedLocally = false hasConfirmedLocally = false
hasVerifiedTheirMac = false
_state.value = VerificationState.ShowingEmojis(txnId, emojis) _state.value = VerificationState.ShowingEmojis(txnId, emojis)
} }
@ -272,6 +274,7 @@ class VerificationService(private val scope: CoroutineScope) {
val doneContent = buildJsonObject { put("transaction_id", txnId) } val doneContent = buildJsonObject { put("transaction_id", txnId) }
onSend(toUser, toDevice, "m.key.verification.done", "${txnId}_done", doneContent) onSend(toUser, toDevice, "m.key.verification.done", "${txnId}_done", doneContent)
hasVerifiedTheirMac = true
cleanup() cleanup()
_state.value = VerificationState.Done _state.value = VerificationState.Done
onVerified() onVerified()
@ -280,6 +283,9 @@ class VerificationService(private val scope: CoroutineScope) {
private fun handleDone(txnId: String) { private fun handleDone(txnId: String) {
if (txnId != currentTxnId) return if (txnId != currentTxnId) return
if (!hasVerifiedTheirMac) {
return
}
cleanup() cleanup()
_state.value = VerificationState.Done _state.value = VerificationState.Done
onVerified() onVerified()
@ -330,6 +336,7 @@ class VerificationService(private val scope: CoroutineScope) {
pendingTheirMac = null pendingTheirMac = null
selectedMacMethod = null selectedMacMethod = null
hasConfirmedLocally = false hasConfirmedLocally = false
hasVerifiedTheirMac = false
} }
} }

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@ -73,6 +74,7 @@ fun RoomListScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
) { ) {
Row( Row(
@ -116,7 +118,11 @@ fun RoomListScreen(
if (actionError != null) { if (actionError != null) {
Text( Text(
text = " ${actionError}", text = " ${actionError}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixGreenDim) style = TextStyle(
fontFamily = TerminalFont,
fontSize = 11.sp,
color = androidx.compose.ui.graphics.Color(0xFFFF4444)
)
) )
} }
@ -303,10 +309,26 @@ private fun RoomRow(room: Room, onClick: () -> Unit, onLongPress: () -> Unit) {
.combinedClickable(onClick = onClick, onLongClick = onLongPress) .combinedClickable(onClick = onClick, onLongClick = onLongPress)
.padding(vertical = 10.dp, horizontal = 4.dp) .padding(vertical = 10.dp, horizontal = 4.dp)
) { ) {
Text( Row(
text = "> ${room.name}", modifier = Modifier.fillMaxWidth(),
style = TextStyle(fontFamily = TerminalFont, fontSize = 15.sp, color = MatrixGreen) horizontalArrangement = Arrangement.SpaceBetween,
) verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "> ${room.name}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 15.sp, color = MatrixGreen)
)
val status = buildList {
if (room.isEncrypted) add("e2ee")
if (room.canLoadMore) add("history")
}.joinToString(" | ")
if (status.isNotBlank()) {
Text(
text = "[$status]",
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixGreenDim)
)
}
}
Text( Text(
text = " ${room.id}", text = " ${room.id}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixBorder) style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixBorder)