Fix E2EE encryption surviving app restarts

Six interconnected fixes to make Olm/Megolm state persist correctly
across restarts:

- Fix OlmAccount/OlmSession unpickle argument order (data, key) — was
  reversed, causing INVALID_BASE64 on every restore
- Fix pickle byte encoding: pickle() returns ByteArray, not StringBuffer;
  encode/decode with ISO_8859_1
- Track publishedOtkCount in SharedPreferences to avoid evicting existing
  OTKs on restore (only generate MAX - published new keys)
- Delay startSync() until after uploadKeys() on fresh login to avoid
  concurrent OlmAccount access (race condition causing BAD_MESSAGE_KEY_ID)
- Persist encryptedRooms to SharedPreferences so rooms stay encrypted
  across restarts
- Check m.room.encryption in both state and timeline events (server
  returns it in timeline for rooms where encryption was enabled early)

Also adds SAS device verification UI, DM naming improvements, and
updates state.md with full architecture and debugging notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ltadeu6 2026-05-17 15:21:09 -03:00
parent b30e83898c
commit 3747172488
8 changed files with 781 additions and 58 deletions

View file

@ -2,6 +2,7 @@ package com.ltadeu6.matrix.matrix
import android.content.Context import android.content.Context
import android.util.Base64 import android.util.Base64
import android.util.Log
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@ -41,8 +42,13 @@ class CryptoService(
init { init {
OlmManager() // loads the native libolm .so 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() restoreInboundSessions()
restoreOutboundSessions()
restoreOlmSessions()
Log.i(TAG, "CryptoService init — inbound=${inboundSessions.size} outbound=${outboundSessions.size} olm=${olmSessions.size}")
} }
val curve25519Key: String val curve25519Key: String
@ -101,23 +107,53 @@ class CryptoService(
/** Call after a successful `/keys/upload` to mark one-time keys as consumed. */ /** Call after a successful `/keys/upload` to mark one-time keys as consumed. */
fun onKeysUploaded() { fun onKeysUploaded() {
val justPublished = olmAccount.oneTimeKeys()["curve25519"]?.size ?: 0
olmAccount.markOneTimeKeysAsPublished() olmAccount.markOneTimeKeysAsPublished()
publishedOtkCount += justPublished
persistAccount(olmAccount) 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<String, String> = 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) // To-device event processing (receive incoming room keys)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
fun processToDeviceEvents(events: List<JsonObject>) { fun processToDeviceEvents(events: List<JsonObject>): Set<String> {
val newRooms = mutableSetOf<String>()
for (event in events) { 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<String>) {
val type = (event["type"] as? JsonPrimitive)?.content ?: return val type = (event["type"] as? JsonPrimitive)?.content ?: return
val content = event["content"]?.jsonObject ?: return val content = event["content"]?.jsonObject ?: return
Log.d(TAG, "to-device type=$type")
when (type) { when (type) {
"m.room.encrypted" -> { "m.room.encrypted" -> {
@ -126,12 +162,18 @@ class CryptoService(
val innerType = (inner["type"] as? JsonPrimitive)?.content val innerType = (inner["type"] as? JsonPrimitive)?.content
val innerContent = inner["content"]?.jsonObject ?: return val innerContent = inner["content"]?.jsonObject ?: return
when (innerType) { when (innerType) {
"m.room_key" -> handleRoomKey(innerContent) "m.room_key" -> handleRoomKey(innerContent)?.let {
"m.forwarded_room_key" -> handleForwardedRoomKey(innerContent) 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.room_key" -> handleRoomKey(content)?.let { newRooms.add(it) }
"m.forwarded_room_key" -> handleForwardedRoomKey(content) "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 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 senderKey = (content["sender_key"] as? JsonPrimitive)?.content ?: return null
val ciphertext = content["ciphertext"]?.jsonObject ?: 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 type = (ourMsg["type"] as? JsonPrimitive)?.content?.toIntOrNull() ?: return null
val body = (ourMsg["body"] as? JsonPrimitive)?.content ?: return null val body = (ourMsg["body"] as? JsonPrimitive)?.content ?: return null
// Try existing sessions first (type 1 — regular message) // Try existing sessions first (type 1 — regular message)
if (type == OlmMessage.MESSAGE_TYPE_MESSAGE) { if (type == OlmMessage.MESSAGE_TYPE_MESSAGE) {
olmSessions[senderKey]?.forEach { session -> olmSessions[senderKey]?.forEach { session ->
return try { try {
val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() } val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() }
session.decryptMessage(olmMsg) return session.decryptMessage(olmMsg).also { persistOlmSessions(senderKey) }
} catch (_: Exception) { null } } 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 // Pre-key message (type 0) — create a new inbound Olm session
@ -159,48 +207,77 @@ class CryptoService(
val session = OlmSession() val session = OlmSession()
session.initInboundSessionFrom(olmAccount, senderKey, body) session.initInboundSessionFrom(olmAccount, senderKey, body)
olmAccount.removeOneTimeKeys(session) olmAccount.removeOneTimeKeys(session)
if (publishedOtkCount > 0) publishedOtkCount--
persistAccount(olmAccount) persistAccount(olmAccount)
olmSessions.getOrPut(senderKey) { mutableListOf() }.add(session) olmSessions.getOrPut(senderKey) { mutableListOf() }.add(session)
persistOlmSessions(senderKey)
val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() } val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() }
session.decryptMessage(olmMsg) session.decryptMessage(olmMsg).also {
} catch (_: Exception) { null } 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 return null
} }
private fun handleRoomKey(content: JsonObject) { /** Returns the room ID if a new session was stored, null otherwise. */
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return private fun handleRoomKey(content: JsonObject): String? {
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return val roomId = (content["room_id"] as? JsonPrimitive)?.content ?: return null
if (sessionId in inboundSessions) return val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
try { val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return null
if (sessionId in inboundSessions) return null
return try {
val session = OlmInboundGroupSession(sessionKey) val session = OlmInboundGroupSession(sessionKey)
inboundSessions[sessionId] = session inboundSessions[sessionId] = session
persistInboundSession(sessionId, session) persistInboundSession(sessionId, session)
} catch (_: Exception) {} roomId
} catch (e: Exception) {
Log.e(TAG, "handleRoomKey — OlmInboundGroupSession create failed id=${sessionId.take(8)}: $e")
null
}
} }
/** /** Returns the room ID if a new session was stored, null otherwise. */
* Handles `m.forwarded_room_key` a key shared by another of our own devices in response private fun handleForwardedRoomKey(content: JsonObject): String? {
* to an `m.room_key_request`. Uses [OlmInboundGroupSession.importSession] because the if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
* forwarded key is in exported (message-index-aware) format. val roomId = (content["room_id"] as? JsonPrimitive)?.content ?: return null
*/ val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
private fun handleForwardedRoomKey(content: JsonObject) { val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return null
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return if (sessionId in inboundSessions) return null
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return return try {
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return
if (sessionId in inboundSessions) return
try {
val session = OlmInboundGroupSession.importSession(sessionKey) val session = OlmInboundGroupSession.importSession(sessionKey)
inboundSessions[sessionId] = session inboundSessions[sessionId] = session
persistInboundSession(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 // 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? { fun decryptRoomEventPayload(content: JsonObject): JsonObject? {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
@ -209,7 +286,10 @@ class CryptoService(
return try { return try {
val result = session.decryptMessage(ciphertext) val result = session.decryptMessage(ciphertext)
json.parseToJsonElement(result.mDecryptedMessage).jsonObject 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? = fun decryptRoomEvent(content: JsonObject): String? =
@ -227,6 +307,8 @@ class CryptoService(
put("session_id", session.sessionIdentifier()) put("session_id", session.sessionIdentifier())
put("ciphertext", session.encryptMessage(plaintext)) put("ciphertext", session.encryptMessage(plaintext))
put("device_id", deviceId) put("device_id", deviceId)
}.also {
persistOutboundSession(roomId, session)
} }
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
@ -250,7 +332,10 @@ class CryptoService(
} }
} }
Pair(sessionId, sessionKey) 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) { private fun persistAccount(account: OlmAccount) {
val pickled = account.pickle(PICKLE_KEY, StringBuffer()) val pickled = account.pickle(PICKLE_KEY, StringBuffer()) as ByteArray
prefs.edit().putString(KEY_ACCOUNT, Base64.encodeToString(pickled, Base64.NO_WRAP)).apply() prefs.edit().putString(KEY_ACCOUNT, String(pickled, Charsets.ISO_8859_1)).apply()
} }
private fun restoreAccount(): OlmAccount? { 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 { return try {
OlmAccount().also { it.unpickle(PICKLE_KEY, Base64.decode(stored, Base64.NO_WRAP)) } OlmAccount().also { it.unpickle(stored.toByteArray(Charsets.ISO_8859_1), PICKLE_KEY) }
} catch (_: Exception) { null } .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) { private fun persistInboundSession(sessionId: String, session: OlmInboundGroupSession) {
@ -314,15 +406,83 @@ class CryptoService(
private fun persistOutboundSession(roomId: String, session: OlmOutboundGroupSession) { private fun persistOutboundSession(roomId: String, session: OlmOutboundGroupSession) {
try { try {
val pickled = session.pickle(PICKLE_KEY, StringBuffer()) val pickled = session.pickle(PICKLE_KEY, StringBuffer()) as ByteArray
prefs.edit().putString("outbound_$roomId", Base64.encodeToString(pickled, Base64.NO_WRAP)).apply() 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) {} } 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 { 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_ACCOUNT = "olm_account"
private const val KEY_INBOUND_IDS = "inbound_ids" 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() private val PICKLE_KEY = "matrix_olm_pickle".toByteArray()
} }
} }

View file

@ -183,3 +183,18 @@ data class Message(
val isEdited: Boolean = false, val isEdited: Boolean = false,
val reactions: Map<String, Int> = emptyMap() val reactions: Map<String, Int> = 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<OwnDevice> = emptyList(),
val error: String? = null
)

View file

@ -21,6 +21,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import android.util.Log
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@ -29,6 +30,7 @@ import retrofit2.Retrofit
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
sealed class AuthState { sealed class AuthState {
object LoggedOut : AuthState() object LoggedOut : AuthState()
object Loading : AuthState() object Loading : AuthState()
@ -57,6 +59,9 @@ class MatrixSession private constructor(private val appContext: Context) {
private val _pendingSsoToken = MutableStateFlow<String?>(null) private val _pendingSsoToken = MutableStateFlow<String?>(null)
val pendingSsoToken: StateFlow<String?> = _pendingSsoToken.asStateFlow() val pendingSsoToken: StateFlow<String?> = _pendingSsoToken.asStateFlow()
private val _deviceSyncState = MutableStateFlow(DeviceSyncState())
val deviceSyncState: StateFlow<DeviceSyncState> = _deviceSyncState.asStateFlow()
private var accessToken: String? = null private var accessToken: String? = null
private var currentUserId: String? = null private var currentUserId: String? = null
private var syncJob: Job? = null private var syncJob: Job? = null
@ -107,6 +112,7 @@ class MatrixSession private constructor(private val appContext: Context) {
verificationService.onVerified = { verificationService.onVerified = {
requestedSessions.clear() requestedSessions.clear()
sharedSessionRooms.clear() sharedSessionRooms.clear()
scope.launch { refreshDeviceSyncState() }
startSync() startSync()
} }
@ -114,6 +120,8 @@ class MatrixSession private constructor(private val appContext: Context) {
val savedHomeserver = prefs.getString("homeserver_url", null) val savedHomeserver = prefs.getString("homeserver_url", null)
val savedUserId = prefs.getString("user_id", null) val savedUserId = prefs.getString("user_id", null)
val savedDeviceId = prefs.getString("device_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) { if (savedToken != null && savedHomeserver != null && savedUserId != null) {
accessToken = savedToken accessToken = savedToken
currentUserId = savedUserId currentUserId = savedUserId
@ -125,7 +133,10 @@ class MatrixSession private constructor(private val appContext: Context) {
verificationService.userId = savedUserId verificationService.userId = savedUserId
verificationService.deviceId = savedDeviceId verificationService.deviceId = savedDeviceId
verificationService.cryptoService = crypto verificationService.cryptoService = crypto
scope.launch { uploadKeys() } scope.launch {
uploadOtks()
refreshDeviceSyncState()
}
} }
startSync() startSync()
} }
@ -186,6 +197,7 @@ class MatrixSession private constructor(private val appContext: Context) {
verificationService.userId = "" verificationService.userId = ""
verificationService.deviceId = "" verificationService.deviceId = ""
verificationService.cryptoService = null verificationService.cryptoService = null
_deviceSyncState.value = DeviceSyncState()
_authState.value = AuthState.LoggedOut _authState.value = AuthState.LoggedOut
_syncState.value = SyncState() _syncState.value = SyncState()
} }
@ -301,6 +313,28 @@ class MatrixSession private constructor(private val appContext: Context) {
} }
} }
suspend fun startSelfVerification(): Result<Unit> {
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 // Login flow
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -320,8 +354,11 @@ class MatrixSession private constructor(private val appContext: Context) {
verificationService.userId = response.userId verificationService.userId = response.userId
verificationService.deviceId = response.deviceId verificationService.deviceId = response.deviceId
verificationService.cryptoService = crypto verificationService.cryptoService = crypto
scope.launch { uploadKeys() } scope.launch {
startSync() 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( private suspend fun sendTimelineEvent(
roomId: String, roomId: String,
eventType: String, eventType: String,
@ -393,6 +441,7 @@ class MatrixSession private constructor(private val appContext: Context) {
val key = "${ms.roomId}:${ms.sessionId}" val key = "${ms.roomId}:${ms.sessionId}"
if (key in requestedSessions) continue if (key in requestedSessions) continue
requestedSessions.add(key) 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 requestId = "${System.currentTimeMillis()}_${ms.sessionId.take(8)}"
val body = buildJsonObject { val body = buildJsonObject {
@ -431,6 +480,7 @@ class MatrixSession private constructor(private val appContext: Context) {
val auth = "Bearer $token" val auth = "Bearer $token"
val (sessionId, sessionKey) = crypto.initOutboundSession(roomId) ?: return val (sessionId, sessionKey) = crypto.initOutboundSession(roomId) ?: return
Log.i(TAG, "shareGroupSession — room=${roomId.take(20)} session=${sessionId.take(8)}")
// 1. Joined members // 1. Joined members
val members = runCatching { matrixApi.getJoinedMembers(auth, roomId) } 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) } 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 // Sync loop
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -545,26 +687,54 @@ class MatrixSession private constructor(private val appContext: Context) {
since = response.nextBatch since = response.nextBatch
// Feed to-device events so incoming room keys are available before decryption // Feed to-device events so incoming room keys are available before decryption
var newKeyRooms: Set<String> = emptySet()
response.toDevice?.events?.let { events -> response.toDevice?.events?.let { events ->
cryptoService?.processToDeviceEvents(events) newKeyRooms = cryptoService?.processToDeviceEvents(events) ?: emptySet()
events.forEach { event -> events.forEach { event ->
val type = (event["type"] as? JsonPrimitive)?.content ?: return@forEach val type = (event["type"] as? JsonPrimitive)?.content ?: return@forEach
if (type.startsWith("m.key.verification.")) { when {
verificationService.processToDeviceEvent(event) type.startsWith("m.key.verification.") ->
verificationService.processToDeviceEvent(event)
type == "m.room_key_request" ->
scope.launch { handleRoomKeyRequest(event, "Bearer $token", matrixApi) }
} }
} }
} }
val missing = processSyncResponse(response) 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<MissingSession>()
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 // Request keys from our other devices for any event we couldn't decrypt
if (missing.isNotEmpty()) { if (missing.isNotEmpty()) {
sendKeyRequests(missing, "Bearer $token", matrixApi) 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) { if (isFirstSync) {
isFirstSync = false isFirstSync = false
refreshDirectRoomTargets(token, matrixApi) refreshDirectRoomTargets(token, matrixApi)
refreshDeviceSyncState(token, matrixApi)
fetchMissingRoomNames(token, matrixApi) fetchMissingRoomNames(token, matrixApi)
} }
} catch (e: CancellationException) { } 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<OwnDevice> {
when {
it.displayName.contains("element", ignoreCase = true) -> 0
else -> 1
}
}.thenBy { it.displayName.lowercase() }
),
error = null
)
}
private fun pickPreferredVerificationTarget(targets: List<OwnDevice>): OwnDevice? {
return targets.firstOrNull()
}
private fun prefersMemberName(roomId: String): Boolean { private fun prefersMemberName(roomId: String): Boolean {
return directRoomTargets.containsKey(roomId) return directRoomTargets.containsKey(roomId)
} }
@ -658,9 +897,14 @@ class MatrixSession private constructor(private val appContext: Context) {
} }
// Track encryption state // Track encryption state
val roomIsEncrypted = (roomData.state?.events ?: emptyList()) val stateEvents = roomData.state?.events ?: emptyList()
.any { it.type == "m.room.encryption" } val roomIsEncrypted = stateEvents.any { it.type == "m.room.encryption" }
if (roomIsEncrypted) encryptedRooms.add(roomId) || (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] roomPrevBatches[roomId] = roomData.timeline?.prevBatch ?: roomPrevBatches[roomId]
rooms[roomId] = Room( rooms[roomId] = Room(
id = roomId, id = roomId,
@ -919,6 +1163,7 @@ class MatrixSession private constructor(private val appContext: Context) {
} }
companion object { companion object {
private const val TAG = "MatrixCrypto"
@Volatile private var instance: MatrixSession? = null @Volatile private var instance: MatrixSession? = null
fun getInstance(context: Context): MatrixSession = fun getInstance(context: Context): MatrixSession =

View file

@ -20,6 +20,7 @@ data class SasEmoji(val emoji: String, val name: String)
sealed class VerificationState { sealed class VerificationState {
object Idle : 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 Requested(val fromUser: String, val fromDevice: String, val txnId: String) : VerificationState()
data class ShowingEmojis(val txnId: String, val emojis: List<SasEmoji>) : VerificationState() data class ShowingEmojis(val txnId: String, val emojis: List<SasEmoji>) : VerificationState()
object Done : VerificationState() object Done : VerificationState()
@ -62,6 +63,9 @@ class VerificationService(private val scope: CoroutineScope) {
private var selectedMacMethod: String? = null private var selectedMacMethod: String? = null
private var hasConfirmedLocally: Boolean = false private var hasConfirmedLocally: Boolean = false
private var hasVerifiedTheirMac: Boolean = false private var hasVerifiedTheirMac: Boolean = false
private var initiatedLocally: Boolean = false
private var sentStartCanonicalJson: String? = null
private var expectedCommitment: String? = null
fun processToDeviceEvent(event: JsonObject) { fun processToDeviceEvent(event: JsonObject) {
val type = (event["type"] as? JsonPrimitive)?.content ?: return val type = (event["type"] as? JsonPrimitive)?.content ?: return
@ -71,7 +75,9 @@ class VerificationService(private val scope: CoroutineScope) {
when (type) { when (type) {
"m.key.verification.request" -> handleRequest(sender, txnId, content) "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.start" -> handleStart(sender, txnId, content)
"m.key.verification.accept" -> handleAccept(txnId, content)
"m.key.verification.key" -> handleKey(txnId, content) "m.key.verification.key" -> handleKey(txnId, content)
"m.key.verification.mac" -> handleMac(txnId, content) "m.key.verification.mac" -> handleMac(txnId, content)
"m.key.verification.done" -> handleDone(txnId) "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) { private fun handleRequest(sender: String, txnId: String, content: JsonObject) {
if (_state.value !is VerificationState.Idle) return if (_state.value !is VerificationState.Idle) return
val fromDevice = (content["from_device"] as? JsonPrimitive)?.content ?: return val fromDevice = (content["from_device"] as? JsonPrimitive)?.content ?: return
initiatedLocally = false
theirUserId = sender theirUserId = sender
theirDeviceId = fromDevice theirDeviceId = fromDevice
currentTxnId = txnId 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) { private fun handleStart(sender: String, txnId: String, content: JsonObject) {
if (txnId != currentTxnId) return if (txnId != currentTxnId) return
val method = (content["method"] as? JsonPrimitive)?.content 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) { private fun handleKey(txnId: String, content: JsonObject) {
if (txnId != currentTxnId) return if (txnId != currentTxnId) return
val sas = olmSas ?: return val sas = olmSas ?: return
val theirKey = (content["key"] as? JsonPrimitive)?.content ?: 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) sas.setTheirPublicKey(theirKey)
val info = "MATRIX_KEY_VERIFICATION_SAS" + val info = if (initiatedLocally) {
"|${theirUserId}|${theirDeviceId}|${theirKey}" + "MATRIX_KEY_VERIFICATION_SAS" +
"|${userId}|${deviceId}|${ourPublicKey}" + "|${userId}|${deviceId}|${ourPublicKey}" +
"|${txnId}" "|${theirUserId}|${theirDeviceId}|${theirKey}" +
"|${txnId}"
} else {
"MATRIX_KEY_VERIFICATION_SAS" +
"|${theirUserId}|${theirDeviceId}|${theirKey}" +
"|${userId}|${deviceId}|${ourPublicKey}" +
"|${txnId}"
}
val sasBytes = sas.generateShortCode(info, 6) val sasBytes = sas.generateShortCode(info, 6)
val emojis = sasBytes.toSasEmojis() val emojis = sasBytes.toSasEmojis()
@ -337,6 +451,9 @@ class VerificationService(private val scope: CoroutineScope) {
selectedMacMethod = null selectedMacMethod = null
hasConfirmedLocally = false hasConfirmedLocally = false
hasVerifiedTheirMac = false hasVerifiedTheirMac = false
initiatedLocally = false
sentStartCanonicalJson = null
expectedCommitment = null
} }
} }

View file

@ -56,6 +56,7 @@ fun RoomListScreen(
val session = MatrixSession.getInstance(context) val session = MatrixSession.getInstance(context)
val syncState by session.syncState.collectAsState() val syncState by session.syncState.collectAsState()
val authState by session.authState.collectAsState() val authState by session.authState.collectAsState()
val deviceSyncState by session.deviceSyncState.collectAsState()
val verificationState by session.verificationService.state.collectAsState() val verificationState by session.verificationService.state.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -132,8 +133,64 @@ fun RoomListScreen(
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixBorder) 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 // Verification banner
when (val vs = verificationState) { 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 -> { is VerificationState.Requested -> {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row( Row(

View file

@ -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 -> { is VerificationState.Requested -> {
Text( Text(
text = " verification request from", text = " verification request from",

View file

@ -13,15 +13,18 @@
inherit system; inherit system;
config.allowUnfree = true; config.allowUnfree = true;
config.android_sdk.accept_license = true; config.android_sdk.accept_license = true;
config.permittedInsecurePackages = [ "olm-3.2.16" ];
}; };
androidComposition = pkgs.androidenv.composeAndroidPackages { androidComposition = pkgs.androidenv.composeAndroidPackages {
platformVersions = [ "31" "35" ]; platformVersions = [ "31" "35" ];
buildToolsVersions = [ "30.0.2" "34.0.0" "35.0.0" ]; buildToolsVersions = [ "30.0.2" "34.0.0" "35.0.0" ];
includeEmulator = false; includeEmulator = true;
includeNDK = true; includeNDK = true;
includeSources = false; includeSources = false;
includeSystemImages = false; includeSystemImages = true;
systemImageTypes = [ "google_apis" ];
abiVersions = [ "x86_64" ];
}; };
androidSdk = androidComposition.androidsdk; androidSdk = androidComposition.androidsdk;
@ -33,6 +36,7 @@
pkgs.gradle pkgs.gradle
pkgs.jdk17 pkgs.jdk17
androidSdk androidSdk
pkgs.matrix-commander
]; ];
ANDROID_HOME = androidSdkRoot; ANDROID_HOME = androidSdkRoot;
@ -50,10 +54,27 @@
sdk.dir=$ANDROID_SDK_ROOT sdk.dir=$ANDROID_SDK_ROOT
EOF 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 "matrix-android dev shell"
echo " JAVA_HOME=$JAVA_HOME" echo " JAVA_HOME=$JAVA_HOME"
echo " ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" echo " ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
echo " Build with: gradle assembleDebug" echo " Build with: gradle assembleDebug"
echo " Emulator: start-avd"
''; '';
}; };
}); });

85
state.md Normal file
View file

@ -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'
```