diff --git a/AGENTS.md b/AGENTS.md index b38f016..4e0abcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=`. The app intercepts this URI via an `` 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`. +### 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 diff --git a/app/src/main/java/com/ltadeu6/matrix/auth/LoginScreen.kt b/app/src/main/java/com/ltadeu6/matrix/auth/LoginScreen.kt index 9728ffe..9230c77 100644 --- a/app/src/main/java/com/ltadeu6/matrix/auth/LoginScreen.kt +++ b/app/src/main/java/com/ltadeu6/matrix/auth/LoginScreen.kt @@ -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 diff --git a/app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt b/app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt index 373b3f9..5427683 100644 --- a/app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt +++ b/app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt @@ -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(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) + ) + } +} diff --git a/app/src/main/java/com/ltadeu6/matrix/chat/ChatViewModel.kt b/app/src/main/java/com/ltadeu6/matrix/chat/ChatViewModel.kt index 14156f1..e78d1d2 100644 --- a/app/src/main/java/com/ltadeu6/matrix/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ltadeu6/matrix/chat/ChatViewModel.kt @@ -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> = session.syncState + private val roomState: StateFlow = session.syncState + .map { it.rooms[roomId] } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val allMessages: StateFlow> = session.syncState .map { it.messages[roomId] ?: emptyList() } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - val roomName: StateFlow = session.syncState - .map { it.rooms[roomId]?.name ?: roomId } - .stateIn(viewModelScope, SharingStarted.Eagerly, roomId) - private val _inputText = MutableStateFlow("") val inputText: StateFlow = _inputText.asStateFlow() + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + private val _sendError = MutableStateFlow(null) + val sendError: StateFlow = _sendError.asStateFlow() + private val _loadingOlder = MutableStateFlow(false) + val loadingOlder: StateFlow = _loadingOlder.asStateFlow() + private val _editingEventId = MutableStateFlow(null) + val editingEventId: StateFlow = _editingEventId.asStateFlow() + + val roomName: StateFlow = roomState + .map { it?.name ?: roomId } + .stateIn(viewModelScope, SharingStarted.Eagerly, roomId) + + val isEncrypted: StateFlow = roomState + .map { it?.isEncrypted == true } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val canLoadMore: StateFlow = roomState + .map { it?.canLoadMore == true } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val messages: StateFlow> = 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" + } } } } diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt index c233648..1bcd98e 100644 --- a/app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt @@ -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 { diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt index 0188629..c7d9e9b 100644 --- a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt @@ -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, diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixModels.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixModels.kt index d4183a1..e13b7a3 100644 --- a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixModels.kt +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixModels.kt @@ -92,7 +92,8 @@ data class JoinedRoomSync( @Serializable data class Timeline( val events: List = 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 = 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 = emptyMap() ) diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt index 0650294..0929bfc 100644 --- a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt @@ -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>() + private val roomPrevBatches = mutableMapOf() + private val directRoomTargets = mutableMapOf() /** * 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 { + 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 { + 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 { + 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 { 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() + 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 { + 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() 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 { 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): 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) { + 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, + messages: MutableMap>, + missingSessions: MutableList + ) { + val cachedEvents = roomEventCache[roomId].orEmpty().sortedBy { it.timestamp } + val builtMessages = mutableListOf() + val messageIndexById = mutableMapOf() + val pendingEdits = mutableMapOf() + val pendingReactions = mutableMapOf>() + + 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 + ): 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 + } } // ------------------------------------------------------------------------- diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/VerificationService.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/VerificationService.kt index 67cf918..a6eccc1 100644 --- a/app/src/main/java/com/ltadeu6/matrix/matrix/VerificationService.kt +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/VerificationService.kt @@ -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 } } diff --git a/app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt b/app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt index fde50eb..ad296de 100644 --- a/app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt +++ b/app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt @@ -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)