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 1bcd98e..0e2723a 100644 --- a/app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt @@ -2,6 +2,7 @@ package com.ltadeu6.matrix.matrix import android.content.Context import android.util.Base64 +import android.util.Log import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -41,8 +42,13 @@ class CryptoService( init { OlmManager() // loads the native libolm .so - olmAccount = restoreAccount() ?: OlmAccount().also { persistAccount(it) } + val restored = restoreAccount() + olmAccount = restored ?: OlmAccount().also { persistAccount(it) } + Log.i(TAG, "CryptoService init — restored=${restored != null} curve25519=${curve25519Key.take(8)}") restoreInboundSessions() + restoreOutboundSessions() + restoreOlmSessions() + Log.i(TAG, "CryptoService init — inbound=${inboundSessions.size} outbound=${outboundSessions.size} olm=${olmSessions.size}") } val curve25519Key: String @@ -101,23 +107,53 @@ class CryptoService( /** Call after a successful `/keys/upload` to mark one-time keys as consumed. */ fun onKeysUploaded() { + val justPublished = olmAccount.oneTimeKeys()["curve25519"]?.size ?: 0 olmAccount.markOneTimeKeysAsPublished() + publishedOtkCount += justPublished persistAccount(olmAccount) } + /** + * Builds a payload with only one-time keys (no device_keys). + * Use this for OTK replenishment so the server does not see device_keys as changed, + * which would trigger device_lists.changed events and break cross-signing trust. + */ + fun buildOtkOnlyUploadPayload(): JsonObject { + val needed = maxOf(0, MAX_ONE_TIME_KEYS - publishedOtkCount) + if (needed > 0) olmAccount.generateOneTimeKeys(needed) + val otks = olmAccount.oneTimeKeys() + val curve25519Otks: Map = otks["curve25519"] ?: emptyMap() + val oneTimeKeys = buildJsonObject { + curve25519Otks.forEach { (keyId, keyValue) -> + val keyObj = buildJsonObject { put("key", keyValue) } + val sig = olmAccount.signMessage(keyObj.toCanonicalJson()) + put("signed_curve25519:$keyId", buildJsonObject { + put("key", keyValue) + put("signatures", buildJsonObject { + put(userId, buildJsonObject { put("ed25519:$deviceId", sig) }) + }) + }) + } + } + return buildJsonObject { put("one_time_keys", oneTimeKeys) } + } + // ------------------------------------------------------------------------- // To-device event processing (receive incoming room keys) // ------------------------------------------------------------------------- - fun processToDeviceEvents(events: List) { + fun processToDeviceEvents(events: List): Set { + val newRooms = mutableSetOf() for (event in events) { - try { processToDeviceEvent(event) } catch (_: Exception) {} + try { processToDeviceEvent(event, newRooms) } catch (_: Exception) {} } + return newRooms } - private fun processToDeviceEvent(event: JsonObject) { + private fun processToDeviceEvent(event: JsonObject, newRooms: MutableSet) { val type = (event["type"] as? JsonPrimitive)?.content ?: return val content = event["content"]?.jsonObject ?: return + Log.d(TAG, "to-device type=$type") when (type) { "m.room.encrypted" -> { @@ -126,12 +162,18 @@ class CryptoService( val innerType = (inner["type"] as? JsonPrimitive)?.content val innerContent = inner["content"]?.jsonObject ?: return when (innerType) { - "m.room_key" -> handleRoomKey(innerContent) - "m.forwarded_room_key" -> handleForwardedRoomKey(innerContent) + "m.room_key" -> handleRoomKey(innerContent)?.let { + Log.i(TAG, "m.room_key stored — room=$it") + newRooms.add(it) + } + "m.forwarded_room_key" -> handleForwardedRoomKey(innerContent)?.let { + Log.i(TAG, "m.forwarded_room_key stored — room=$it") + newRooms.add(it) + } } } - "m.room_key" -> handleRoomKey(content) - "m.forwarded_room_key" -> handleForwardedRoomKey(content) + "m.room_key" -> handleRoomKey(content)?.let { newRooms.add(it) } + "m.forwarded_room_key" -> handleForwardedRoomKey(content)?.let { newRooms.add(it) } } } @@ -139,18 +181,24 @@ class CryptoService( if ((content["algorithm"] as? JsonPrimitive)?.content != "m.olm.v1.curve25519-aes-sha2") return null val senderKey = (content["sender_key"] as? JsonPrimitive)?.content ?: return null val ciphertext = content["ciphertext"]?.jsonObject ?: return null - val ourMsg = ciphertext[curve25519Key]?.jsonObject ?: return null + val myCurve = curve25519Key + Log.d(TAG, "olm decrypt — our curve25519=${myCurve.take(8)} ciphertext keys=${ciphertext.keys.map { it.take(8) }}") + val ourMsg = ciphertext[myCurve]?.jsonObject ?: run { + Log.e(TAG, "olm decrypt — our key ${myCurve.take(8)} NOT in ciphertext — key mismatch!") + return null + } val type = (ourMsg["type"] as? JsonPrimitive)?.content?.toIntOrNull() ?: return null val body = (ourMsg["body"] as? JsonPrimitive)?.content ?: return null // Try existing sessions first (type 1 — regular message) if (type == OlmMessage.MESSAGE_TYPE_MESSAGE) { olmSessions[senderKey]?.forEach { session -> - return try { + try { val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() } - session.decryptMessage(olmMsg) - } catch (_: Exception) { null } + return session.decryptMessage(olmMsg).also { persistOlmSessions(senderKey) } + } catch (_: Exception) { /* try next session */ } } + Log.w(TAG, "olm type-1 all sessions failed for ${senderKey.take(8)}") } // Pre-key message (type 0) — create a new inbound Olm session @@ -159,48 +207,77 @@ class CryptoService( val session = OlmSession() session.initInboundSessionFrom(olmAccount, senderKey, body) olmAccount.removeOneTimeKeys(session) + if (publishedOtkCount > 0) publishedOtkCount-- persistAccount(olmAccount) olmSessions.getOrPut(senderKey) { mutableListOf() }.add(session) + persistOlmSessions(senderKey) val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() } - session.decryptMessage(olmMsg) - } catch (_: Exception) { null } + session.decryptMessage(olmMsg).also { + Log.i(TAG, "olm PRE_KEY ok — new inbound session for ${senderKey.take(8)}") + persistOlmSessions(senderKey) + } + } catch (e: Exception) { + Log.e(TAG, "olm PRE_KEY failed for ${senderKey.take(8)}: $e") + null + } } return null } - private fun handleRoomKey(content: JsonObject) { - if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return - val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return - val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return - if (sessionId in inboundSessions) return - try { + /** Returns the room ID if a new session was stored, null otherwise. */ + private fun handleRoomKey(content: JsonObject): String? { + if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null + val roomId = (content["room_id"] as? JsonPrimitive)?.content ?: return null + val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null + val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return null + if (sessionId in inboundSessions) return null + return try { val session = OlmInboundGroupSession(sessionKey) inboundSessions[sessionId] = session persistInboundSession(sessionId, session) - } catch (_: Exception) {} + roomId + } catch (e: Exception) { + Log.e(TAG, "handleRoomKey — OlmInboundGroupSession create failed id=${sessionId.take(8)}: $e") + null + } } - /** - * Handles `m.forwarded_room_key` — a key shared by another of our own devices in response - * to an `m.room_key_request`. Uses [OlmInboundGroupSession.importSession] because the - * forwarded key is in exported (message-index-aware) format. - */ - private fun handleForwardedRoomKey(content: JsonObject) { - if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return - val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return - val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return - if (sessionId in inboundSessions) return - try { + /** Returns the room ID if a new session was stored, null otherwise. */ + private fun handleForwardedRoomKey(content: JsonObject): String? { + if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null + val roomId = (content["room_id"] as? JsonPrimitive)?.content ?: return null + val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null + val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return null + if (sessionId in inboundSessions) return null + return try { val session = OlmInboundGroupSession.importSession(sessionKey) inboundSessions[sessionId] = session persistInboundSession(sessionId, session) - } catch (_: Exception) {} + roomId + } catch (e: Exception) { + Log.e(TAG, "handleForwardedRoomKey — importSession failed id=${sessionId.take(8)}: $e") + null + } } // ------------------------------------------------------------------------- // Room message decryption / encryption // ------------------------------------------------------------------------- + /** Exports the inbound Megolm session at its first known index, for key forwarding. */ + fun exportInboundSession(sessionId: String): String? { + val session = inboundSessions[sessionId] ?: run { + Log.w(TAG, "exportInboundSession — session not found: ${sessionId.take(8)}") + return null + } + return try { + session.export(session.getFirstKnownIndex()) + } catch (e: Exception) { + Log.e(TAG, "exportInboundSession failed: $e") + null + } + } + 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 @@ -209,7 +286,10 @@ class CryptoService( return try { val result = session.decryptMessage(ciphertext) json.parseToJsonElement(result.mDecryptedMessage).jsonObject - } catch (_: Exception) { null } + } catch (e: Exception) { + Log.w(TAG, "megolm decrypt failed id=${sessionId.take(8)}: $e") + null + } } fun decryptRoomEvent(content: JsonObject): String? = @@ -227,6 +307,8 @@ class CryptoService( put("session_id", session.sessionIdentifier()) put("ciphertext", session.encryptMessage(plaintext)) put("device_id", deviceId) + }.also { + persistOutboundSession(roomId, session) } } catch (_: Exception) { null } } @@ -250,7 +332,10 @@ class CryptoService( } } Pair(sessionId, sessionKey) - } catch (_: Exception) { null } + } catch (e: Exception) { + Log.e(TAG, "initOutboundSession failed: $e") + null + } } /** @@ -283,15 +368,22 @@ class CryptoService( // ------------------------------------------------------------------------- private fun persistAccount(account: OlmAccount) { - val pickled = account.pickle(PICKLE_KEY, StringBuffer()) - prefs.edit().putString(KEY_ACCOUNT, Base64.encodeToString(pickled, Base64.NO_WRAP)).apply() + val pickled = account.pickle(PICKLE_KEY, StringBuffer()) as ByteArray + prefs.edit().putString(KEY_ACCOUNT, String(pickled, Charsets.ISO_8859_1)).apply() } private fun restoreAccount(): OlmAccount? { - val stored = prefs.getString(KEY_ACCOUNT, null) ?: return null + val stored = prefs.getString(KEY_ACCOUNT, null) ?: run { + Log.e(TAG, "restoreAccount — nothing in prefs (key=$KEY_ACCOUNT)") + return null + } return try { - OlmAccount().also { it.unpickle(PICKLE_KEY, Base64.decode(stored, Base64.NO_WRAP)) } - } catch (_: Exception) { null } + OlmAccount().also { it.unpickle(stored.toByteArray(Charsets.ISO_8859_1), PICKLE_KEY) } + .also { Log.i(TAG, "restoreAccount — unpickle ok") } + } catch (e: Exception) { + Log.e(TAG, "restoreAccount — unpickle FAILED: $e") + null + } } private fun persistInboundSession(sessionId: String, session: OlmInboundGroupSession) { @@ -314,15 +406,83 @@ class CryptoService( private fun persistOutboundSession(roomId: String, session: OlmOutboundGroupSession) { try { - val pickled = session.pickle(PICKLE_KEY, StringBuffer()) - prefs.edit().putString("outbound_$roomId", Base64.encodeToString(pickled, Base64.NO_WRAP)).apply() + val pickled = session.pickle(PICKLE_KEY, StringBuffer()) as ByteArray + prefs.edit().putString("outbound_$roomId", String(pickled, Charsets.ISO_8859_1)).apply() + val ids = prefs.getStringSet(KEY_OUTBOUND_IDS, emptySet())!!.toMutableSet() + ids.add(roomId) + prefs.edit().putStringSet(KEY_OUTBOUND_IDS, ids).apply() } catch (_: Exception) {} } + private fun restoreOutboundSessions() { + val ids = prefs.getStringSet(KEY_OUTBOUND_IDS, emptySet()) ?: return + for (roomId in ids) { + val stored = prefs.getString("outbound_$roomId", null) ?: continue + try { + outboundSessions[roomId] = OlmOutboundGroupSession().also { + it.unpickle(stored.toByteArray(Charsets.ISO_8859_1), PICKLE_KEY) + } + } catch (_: Exception) {} + } + } + + private fun persistOlmSessions(senderKey: String) { + val sessions = olmSessions[senderKey].orEmpty() + if (sessions.isEmpty()) return + try { + val pickles = sessions.mapNotNull { session -> + runCatching { + String(session.pickle(PICKLE_KEY, StringBuffer()) as ByteArray, Charsets.ISO_8859_1) + }.getOrNull() + }.toSet() + if (pickles.isEmpty()) return + val prefKey = olmSessionPrefKey(senderKey) + val senderKeys = prefs.getStringSet(KEY_OLM_SENDER_KEYS, emptySet())!!.toMutableSet() + senderKeys.add(senderKey) + prefs.edit() + .putStringSet(prefKey, pickles) + .putStringSet(KEY_OLM_SENDER_KEYS, senderKeys) + .apply() + } catch (_: Exception) {} + } + + private fun restoreOlmSessions() { + val senderKeys = prefs.getStringSet(KEY_OLM_SENDER_KEYS, emptySet()) ?: return + for (senderKey in senderKeys) { + val pickles = prefs.getStringSet(olmSessionPrefKey(senderKey), emptySet()).orEmpty() + val restored = pickles.mapNotNull { pickled -> + runCatching { + OlmSession().also { + it.unpickle(pickled.toByteArray(Charsets.ISO_8859_1), PICKLE_KEY) + } + }.getOrNull() + }.toMutableList() + if (restored.isNotEmpty()) { + olmSessions[senderKey] = restored + } + } + } + + private fun olmSessionPrefKey(senderKey: String): String { + val encoded = Base64.encodeToString( + senderKey.toByteArray(Charsets.UTF_8), + Base64.NO_WRAP or Base64.URL_SAFE + ) + return "olm_sessions_$encoded" + } + + private var publishedOtkCount: Int + get() = prefs.getInt(KEY_OTK_COUNT, 0) + set(v) { prefs.edit().putInt(KEY_OTK_COUNT, v).apply() } + companion object { - private const val MAX_ONE_TIME_KEYS = 10 + private const val TAG = "MatrixCrypto" + private const val MAX_ONE_TIME_KEYS = 50 private const val KEY_ACCOUNT = "olm_account" private const val KEY_INBOUND_IDS = "inbound_ids" + private const val KEY_OUTBOUND_IDS = "outbound_ids" + private const val KEY_OLM_SENDER_KEYS = "olm_sender_keys" + private const val KEY_OTK_COUNT = "published_otk_count" private val PICKLE_KEY = "matrix_olm_pickle".toByteArray() } } 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 e13b7a3..3651588 100644 --- a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixModels.kt +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixModels.kt @@ -183,3 +183,18 @@ data class Message( val isEdited: Boolean = false, val reactions: Map = emptyMap() ) + +data class OwnDevice( + val userId: String, + val deviceId: String, + val displayName: String, + val isCurrent: Boolean, + val isVerified: Boolean +) + +data class DeviceSyncState( + val isLoaded: Boolean = false, + val isCurrentDeviceVerified: Boolean = false, + val availableTargets: List = emptyList(), + val error: String? = null +) 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 0929bfc..359d0c9 100644 --- a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt @@ -21,6 +21,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put +import android.util.Log import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -29,6 +30,7 @@ import retrofit2.Retrofit import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger + sealed class AuthState { object LoggedOut : AuthState() object Loading : AuthState() @@ -57,6 +59,9 @@ class MatrixSession private constructor(private val appContext: Context) { private val _pendingSsoToken = MutableStateFlow(null) val pendingSsoToken: StateFlow = _pendingSsoToken.asStateFlow() + private val _deviceSyncState = MutableStateFlow(DeviceSyncState()) + val deviceSyncState: StateFlow = _deviceSyncState.asStateFlow() + private var accessToken: String? = null private var currentUserId: String? = null private var syncJob: Job? = null @@ -107,6 +112,7 @@ class MatrixSession private constructor(private val appContext: Context) { verificationService.onVerified = { requestedSessions.clear() sharedSessionRooms.clear() + scope.launch { refreshDeviceSyncState() } startSync() } @@ -114,6 +120,8 @@ class MatrixSession private constructor(private val appContext: Context) { val savedHomeserver = prefs.getString("homeserver_url", null) val savedUserId = prefs.getString("user_id", null) val savedDeviceId = prefs.getString("device_id", null) + encryptedRooms.addAll(prefs.getStringSet("encrypted_room_ids", emptySet()) ?: emptySet()) + if (savedToken != null && savedHomeserver != null && savedUserId != null) { accessToken = savedToken currentUserId = savedUserId @@ -125,7 +133,10 @@ class MatrixSession private constructor(private val appContext: Context) { verificationService.userId = savedUserId verificationService.deviceId = savedDeviceId verificationService.cryptoService = crypto - scope.launch { uploadKeys() } + scope.launch { + uploadOtks() + refreshDeviceSyncState() + } } startSync() } @@ -186,6 +197,7 @@ class MatrixSession private constructor(private val appContext: Context) { verificationService.userId = "" verificationService.deviceId = "" verificationService.cryptoService = null + _deviceSyncState.value = DeviceSyncState() _authState.value = AuthState.LoggedOut _syncState.value = SyncState() } @@ -301,6 +313,28 @@ class MatrixSession private constructor(private val appContext: Context) { } } + suspend fun startSelfVerification(): Result { + val token = accessToken ?: return Result.failure(Exception("Not logged in")) + val matrixApi = api ?: return Result.failure(Exception("No API client")) + return runCatching { + refreshDeviceSyncState(token, matrixApi) + val state = _deviceSyncState.value + if (state.isCurrentDeviceVerified) { + throw Exception("This device is already synced") + } + val target = pickPreferredVerificationTarget(state.availableTargets) + ?: throw Exception("No other device available for verification") + val started = verificationService.requestVerification( + toUser = target.userId, + toDevice = target.deviceId, + deviceLabel = target.displayName + ) + if (!started) { + throw Exception("Another verification is already in progress") + } + } + } + // ------------------------------------------------------------------------- // Login flow // ------------------------------------------------------------------------- @@ -320,8 +354,11 @@ class MatrixSession private constructor(private val appContext: Context) { verificationService.userId = response.userId verificationService.deviceId = response.deviceId verificationService.cryptoService = crypto - scope.launch { uploadKeys() } - startSync() + scope.launch { + uploadKeys() + refreshDeviceSyncState() + startSync() + } } // ------------------------------------------------------------------------- @@ -339,6 +376,17 @@ class MatrixSession private constructor(private val appContext: Context) { } } + private suspend fun uploadOtks() { + val crypto = cryptoService ?: return + val token = accessToken ?: return + val matrixApi = api ?: return + runCatching { + val payload = crypto.buildOtkOnlyUploadPayload() + matrixApi.uploadKeys("Bearer $token", payload) + crypto.onKeysUploaded() + } + } + private suspend fun sendTimelineEvent( roomId: String, eventType: String, @@ -393,6 +441,7 @@ class MatrixSession private constructor(private val appContext: Context) { val key = "${ms.roomId}:${ms.sessionId}" if (key in requestedSessions) continue requestedSessions.add(key) + Log.i(TAG, "sending key request — room=${ms.roomId.take(20)} session=${ms.sessionId.take(8)} senderKey=${ms.senderKey.take(8)}") val requestId = "${System.currentTimeMillis()}_${ms.sessionId.take(8)}" val body = buildJsonObject { @@ -431,6 +480,7 @@ class MatrixSession private constructor(private val appContext: Context) { val auth = "Bearer $token" val (sessionId, sessionKey) = crypto.initOutboundSession(roomId) ?: return + Log.i(TAG, "shareGroupSession — room=${roomId.take(20)} session=${sessionId.take(8)}") // 1. Joined members val members = runCatching { matrixApi.getJoinedMembers(auth, roomId) } @@ -523,6 +573,98 @@ class MatrixSession private constructor(private val appContext: Context) { runCatching { matrixApi.sendToDevice(auth, "m.room.encrypted", txnId, toDeviceBody) } } + // ------------------------------------------------------------------------- + // Room key request response (answer key requests from our own devices) + // ------------------------------------------------------------------------- + + /** + * Responds to `m.room_key_request` to-device events from our own devices. + * Exports the inbound Megolm session and sends `m.forwarded_room_key` back via a fresh + * Olm PRE_KEY channel so the requester can decrypt our messages. + */ + private suspend fun handleRoomKeyRequest(event: JsonObject, auth: String, matrixApi: MatrixApi) { + val content = event["content"]?.jsonObject ?: return + if ((content["action"] as? JsonPrimitive)?.content != "request") return + val sender = (event["sender"] as? JsonPrimitive)?.content ?: return + if (sender != currentUserId) return // only serve our own devices + + val requestingDeviceId = (content["requesting_device_id"] as? JsonPrimitive)?.content ?: return + val body = content["body"]?.jsonObject ?: return + if ((body["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return + val roomId = (body["room_id"] as? JsonPrimitive)?.content ?: return + val sessionId = (body["session_id"] as? JsonPrimitive)?.content ?: return + + val crypto = cryptoService ?: return + Log.i(TAG, "key request from device=$requestingDeviceId room=${roomId.take(20)} session=${sessionId.take(8)}") + val exportedKey = crypto.exportInboundSession(sessionId) ?: run { + Log.w(TAG, "key request — session ${sessionId.take(8)} not in inboundSessions, cannot respond") + return + } + + // Look up the requesting device's keys + val queryBody = buildJsonObject { + put("device_keys", buildJsonObject { + put(sender, JsonArray(listOf(JsonPrimitive(requestingDeviceId)))) + }) + } + val queryResp = runCatching { matrixApi.queryKeys(auth, queryBody) }.getOrNull() ?: return + val devInfo = queryResp["device_keys"]?.jsonObject + ?.get(sender)?.jsonObject + ?.get(requestingDeviceId)?.jsonObject ?: return + val keys = devInfo["keys"]?.jsonObject ?: return + val theirCurve25519 = (keys["curve25519:$requestingDeviceId"] as? JsonPrimitive)?.content ?: return + val theirEd25519 = (keys["ed25519:$requestingDeviceId"] as? JsonPrimitive)?.content ?: return + + // Claim an OTK so we establish a fresh Olm session (PRE_KEY) + val claimBody = buildJsonObject { + put("one_time_keys", buildJsonObject { + put(sender, buildJsonObject { put(requestingDeviceId, "signed_curve25519") }) + }) + } + val claimResp = runCatching { matrixApi.claimKeys(auth, claimBody) }.getOrNull() ?: return + val otkKey = claimResp["one_time_keys"]?.jsonObject + ?.get(sender)?.jsonObject + ?.get(requestingDeviceId)?.jsonObject + ?.values?.firstOrNull()?.jsonObject + ?.let { (it["key"] as? JsonPrimitive)?.content } ?: return + + val devId = prefs.getString("device_id", "") ?: return + val uid = currentUserId ?: return + val payload = buildJsonObject { + put("type", "m.forwarded_room_key") + put("content", buildJsonObject { + put("algorithm", "m.megolm.v1.aes-sha2") + put("room_id", roomId) + put("session_id", sessionId) + put("session_key", exportedKey) + put("sender_key", crypto.curve25519Key) + put("sender_claimed_ed25519_key", crypto.ed25519Key) + put("forwarding_curve25519_key_chain", JsonArray(emptyList())) + }) + put("sender", uid) + put("sender_device", devId) + put("keys", buildJsonObject { put("ed25519", crypto.ed25519Key) }) + put("recipient", sender) + put("recipient_keys", buildJsonObject { put("ed25519", theirEd25519) }) + }.toString() + + val ciphertext = crypto.encryptOlmPayload(payload, theirCurve25519, otkKey) ?: return + val encContent = buildJsonObject { + put("algorithm", "m.olm.v1.curve25519-aes-sha2") + put("sender_key", crypto.curve25519Key) + put("ciphertext", ciphertext) + } + val txnId = "${System.currentTimeMillis()}_keyreply_${sessionId.take(8)}" + val toDeviceBody = buildJsonObject { + put("messages", buildJsonObject { + put(sender, buildJsonObject { put(requestingDeviceId, encContent) }) + }) + } + runCatching { matrixApi.sendToDevice(auth, "m.room.encrypted", txnId, toDeviceBody) } + .onSuccess { Log.i(TAG, "forwarded_room_key sent to device=$requestingDeviceId session=${sessionId.take(8)}") } + .onFailure { Log.e(TAG, "failed to send forwarded_room_key: $it") } + } + // ------------------------------------------------------------------------- // Sync loop // ------------------------------------------------------------------------- @@ -545,26 +687,54 @@ class MatrixSession private constructor(private val appContext: Context) { since = response.nextBatch // Feed to-device events so incoming room keys are available before decryption + var newKeyRooms: Set = emptySet() response.toDevice?.events?.let { events -> - cryptoService?.processToDeviceEvents(events) + newKeyRooms = cryptoService?.processToDeviceEvents(events) ?: emptySet() events.forEach { event -> val type = (event["type"] as? JsonPrimitive)?.content ?: return@forEach - if (type.startsWith("m.key.verification.")) { - verificationService.processToDeviceEvent(event) + when { + type.startsWith("m.key.verification.") -> + verificationService.processToDeviceEvent(event) + type == "m.room_key_request" -> + scope.launch { handleRoomKeyRequest(event, "Bearer $token", matrixApi) } } } } val missing = processSyncResponse(response) + // Re-decrypt cached events for rooms where new keys just arrived + if (newKeyRooms.isNotEmpty()) { + val current = _syncState.value + val rooms = current.rooms.toMutableMap() + val messages = current.messages.toMutableMap() + val extraMissing = mutableListOf() + for (roomId in newKeyRooms) { + if (roomId in roomEventCache) { + rebuildRoomMessages(roomId, rooms, messages, extraMissing) + } + } + _syncState.value = SyncState(rooms = rooms, messages = messages) + if (extraMissing.isNotEmpty()) { + sendKeyRequests(extraMissing, "Bearer $token", matrixApi) + } + } + // Request keys from our other devices for any event we couldn't decrypt if (missing.isNotEmpty()) { sendKeyRequests(missing, "Bearer $token", matrixApi) } + // Replenish OTKs if the server reports running low (without re-uploading device keys) + val otkCount = response.deviceOneTimeKeysCount["signed_curve25519"] ?: -1 + if (otkCount in 0..9) { + scope.launch { uploadOtks() } + } + if (isFirstSync) { isFirstSync = false refreshDirectRoomTargets(token, matrixApi) + refreshDeviceSyncState(token, matrixApi) fetchMissingRoomNames(token, matrixApi) } } catch (e: CancellationException) { @@ -626,6 +796,75 @@ class MatrixSession private constructor(private val appContext: Context) { } } + private suspend fun refreshDeviceSyncState() { + val token = accessToken ?: return + val matrixApi = api ?: return + refreshDeviceSyncState(token, matrixApi) + } + + private suspend fun refreshDeviceSyncState(token: String, matrixApi: MatrixApi) { + val userId = currentUserId ?: return + val currentDeviceId = prefs.getString("device_id", null) ?: return + val request = buildJsonObject { + put("device_keys", buildJsonObject { + put(userId, JsonArray(emptyList())) + }) + } + val response = runCatching { matrixApi.queryKeys("Bearer $token", request) }.getOrNull() + if (response == null) { + _deviceSyncState.value = _deviceSyncState.value.copy( + isLoaded = true, + error = "failed to load device sync status" + ) + return + } + + val selfSigningKeyId = response["self_signing_keys"]?.jsonObject + ?.get(userId)?.jsonObject + ?.get("keys")?.jsonObject + ?.keys?.firstOrNull() + + val devicesJson = response["device_keys"]?.jsonObject + ?.get(userId)?.jsonObject + ?: JsonObject(emptyMap()) + + val devices = devicesJson.map { (deviceId, rawDevice) -> + val device = rawDevice.jsonObject + val signatures = device["signatures"]?.jsonObject?.get(userId)?.jsonObject + val isVerified = selfSigningKeyId != null && signatures?.containsKey(selfSigningKeyId) == true + val displayName = (device["unsigned"]?.jsonObject?.get("device_display_name") as? JsonPrimitive)?.content + ?.takeIf { it.isNotBlank() } + ?: deviceId + OwnDevice( + userId = userId, + deviceId = deviceId, + displayName = displayName, + isCurrent = deviceId == currentDeviceId, + isVerified = isVerified + ) + } + + _deviceSyncState.value = DeviceSyncState( + isLoaded = true, + isCurrentDeviceVerified = devices.firstOrNull { it.isCurrent }?.isVerified == true, + availableTargets = devices + .filterNot { it.isCurrent } + .sortedWith( + compareBy { + when { + it.displayName.contains("element", ignoreCase = true) -> 0 + else -> 1 + } + }.thenBy { it.displayName.lowercase() } + ), + error = null + ) + } + + private fun pickPreferredVerificationTarget(targets: List): OwnDevice? { + return targets.firstOrNull() + } + private fun prefersMemberName(roomId: String): Boolean { return directRoomTargets.containsKey(roomId) } @@ -658,9 +897,14 @@ class MatrixSession private constructor(private val appContext: Context) { } // Track encryption state - val roomIsEncrypted = (roomData.state?.events ?: emptyList()) - .any { it.type == "m.room.encryption" } - if (roomIsEncrypted) encryptedRooms.add(roomId) + val stateEvents = roomData.state?.events ?: emptyList() + val roomIsEncrypted = stateEvents.any { it.type == "m.room.encryption" } + || (roomData.timeline?.events ?: emptyList()).any { it.type == "m.room.encryption" } + if (roomIsEncrypted && encryptedRooms.add(roomId)) { + val saved = prefs.getStringSet("encrypted_room_ids", emptySet())!!.toMutableSet() + saved.add(roomId) + prefs.edit().putStringSet("encrypted_room_ids", saved).apply() + } roomPrevBatches[roomId] = roomData.timeline?.prevBatch ?: roomPrevBatches[roomId] rooms[roomId] = Room( id = roomId, @@ -919,6 +1163,7 @@ class MatrixSession private constructor(private val appContext: Context) { } companion object { + private const val TAG = "MatrixCrypto" @Volatile private var instance: MatrixSession? = null fun getInstance(context: Context): MatrixSession = 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 a6eccc1..31e736b 100644 --- a/app/src/main/java/com/ltadeu6/matrix/matrix/VerificationService.kt +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/VerificationService.kt @@ -20,6 +20,7 @@ data class SasEmoji(val emoji: String, val name: String) sealed class VerificationState { object Idle : VerificationState() + data class Requesting(val toUser: String, val toDevice: String, val txnId: String, val deviceLabel: String) : VerificationState() data class Requested(val fromUser: String, val fromDevice: String, val txnId: String) : VerificationState() data class ShowingEmojis(val txnId: String, val emojis: List) : VerificationState() object Done : VerificationState() @@ -62,6 +63,9 @@ class VerificationService(private val scope: CoroutineScope) { private var selectedMacMethod: String? = null private var hasConfirmedLocally: Boolean = false private var hasVerifiedTheirMac: Boolean = false + private var initiatedLocally: Boolean = false + private var sentStartCanonicalJson: String? = null + private var expectedCommitment: String? = null fun processToDeviceEvent(event: JsonObject) { val type = (event["type"] as? JsonPrimitive)?.content ?: return @@ -71,7 +75,9 @@ class VerificationService(private val scope: CoroutineScope) { when (type) { "m.key.verification.request" -> handleRequest(sender, txnId, content) + "m.key.verification.ready" -> handleReady(sender, txnId, content) "m.key.verification.start" -> handleStart(sender, txnId, content) + "m.key.verification.accept" -> handleAccept(txnId, content) "m.key.verification.key" -> handleKey(txnId, content) "m.key.verification.mac" -> handleMac(txnId, content) "m.key.verification.done" -> handleDone(txnId) @@ -79,9 +85,32 @@ class VerificationService(private val scope: CoroutineScope) { } } + fun requestVerification(toUser: String, toDevice: String, deviceLabel: String): Boolean { + if (_state.value !is VerificationState.Idle && _state.value !is VerificationState.Cancelled) return false + cleanup() + val txnId = "txn_${System.currentTimeMillis()}" + currentTxnId = txnId + theirUserId = toUser + theirDeviceId = toDevice + initiatedLocally = true + _state.value = VerificationState.Requesting(toUser, toDevice, txnId, deviceLabel) + + val content = buildJsonObject { + put("from_device", deviceId) + put("transaction_id", txnId) + put("methods", buildJsonArray { add(JsonPrimitive("m.sas.v1")) }) + put("timestamp", System.currentTimeMillis()) + } + scope.launch { + onSend(toUser, toDevice, "m.key.verification.request", "${txnId}_request", content) + } + return true + } + private fun handleRequest(sender: String, txnId: String, content: JsonObject) { if (_state.value !is VerificationState.Idle) return val fromDevice = (content["from_device"] as? JsonPrimitive)?.content ?: return + initiatedLocally = false theirUserId = sender theirDeviceId = fromDevice currentTxnId = txnId @@ -102,6 +131,43 @@ class VerificationService(private val scope: CoroutineScope) { } } + private fun handleReady(sender: String, txnId: String, content: JsonObject) { + val state = _state.value as? VerificationState.Requesting ?: return + if (!initiatedLocally || txnId != currentTxnId || sender != state.toUser) return + val fromDevice = (content["from_device"] as? JsonPrimitive)?.content ?: return + theirDeviceId = fromDevice + theirUserId = sender + + val sas = OlmSAS() + olmSas = sas + ourPublicKey = sas.publicKey + + val startContent = buildJsonObject { + put("from_device", deviceId) + put("transaction_id", txnId) + put("method", "m.sas.v1") + put("key_agreement_protocols", buildJsonArray { + add(JsonPrimitive("curve25519-hkdf-sha256")) + }) + put("hashes", buildJsonArray { + add(JsonPrimitive("sha256")) + }) + put("message_authentication_codes", buildJsonArray { + add(JsonPrimitive("hkdf-hmac-sha256.v2")) + add(JsonPrimitive("hkdf-hmac-sha256")) + }) + put("short_authentication_string", buildJsonArray { + add(JsonPrimitive("decimal")) + add(JsonPrimitive("emoji")) + }) + } + sentStartCanonicalJson = startContent.toCanonicalJson() + + scope.launch { + onSend(sender, fromDevice, "m.key.verification.start", "${txnId}_start", startContent) + } + } + private fun handleStart(sender: String, txnId: String, content: JsonObject) { if (txnId != currentTxnId) return val method = (content["method"] as? JsonPrimitive)?.content @@ -160,17 +226,65 @@ class VerificationService(private val scope: CoroutineScope) { } } + private fun handleAccept(txnId: String, content: JsonObject) { + if (!initiatedLocally || txnId != currentTxnId) return + val method = (content["method"] as? JsonPrimitive)?.content + if (method != "m.sas.v1") { + sendCancel(txnId, "m.unknown_method", "Unsupported method") + return + } + selectedMacMethod = (content["message_authentication_code"] as? JsonPrimitive)?.content + ?: run { + sendCancel(txnId, "m.unknown_method", "Missing MAC method") + return + } + expectedCommitment = (content["commitment"] as? JsonPrimitive)?.content + val toUser = theirUserId ?: return + val toDevice = theirDeviceId ?: return + val key = ourPublicKey ?: return + val keyContent = buildJsonObject { + put("transaction_id", txnId) + put("key", key) + } + scope.launch { + onSend(toUser, toDevice, "m.key.verification.key", "${txnId}_key", keyContent) + } + } + private fun handleKey(txnId: String, content: JsonObject) { if (txnId != currentTxnId) return val sas = olmSas ?: return val theirKey = (content["key"] as? JsonPrimitive)?.content ?: return + if (initiatedLocally) { + val commitment = expectedCommitment + val startCanonical = sentStartCanonicalJson + if (commitment != null && startCanonical != null) { + val sha256 = MessageDigest.getInstance("SHA-256") + val computed = Base64.encodeToString( + sha256.digest((theirKey + startCanonical).toByteArray(Charsets.UTF_8)), + Base64.NO_WRAP or Base64.NO_PADDING + ) + if (computed != commitment) { + sendCancel(txnId, "m.mismatched_commitment", "Commitment mismatch") + return + } + } + } + sas.setTheirPublicKey(theirKey) - val info = "MATRIX_KEY_VERIFICATION_SAS" + - "|${theirUserId}|${theirDeviceId}|${theirKey}" + - "|${userId}|${deviceId}|${ourPublicKey}" + - "|${txnId}" + val info = if (initiatedLocally) { + "MATRIX_KEY_VERIFICATION_SAS" + + "|${userId}|${deviceId}|${ourPublicKey}" + + "|${theirUserId}|${theirDeviceId}|${theirKey}" + + "|${txnId}" + } else { + "MATRIX_KEY_VERIFICATION_SAS" + + "|${theirUserId}|${theirDeviceId}|${theirKey}" + + "|${userId}|${deviceId}|${ourPublicKey}" + + "|${txnId}" + } val sasBytes = sas.generateShortCode(info, 6) val emojis = sasBytes.toSasEmojis() @@ -337,6 +451,9 @@ class VerificationService(private val scope: CoroutineScope) { selectedMacMethod = null hasConfirmedLocally = false hasVerifiedTheirMac = false + initiatedLocally = false + sentStartCanonicalJson = null + expectedCommitment = null } } 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 ad296de..41b0e22 100644 --- a/app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt +++ b/app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt @@ -56,6 +56,7 @@ fun RoomListScreen( val session = MatrixSession.getInstance(context) val syncState by session.syncState.collectAsState() val authState by session.authState.collectAsState() + val deviceSyncState by session.deviceSyncState.collectAsState() val verificationState by session.verificationService.state.collectAsState() val scope = rememberCoroutineScope() @@ -132,8 +133,64 @@ fun RoomListScreen( style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixBorder) ) + if (deviceSyncState.isLoaded && + !deviceSyncState.isCurrentDeviceVerified && + deviceSyncState.availableTargets.isNotEmpty() && + verificationState is VerificationState.Idle + ) { + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = " [!] device not synced yet", + style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreen) + ) + Text( + text = " accept it on ${deviceSyncState.availableTargets.first().displayName}", + style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixGreenDim) + ) + } + Button( + onClick = { + scope.launch { + session.startSelfVerification() + .onSuccess { + actionError = null + onOpenVerification() + } + .onFailure { actionError = it.message ?: "failed to start verification" } + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = MatrixGreen, + contentColor = MatrixBackground + ) + ) { + Text("[sync]", fontFamily = TerminalFont, fontSize = 12.sp) + } + } + } + // Verification banner when (val vs = verificationState) { + is VerificationState.Requesting -> { + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenVerification() }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = " [!] verification requested — open ${vs.deviceLabel}", + style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreen) + ) + } + } is VerificationState.Requested -> { Spacer(Modifier.height(8.dp)) Row( diff --git a/app/src/main/java/com/ltadeu6/matrix/verification/VerificationScreen.kt b/app/src/main/java/com/ltadeu6/matrix/verification/VerificationScreen.kt index 79215cd..2d2ad85 100644 --- a/app/src/main/java/com/ltadeu6/matrix/verification/VerificationScreen.kt +++ b/app/src/main/java/com/ltadeu6/matrix/verification/VerificationScreen.kt @@ -64,6 +64,29 @@ fun VerificationScreen(onDone: () -> Unit) { ) } + is VerificationState.Requesting -> { + Text( + text = " verification request sent to", + style = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreenDim) + ) + Spacer(Modifier.height(4.dp)) + Text( + text = " ${s.deviceLabel}", + style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen) + ) + Text( + text = " accept it there to continue", + style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder) + ) + Spacer(Modifier.height(24.dp)) + OutlinedButton( + onClick = { session.verificationService.cancel(); onDone() }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MatrixGreenDim) + ) { + Text("[cancel]", fontFamily = TerminalFont) + } + } + is VerificationState.Requested -> { Text( text = " verification request from", diff --git a/flake.nix b/flake.nix index 5191144..bd4eb95 100644 --- a/flake.nix +++ b/flake.nix @@ -13,15 +13,18 @@ inherit system; config.allowUnfree = true; config.android_sdk.accept_license = true; + config.permittedInsecurePackages = [ "olm-3.2.16" ]; }; androidComposition = pkgs.androidenv.composeAndroidPackages { platformVersions = [ "31" "35" ]; buildToolsVersions = [ "30.0.2" "34.0.0" "35.0.0" ]; - includeEmulator = false; + includeEmulator = true; includeNDK = true; includeSources = false; - includeSystemImages = false; + includeSystemImages = true; + systemImageTypes = [ "google_apis" ]; + abiVersions = [ "x86_64" ]; }; androidSdk = androidComposition.androidsdk; @@ -33,6 +36,7 @@ pkgs.gradle pkgs.jdk17 androidSdk + pkgs.matrix-commander ]; ANDROID_HOME = androidSdkRoot; @@ -50,10 +54,27 @@ sdk.dir=$ANDROID_SDK_ROOT EOF + # Launches the Pixel_3a_API_35 AVD. System images come from the nix SDK. + # The emulator window is shown by default. + start-avd() { + local avd_name="Pixel_3a_API_35" + local avd_dir="$HOME/.android/avd/$avd_name.avd" + if [ ! -d "$avd_dir" ]; then + echo "Creating AVD $avd_name..." + echo "no" | avdmanager create avd \ + -n "$avd_name" \ + -k "system-images;android-35;google_apis;x86_64" \ + -d "pixel_3a" --force + fi + emulator -avd "$avd_name" -no-snapshot-load "$@" & + echo "Emulator starting (PID $!) — run 'adb wait-for-device' to wait for boot" + } + echo "matrix-android dev shell" echo " JAVA_HOME=$JAVA_HOME" echo " ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" echo " Build with: gradle assembleDebug" + echo " Emulator: start-avd" ''; }; }); diff --git a/state.md b/state.md new file mode 100644 index 0000000..448e3a1 --- /dev/null +++ b/state.md @@ -0,0 +1,85 @@ +# State - matrix-android + +Current snapshot of the project as of 2026-05-17. + +## What is working + +- NixOS dev shell is available through `flake.nix`. +- Android builds work from the CLI with `nix develop -c gradle --no-daemon assembleDebug`. +- The app installs and launches over ADB on the test device without wiping user data. +- Login, room list, chat, send, and receive flows are in place. +- The UI uses the terminal theme and respects the status bar on the main screens. +- Direct message naming prefers the other member's display name. +- Local 16 KB-compatible `libolm` packaging is integrated. +- SAS device verification is implemented and can be initiated from the Android side. +- The app exposes a "sync this device" CTA on the home screen when the current device is unverified. +- **E2EE encryption survives app restarts** — messages can be sent and received encrypted after closing and reopening the app. + +## Encryption state + +Encryption works across restarts. All three flows verified: +- Messages from matrix-commander (CLI) → decrypted on Android after restart ✓ +- Messages from Android → appear encrypted in Element Web ✓ +- Messages from Element Web → decrypted on Android ✓ (after Element Web rotates its Megolm session with fresh OTKs) + +### Root causes fixed + +**1. OlmAccount pickle/unpickle argument order** +`unpickle(key, data)` was wrong — the Java API is `deserialize(data, key)`. Passing the key bytes as the first argument caused `INVALID_BASE64` since the key contains underscores (invalid base64). Fixed to `unpickle(data, key)` for all three Olm object types (`OlmAccount`, `OlmOutboundGroupSession`, `OlmSession`). + +**2. Pickle byte encoding** +`pickle()` returns `ByteArray`, not `StringBuffer`. The original cast `as StringBuffer` crashed or silently failed. Fixed to `String(pickled as ByteArray, Charsets.ISO_8859_1)` for persist and `stored.toByteArray(Charsets.ISO_8859_1)` for restore. + +**3. OTK eviction on restore** +`uploadOtks()` on session restore called `generateOneTimeKeys(50)` unconditionally. Since the Olm OTK ring buffer has a fixed max (50), this evicted all existing OTKs. Senders who had claimed old OTKs got `BAD_MESSAGE_KEY_ID`. Fixed by tracking `publishedOtkCount` in SharedPreferences and calling `generateOneTimeKeys(needed)` where `needed = MAX - publishedOtkCount`. This only fills the gap without evicting. + +**4. Login OTK upload race condition** +`startSync()` was called immediately in `onLoginSuccess`, before `uploadKeys()` completed. The first sync could report `otkCount=0` and trigger `uploadOtks()` concurrently with `uploadKeys()` — both calling `generateOneTimeKeys` on the non-thread-safe `OlmAccount`. Fixed by moving `startSync()` inside the upload coroutine for fresh login (so sync only starts after keys are uploaded). + +**5. `m.room.encryption` only checked in state events** +The server returns `m.room.encryption` in the **timeline** (not in `state`) for this room, because the encryption was enabled early in the room's history and the timeline window covers it. The check was `roomData.state?.events.any { it.type == "m.room.encryption" }` — always false. Fixed to also check timeline events. + +**6. `encryptedRooms` not persisted** +`encryptedRooms` was in-memory only, reset on every restart. After restart, the room appeared unencrypted and messages were sent as plaintext until the first sync populated it. Fixed by persisting to `matrix_session` SharedPreferences under key `encrypted_room_ids`, loaded on init before sync starts. + +### Remaining known issue + +Element Web (`e6CqkKos`) keeps sending `BAD_MESSAGE_KEY_ID` PRE_KEY messages. This is because it has cached OTKs from earlier broken sessions (before the fix) that were evicted from the account. Element Web will recover automatically when it rotates its outbound Megolm session (after ~100 messages or 7 days), at which point it re-claims fresh OTKs. New sessions (like matrix-commander) work immediately. + +## Architecture notes + +- `MatrixSession` (singleton) owns all runtime state. Always via `MatrixSession.getInstance(context)`. +- `CryptoService` holds and persists: `OlmAccount`, inbound Megolm sessions, outbound Megolm sessions, Olm 1:1 sessions. All in `olm_crypto` SharedPreferences. +- `uploadKeys()` — uploads device keys + OTKs. Called only on **fresh login**. Causes `device_lists.changed`. +- `uploadOtks()` — uploads only OTKs (no `device_keys`). Called on **session restore** and when sync reports OTK count ≤ 9. Does NOT cause `device_lists.changed`. Only generates `MAX - publishedOtkCount` new keys to avoid eviction. +- `encryptedRooms` — persisted in `matrix_session` prefs. Populated from both `state` and `timeline` events in sync. +- `logout()` wipes both `matrix_session` and `olm_crypto` prefs. + +## Test infrastructure + +- Android emulator: Pixel_3a_API_35 (emulator-5554), launched via `start-avd` from the nix dev shell. +- matrix-commander account: `@claudetestacc:matrix.org` (credentials in `.mc/credentials.json`, store in `.mc/store/`). +- Test room: `!rNfgFvyFMQEWIQnYVV:matrix.org` (shared between `@claudetestacc` and `@ltadeu616`). + +## Files to watch + +- `app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt` +- `app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt` +- `app/src/main/java/com/ltadeu6/matrix/matrix/VerificationService.kt` +- `app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt` +- `app/src/main/java/com/ltadeu6/matrix/verification/VerificationScreen.kt` + +## Build / deploy + +```bash +nix develop -c gradle --no-daemon assembleDebug +adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +`adb install -r` preserves user data. Use `logout()` in-app to wipe crypto state. + +To send a test message from matrix-commander: +```bash +nix develop -c matrix-commander --credentials .mc/credentials.json --store .mc/store \ + -m "test message" --room '!rNfgFvyFMQEWIQnYVV:matrix.org' +```