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 |
|---|---|
| Build infrastructure (Gradle, version catalog) | done |
| NixOS CLI dev shell / Android SDK toolchain | done |
| Matrix networking (Retrofit API + Session) | done |
| SSO / password login flow | done |
| Room list screen | done |
| Chat screen (send + receive) | done |
| E2EE via local Olm wrapper | done |
| Device verification (SAS) | done |
| Terminal theme (green on black, monospace) | done |
| Launcher icon (vector adaptive) | done |
| README | done |
@ -24,6 +27,23 @@ Progress log and architecture notes for AI agents working on this project.
### 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.
### 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
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` 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
Initial and incremental syncs use a compact filter:
- 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
- **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.
- **Pagination**: No scroll-back pagination for old messages.
- **Room member display names**: Members are shown as their MXID, not display name.
- **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.
- **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.
- **Direct messages**: DMs appear in the room list using the room ID; should resolve the other user's display name.
- **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.
- **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
│ │ ├── auth/LoginScreen.kt
│ │ ├── auth/LoginViewModel.kt
│ │ ├── verification/VerificationScreen.kt
│ │ ├── rooms/RoomListScreen.kt
│ │ ├── chat/ChatScreen.kt
│ │ ├── chat/ChatViewModel.kt
│ │ ├── matrix/MatrixApi.kt
│ │ ├── matrix/MatrixModels.kt
│ │ ├── matrix/MatrixSession.kt
│ │ ├── matrix/CryptoService.kt
│ │ ├── matrix/VerificationService.kt
│ │ └── ui/theme/Theme.kt
│ └── res/
│ ├── drawable/ic_launcher_foreground.xml
│ ├── mipmap-anydpi-v26/ic_launcher.xml
│ ├── mipmap-anydpi-v26/ic_launcher_round.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
└── AGENTS.md
```
@ -104,3 +142,36 @@ matrix-android/
- SSO flow: Custom Tabs → homeserver → `matrixandroid://sso` deep link → token exchange
- Compose UI with monospace font, #00FF41 green, #0A0A0A background
- 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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
@ -70,6 +71,7 @@ fun LoginScreen(
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 24.dp, vertical = 48.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start

View file

@ -1,5 +1,7 @@
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.Column
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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.Modifier
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.TerminalFont
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatScreen(
roomId: String,
@ -55,11 +62,18 @@ fun ChatScreen(
val messages by vm.messages.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 searchQuery by vm.searchQuery.collectAsState()
val sendError by vm.sendError.collectAsState()
val editingEventId by vm.editingEventId.collectAsState()
val listState = rememberLazyListState()
var selectedMessage by remember { mutableStateOf<Message?>(null) }
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
LaunchedEffect(messages.size, searchQuery) {
if (messages.isNotEmpty() && searchQuery.isBlank()) {
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 = "".repeat(50),
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder),
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
LazyColumn(
state = listState,
@ -105,11 +182,29 @@ fun ChatScreen(
contentPadding = PaddingValues(vertical = 8.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 = messages,
key = { it.eventId.ifEmpty { "${it.sender}:${it.timestamp}" } }
) { 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
private fun MessageRow(msg: Message) {
private fun MessageRow(msg: Message, onLongPress: () -> Unit) {
val shortSender = msg.sender.removePrefix("@").substringBefore(":")
if (msg.isOutgoing) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = {}, onLongClick = onLongPress),
horizontalArrangement = Arrangement.End
) {
Text(
text = "> ${msg.body}",
style = TextStyle(
fontFamily = TerminalFont,
fontSize = 14.sp,
color = MatrixGreenDim
),
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.widthIn(max = 300.dp)
)
) {
Text(
text = "> ${msg.body}",
style = TextStyle(
fontFamily = TerminalFont,
fontSize = 14.sp,
color = MatrixGreenDim
)
)
MessageMeta(msg)
}
}
} else {
Column(modifier = Modifier.widthIn(max = 300.dp)) {
Column(
modifier = Modifier
.widthIn(max = 300.dp)
.combinedClickable(onClick = {}, onLongClick = onLongPress)
) {
Text(
text = "< $shortSender",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
@ -192,6 +339,23 @@ private fun MessageRow(msg: Message) {
text = " ${msg.body}",
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 com.ltadeu6.matrix.matrix.MatrixSession
import com.ltadeu6.matrix.matrix.Message
import com.ltadeu6.matrix.matrix.Room
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -18,27 +20,104 @@ class ChatViewModel(
val roomId: String
) : 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() }
.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("")
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) {
_sendError.value = null
_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() {
val text = _inputText.value.trim()
if (text.isBlank()) return
_inputText.value = ""
val editingEventId = _editingEventId.value
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
// -------------------------------------------------------------------------
fun decryptRoomEvent(content: JsonObject): String? {
fun decryptRoomEventPayload(content: JsonObject): JsonObject? {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
val ciphertext = (content["ciphertext"] as? JsonPrimitive)?.content ?: return null
val session = inboundSessions[sessionId] ?: return null
return try {
val result = session.decryptMessage(ciphertext)
val decrypted = json.parseToJsonElement(result.mDecryptedMessage).jsonObject
(decrypted["content"]?.jsonObject?.get("body") as? JsonPrimitive)?.content
json.parseToJsonElement(result.mDecryptedMessage).jsonObject
} 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? {
val session = outboundSessions[roomId] ?: return null
return try {

View file

@ -63,6 +63,12 @@ interface MatrixApi {
@Body request: CreateRoomRequest
): 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")
suspend fun leaveRoom(
@Header("Authorization") auth: String,
@ -101,6 +107,15 @@ interface MatrixApi {
@Path("roomId") roomId: String
): 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}")
suspend fun sendToDevice(
@Header("Authorization") auth: String,

View file

@ -92,7 +92,8 @@ data class JoinedRoomSync(
@Serializable
data class Timeline(
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
@ -157,11 +158,20 @@ data class MemberInfo(
@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 ---
data class Room(
val id: String,
val name: String
val name: String,
val isEncrypted: Boolean = false,
val canLoadMore: Boolean = false
)
data class Message(
@ -169,5 +179,7 @@ data class Message(
val sender: String,
val body: String,
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 api: MatrixApi? = 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
@ -176,6 +179,9 @@ class MatrixSession private constructor(private val appContext: Context) {
encryptedRooms.clear()
sharedSessionRooms.clear()
requestedSessions.clear()
roomEventCache.clear()
roomPrevBatches.clear()
directRoomTargets.clear()
verificationService.dismiss()
verificationService.userId = ""
verificationService.deviceId = ""
@ -185,30 +191,66 @@ class MatrixSession private constructor(private val appContext: Context) {
}
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 matrixApi = api ?: return Result.failure(Exception("No API client"))
val from = roomPrevBatches[roomId] ?: return Result.failure(Exception("No older messages"))
return runCatching {
val txnId = "${System.currentTimeMillis()}_${txnCounter.incrementAndGet()}"
val crypto = cryptoService
if (crypto != null && roomId in encryptedRooms) {
// Share our outbound session with room members if we haven't yet
if (roomId !in sharedSessionRooms) {
shareGroupSession(roomId)
sharedSessionRooms.add(roomId)
}
val eventPayload = buildJsonObject {
put("room_id", roomId)
put("type", "m.room.message")
put("content", buildJsonObject {
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))
val response = matrixApi.getMessages("Bearer $token", roomId, from = from, limit = limit)
mergeRoomEvents(roomId, response.chunk)
roomPrevBatches[roomId] = response.end
val current = _syncState.value
val rooms = current.rooms.toMutableMap()
val messages = current.messages.toMutableMap()
val missingSessions = mutableListOf<MissingSession>()
rebuildRoomMessages(roomId, rooms, messages, missingSessions)
_syncState.value = SyncState(rooms = rooms, messages = messages)
if (missingSessions.isNotEmpty()) {
sendKeyRequests(missingSessions, "Bearer $token", matrixApi)
}
}
}
@ -228,6 +270,8 @@ class MatrixSession private constructor(private val appContext: Context) {
)
encryptedRooms.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
* 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) {
isFirstSync = false
refreshDirectRoomTargets(token, matrixApi)
fetchMissingRoomNames(token, matrixApi)
}
} 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) {
val current = _syncState.value
val updates = mutableMapOf<String, 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 }
.getOrNull()?.takeIf { it.isNotBlank() }
.getOrNull()
?.takeIf { it.isNotBlank() && directTarget == null }
?: 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)
}
}
@ -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> {
val current = _syncState.value
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 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
?: rooms[roomId]?.name
?: roomId
rooms[roomId] = Room(id = roomId, name = resolvedName)
val existingName = rooms[roomId]?.name
val resolvedName = if (prefersMemberName(roomId)) {
existingName?.takeIf { it.isNotBlank() && it != roomId }
?: explicitName
?: roomId
} else {
explicitName
?: existingName
?: roomId
}
// Track encryption state
val roomIsEncrypted = (roomData.state?.events ?: emptyList())
.any { it.type == "m.room.encryption" }
if (roomIsEncrypted) encryptedRooms.add(roomId)
val newMsgs = roomData.timeline?.events
?.filter { it.type == "m.room.message" || it.type == "m.room.encrypted" }
?.mapNotNull { event ->
val body = when (event.type) {
"m.room.message" ->
(event.content?.get("body") as? JsonPrimitive)?.content
?: return@mapNotNull null
"m.room.encrypted" -> tryDecrypt(event, roomId, 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 }
}
roomPrevBatches[roomId] = roomData.timeline?.prevBatch ?: roomPrevBatches[roomId]
rooms[roomId] = Room(
id = roomId,
name = resolvedName,
isEncrypted = roomId in encryptedRooms,
canLoadMore = roomPrevBatches[roomId] != null
)
mergeRoomEvents(roomId, roomData.timeline?.events ?: emptyList())
rebuildRoomMessages(roomId, rooms, messages, missingSessions)
}
_syncState.value = SyncState(rooms = rooms, messages = messages)
return missingSessions
}
private fun tryDecrypt(event: RoomEvent, roomId: String, missingSessions: MutableList<MissingSession>): String {
val content = event.content ?: return "[encrypted message]"
val result = cryptoService?.decryptRoomEvent(content)
if (result == null) {
val sessionId = (content["session_id"] as? JsonPrimitive)?.content
val senderKey = (content["sender_key"] as? JsonPrimitive)?.content
if (sessionId != null && senderKey != null) {
missingSessions.add(MissingSession(roomId, senderKey, sessionId))
private fun mergeRoomEvents(roomId: String, events: List<RoomEvent>) {
if (events.isEmpty()) return
val existing = roomEventCache.getOrPut(roomId) { mutableListOf() }
val knownIds = existing.mapTo(mutableSetOf()) { it.eventId }
events.forEach { event ->
val dedupeKey = event.eventId.ifEmpty { "${event.type}:${event.sender}:${event.timestamp}" }
if (dedupeKey !in knownIds) {
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 selectedMacMethod: String? = null
private var hasConfirmedLocally: Boolean = false
private var hasVerifiedTheirMac: Boolean = false
fun processToDeviceEvent(event: JsonObject) {
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 emojis = sasBytes.toSasEmojis()
hasConfirmedLocally = false
hasVerifiedTheirMac = false
_state.value = VerificationState.ShowingEmojis(txnId, emojis)
}
@ -272,6 +274,7 @@ class VerificationService(private val scope: CoroutineScope) {
val doneContent = buildJsonObject { put("transaction_id", txnId) }
onSend(toUser, toDevice, "m.key.verification.done", "${txnId}_done", doneContent)
hasVerifiedTheirMac = true
cleanup()
_state.value = VerificationState.Done
onVerified()
@ -280,6 +283,9 @@ class VerificationService(private val scope: CoroutineScope) {
private fun handleDone(txnId: String) {
if (txnId != currentTxnId) return
if (!hasVerifiedTheirMac) {
return
}
cleanup()
_state.value = VerificationState.Done
onVerified()
@ -330,6 +336,7 @@ class VerificationService(private val scope: CoroutineScope) {
pendingTheirMac = null
selectedMacMethod = null
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
@ -73,6 +74,7 @@ fun RoomListScreen(
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
@ -116,7 +118,11 @@ fun RoomListScreen(
if (actionError != null) {
Text(
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)
.padding(vertical = 10.dp, horizontal = 4.dp)
) {
Text(
text = "> ${room.name}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 15.sp, color = MatrixGreen)
)
Row(
modifier = Modifier.fillMaxWidth(),
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 = " ${room.id}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixBorder)