From 3204bd66056baffbd7fc969db19a3cd67a8dccd3 Mon Sep 17 00:00:00 2001 From: filip Date: Thu, 12 Mar 2026 19:30:19 +0100 Subject: [PATCH] Start preview --- .../java/com/kecalek/chat/core/ChatClient.kt | 597 ++++++++++++++++-- .../java/com/kecalek/chat/core/KeyStorage.kt | 21 +- .../com/kecalek/chat/core/SessionManager.kt | 56 +- .../com/kecalek/chat/crypto/DoubleRatchet.kt | 18 +- .../java/com/kecalek/chat/crypto/RSACrypto.kt | 29 +- .../java/com/kecalek/chat/di/AppModule.kt | 51 +- .../com/kecalek/chat/ui/auth/AuthViewModel.kt | 13 +- .../com/kecalek/chat/ui/auth/LoginScreen.kt | 2 +- .../com/kecalek/chat/ui/auth/PairingScreen.kt | 2 +- .../kecalek/chat/ui/auth/RegisterScreen.kt | 25 +- .../com/kecalek/chat/ui/chat/ChatViewModel.kt | 567 ++++++++++++++++- .../ui/conversations/ConversationListVM.kt | 40 +- .../kecalek/chat/ui/navigation/NavGraph.kt | 45 +- 13 files changed, 1349 insertions(+), 117 deletions(-) diff --git a/app/src/main/java/com/kecalek/chat/core/ChatClient.kt b/app/src/main/java/com/kecalek/chat/core/ChatClient.kt index 9be8547..9f5a855 100644 --- a/app/src/main/java/com/kecalek/chat/core/ChatClient.kt +++ b/app/src/main/java/com/kecalek/chat/core/ChatClient.kt @@ -1,11 +1,23 @@ package com.kecalek.chat.core +import android.util.Log +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone import com.kecalek.chat.crypto.* import com.kecalek.chat.network.ConnectionManager import com.kecalek.chat.network.ServerApi import com.kecalek.chat.network.decodeBinary import com.kecalek.chat.network.encodeBinary import com.kecalek.chat.util.Constants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters @@ -39,18 +51,22 @@ class ChatClient @Inject constructor( private val notificationRouter: NotificationRouter, ) { + companion object { + private const val TAG = "ChatClient" + } + private var identityPrivate: Ed25519PrivateKeyParameters? = null private var identityPublic: Ed25519PublicKeyParameters? = null private var selfEncryptionKey: ByteArray? = null - // Session cache: (userId, deviceId) -> DoubleRatchet + // Session cache: "userId_deviceId" -> DoubleRatchet private val sessions = mutableMapOf() private val sessionMutex = Mutex() // Device bundle cache: userId -> (bundles, timestamp) private val bundleCache = mutableMapOf, Long>>() - // Sender key cache: (conversationId, userId) -> SenderKeyState + // Sender key cache: "conversationId_userId" -> SenderKeyState private val senderKeys = mutableMapOf() // TOFU registry: userId -> identityKeyBytes @@ -61,6 +77,19 @@ class ChatClient @Inject constructor( private val mutex = Mutex() + // Coroutine scope for notification handlers (need suspend functions) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + // Incoming decrypted messages flow — observed by ChatViewModel + private val _newMessageFlow = MutableSharedFlow(extraBufferCapacity = 64) + val newMessageFlow: SharedFlow = _newMessageFlow.asSharedFlow() + + // Conversation list update signal — observed by ConversationListVM. + // Emits a ConversationUpdateEvent whenever the conversation list might need refreshing + // (new message, conversation created, member added/removed, invitation, etc.). + private val _conversationUpdateFlow = MutableSharedFlow(extraBufferCapacity = 64) + val conversationUpdateFlow: SharedFlow = _conversationUpdateFlow.asSharedFlow() + /** * Initialize after login. Loads keys and sets up notification handlers. */ @@ -85,53 +114,98 @@ class ChatClient @Inject constructor( /** * Send an encrypted DM (direct message). * Encrypts per-device with Double Ratchet + self-encryption for multi-device. + * + * @param conversationId the conversation to send to + * @param plaintext the raw plaintext bytes (will be padded) + * @param memberUserIds user IDs of all conversation members (including self) + * @param replyTo optional message ID being replied to + * @param imageFileId optional image file attachment + * @return the message_id from the server */ suspend fun sendDm( conversationId: String, plaintext: ByteArray, + memberUserIds: List, replyTo: String? = null, imageFileId: String? = null, - ): String { + ): String = sessionMutex.withLock { val session = sessionManager.currentSession ?: throw IllegalStateException("Not logged in") + val myUserId = session.userId + val idPub = identityPublic + ?: throw IllegalStateException("Identity key not loaded") // Pad plaintext val padded = MessagePadding.pad(plaintext) - // Get device bundles for all recipients - val conversations = api.listConversations() - // For simplicity, we'll encrypt directly with known sessions - - // Get recipient user IDs from the conversation val recipientEntries = mutableListOf>() + var firstPeerHeader: Map? = null - // Get device bundles for all members - val convResp = api.getMessages(conversationId, limit = 0) + // Encrypt for each recipient's devices (excluding self) + for (memberId in memberUserIds) { + if (memberId == myUserId) continue - // Encrypt for each recipient device - val deviceBundles = getDeviceBundles(session.userId) // self bundles - // TODO: Get bundles for all conversation members and encrypt per-device + val bundles = getDeviceBundles(memberId) + if (bundles.isEmpty()) { + Log.w(TAG, "No device bundles for user $memberId, skipping") + continue + } + + for (bundle in bundles) { + try { + val sessionResult = loadOrCreateSessionWithHeader(memberId, bundle) + + // Encrypt with Double Ratchet + val ratchetMsg = sessionResult.ratchet.encrypt(padded) + + // Save updated session state + val sessionKey = "${memberId}_${bundle.deviceId}" + sessions[sessionKey] = sessionResult.ratchet + keyStorage.saveSession(memberId, bundle.deviceId, sessionResult.ratchet.exportState()) + + // Build recipient entry + val entry = mutableMapOf( + "user_id" to memberId, + "device_id" to bundle.deviceId, + "encrypted_content" to encodeBinary(ratchetMsg.ciphertext), + "nonce" to encodeBinary(ratchetMsg.nonce), + "ratchet_header" to ratchetMsg.header.toMap(), + ) + + // Attach X3DH header if this is a new session + if (sessionResult.x3dhHeader != null) { + entry["x3dh_header"] = sessionResult.x3dhHeader + } + + recipientEntries.add(entry) + + // Track first peer header for top-level ratchet_header + if (firstPeerHeader == null) { + firstPeerHeader = ratchetMsg.header.toMap() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to encrypt for device ${bundle.deviceId} of user $memberId", e) + // Continue with other devices — don't fail the entire send + } + } + } // Self-encryption for multi-device access val selfResult = encryptSelf(padded) recipientEntries.add(mapOf( - "user_id" to session.userId, + "user_id" to myUserId, "device_id" to Constants.SELF_DEVICE_ID, "encrypted_content" to encodeBinary(selfResult.ciphertext), "nonce" to encodeBinary(selfResult.nonce), + "ratchet_header" to mapOf("self" to true), )) - // Build ratchet header from first recipient encryption - // TODO: Use actual ratchet header from per-device encryption - val ratchetHeader = mapOf( - "dh_pub" to "TODO", - "n" to 0, - "pn" to 0, - ) + // Top-level ratchet_header: use first peer's header, or self marker if no peers + val topLevelHeader = firstPeerHeader ?: mapOf("self" to true) val resp = api.sendMessage( conversationId = conversationId, - ratchetHeader = ratchetHeader, + ratchetHeader = topLevelHeader, recipients = recipientEntries, imageFileId = imageFileId, ) @@ -142,51 +216,158 @@ class ChatClient @Inject constructor( /** * Send an encrypted group message using sender keys. + * + * Protocol (matches Python _send_group_message()): + * - All recipients get the SAME sender-key-encrypted ciphertext (no device_id per entry for peers) + * - Self-encrypted copy uses SELF_DEVICE_ID sentinel + * - Dummy ratchet header "00"*32 (server requires it but groups use sender keys) + * - sender_chain_id + sender_chain_n identify the sender key chain + * - If new sender key: distribute to all members first via per-device DM encryption */ suspend fun sendGroupMessage( conversationId: String, plaintext: ByteArray, memberIds: List, - ): String { + ): String = sessionMutex.withLock { val session = sessionManager.currentSession ?: throw IllegalStateException("Not logged in") val padded = MessagePadding.pad(plaintext) + // Detect if this is a new sender key (no existing state in memory or storage) + val cacheKey = "${conversationId}_${session.userId}" + val isNewKey = senderKeys[cacheKey] == null && + keyStorage.loadSenderKey(conversationId, session.userId) == null + // Get or create sender key for this conversation val senderKeyState = getOrCreateSenderKey(conversationId, session.userId) - // Encrypt with sender key (symmetric) - val skMessage = senderKeyState.encrypt(padded) + // If new sender key: distribute to all members first (before sending group msg) + if (isNewKey && memberIds.isNotEmpty()) { + distributeSenderKey(conversationId, senderKeyState, memberIds, session) + } - // Save updated state + // Encrypt with sender key (same ciphertext for ALL recipients) + val skMessage = senderKeyState.encrypt(padded) keyStorage.saveSenderKey(conversationId, session.userId, senderKeyState.exportState()) - // Distribute sender key to members who don't have it yet - // TODO: Track which members have received the sender key - - // Build recipients list with per-device encrypted sender key distribution + // Build recipients: same ciphertext per member user_id (no device_id for group peers) val recipientEntries = mutableListOf>() + for (memberId in memberIds) { + if (memberId == session.userId) continue + recipientEntries.add(mapOf( + "user_id" to memberId, + "encrypted_content" to encodeBinary(skMessage.ciphertext), + "nonce" to encodeBinary(skMessage.nonce), + )) + } - // Self-encryption + // Self-encrypted copy for reading own group messages val selfResult = encryptSelf(padded) recipientEntries.add(mapOf( "user_id" to session.userId, "device_id" to Constants.SELF_DEVICE_ID, "encrypted_content" to encodeBinary(selfResult.ciphertext), "nonce" to encodeBinary(selfResult.nonce), + "ratchet_header" to mapOf("self" to true), )) + // Dummy ratchet header (server requires it; groups use sender keys not Double Ratchet) + val dummyRatchetHeader = mapOf("dh_pub" to "00".repeat(32), "n" to 0, "pn" to 0) + val resp = api.sendMessage( conversationId = conversationId, - ratchetHeader = mapOf("dh_pub" to "group", "n" to 0, "pn" to 0), + ratchetHeader = dummyRatchetHeader, recipients = recipientEntries, senderChainId = encodeBinary(skMessage.chainIdHex.hexToBytes()), senderChainN = skMessage.n, ) if (!resp.isOk) throw Exception("Send failed: ${resp.errorMessage}") - return resp.data.getString("message_id") + return@withLock resp.data.getString("message_id") + } + + /** + * Distribute our sender key to all group members via per-device pairwise DM encryption. + * Called when creating a new sender key for a conversation. + * Matches Python's _distribute_sender_key() logic exactly. + * + * Sends a control message with _sender_key payload to each member's devices. + * Recipients import the key in handleNewMessage() and store it silently. + */ + private suspend fun distributeSenderKey( + conversationId: String, + senderKeyState: SenderKeyState, + memberIds: List, + session: SessionManager.Session, + ) { + val exportedKey = senderKeyState.exportKey() + val isoFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).also { + it.timeZone = TimeZone.getTimeZone("UTC") + } + + // Control message payload: sender key wrapped in _sender_key field + val controlPayload = JSONObject().apply { + put("sender", session.username) + put("text", "") + put("reply_to", JSONObject.NULL) + put("timestamp", isoFmt.format(Date())) + put("_sender_key", JSONObject().apply { + put("conv_id", conversationId) + put("key", encodeBinary(exportedKey)) + put("sender_device_id", session.deviceId) + }) + } + val padded = MessagePadding.pad(controlPayload.toString().toByteArray(Charsets.UTF_8)) + + for (memberId in memberIds) { + if (memberId == session.userId) continue + try { + val bundles = getDeviceBundles(memberId) + if (bundles.isEmpty()) continue + + val recipientEntries = mutableListOf>() + var firstPeerHeader: Map? = null + + for (bundle in bundles) { + val sessionResult = loadOrCreateSessionWithHeader(memberId, bundle) + val ratchetMsg = sessionResult.ratchet.encrypt(padded) + val sk = "${memberId}_${bundle.deviceId}" + sessions[sk] = sessionResult.ratchet + keyStorage.saveSession(memberId, bundle.deviceId, sessionResult.ratchet.exportState()) + + val entry = mutableMapOf( + "user_id" to memberId, + "device_id" to bundle.deviceId, + "encrypted_content" to encodeBinary(ratchetMsg.ciphertext), + "nonce" to encodeBinary(ratchetMsg.nonce), + "ratchet_header" to ratchetMsg.header.toMap(), + ) + if (sessionResult.x3dhHeader != null) entry["x3dh_header"] = sessionResult.x3dhHeader + recipientEntries.add(entry) + if (firstPeerHeader == null) firstPeerHeader = ratchetMsg.header.toMap() + } + + // Self-copy of the control message + val selfResult = encryptSelf(padded) + recipientEntries.add(mapOf( + "user_id" to session.userId, + "device_id" to Constants.SELF_DEVICE_ID, + "encrypted_content" to encodeBinary(selfResult.ciphertext), + "nonce" to encodeBinary(selfResult.nonce), + "ratchet_header" to mapOf("self" to true), + )) + + api.sendMessage( + conversationId = conversationId, + ratchetHeader = firstPeerHeader ?: mapOf("self" to true), + recipients = recipientEntries, + ) + Log.d(TAG, "Distributed sender key to $memberId for conv $conversationId") + } catch (e: Exception) { + Log.e(TAG, "Failed to distribute sender key to $memberId", e) + } + } } /** @@ -201,24 +382,45 @@ class ChatClient @Inject constructor( x3dhHeaderMap: Map? = null, ): ByteArray = sessionMutex.withLock { val sessionKey = "${senderId}_${senderDeviceId}" + val header = RatchetHeader.fromMap(ratchetHeaderMap) - // If X3DH header present, establish new session - if (x3dhHeaderMap != null) { - val ratchet = establishSessionFromX3DH(senderId, senderDeviceId, x3dhHeaderMap) - sessions[sessionKey] = ratchet + // 1) Try existing session first (in memory or storage) + var ratchet = sessions[sessionKey] + if (ratchet == null) { + val stored = keyStorage.loadSession(senderId, senderDeviceId) + if (stored != null) { + ratchet = DoubleRatchet.importState(stored) + sessions[sessionKey] = ratchet + } } - val ratchet = sessions[sessionKey] - ?: loadOrCreateSession(senderId, senderDeviceId) + if (ratchet != null) { + try { + val padded = ratchet.decrypt(header, encryptedContent, nonce) + keyStorage.saveSession(senderId, senderDeviceId, ratchet.exportState()) + return@withLock MessagePadding.unpad(padded) + } catch (e: Exception) { + if (x3dhHeaderMap == null) throw e + // Existing session failed — sender may have reset, try new X3DH below + Log.d(TAG, "Existing session failed for $sessionKey, trying X3DH fallback") + } + } - val header = RatchetHeader.fromMap(ratchetHeaderMap) - val padded = ratchet.decrypt(header, encryptedContent, nonce) + // 2) Establish new session from X3DH header (first-time or sender reset) + if (x3dhHeaderMap != null) { + val newRatchet = establishSessionFromX3DH(senderId, senderDeviceId, x3dhHeaderMap) + sessions[sessionKey] = newRatchet + val padded = newRatchet.decrypt(header, encryptedContent, nonce) + keyStorage.saveSession(senderId, senderDeviceId, newRatchet.exportState()) + return@withLock MessagePadding.unpad(padded) + } - // Save updated session state - keyStorage.saveSession(senderId, senderDeviceId, ratchet.exportState()) - sessions[sessionKey] = ratchet - - return MessagePadding.unpad(padded) + // 3) No session and no X3DH header — try to create via key bundle + val newRatchet = loadOrCreateSession(senderId, senderDeviceId) + sessions[sessionKey] = newRatchet + val padded = newRatchet.decrypt(header, encryptedContent, nonce) + keyStorage.saveSession(senderId, senderDeviceId, newRatchet.exportState()) + return@withLock MessagePadding.unpad(padded) } /** @@ -363,6 +565,64 @@ class ChatClient @Inject constructor( return bundles } + /** + * Load or create a session with X3DH header tracking. + * Used by sendDm() to get both the ratchet and the X3DH header (if new). + */ + private suspend fun loadOrCreateSessionWithHeader( + userId: String, + bundle: DeviceBundleInfo, + ): SessionWithHeader { + val sessionKey = "${userId}_${bundle.deviceId}" + + // Check in-memory cache + sessions[sessionKey]?.let { + return SessionWithHeader(it, x3dhHeader = null) + } + + // Try loading from storage + val stored = keyStorage.loadSession(userId, bundle.deviceId) + if (stored != null) { + val ratchet = DoubleRatchet.importState(stored) + sessions[sessionKey] = ratchet + return SessionWithHeader(ratchet, x3dhHeader = null) + } + + // Need to create via X3DH — this is a new session + val idPriv = identityPrivate ?: throw IllegalStateException("Identity key not loaded") + val idPub = identityPublic ?: throw IllegalStateException("Identity public key not loaded") + val remoteIdPub = Ed25519Crypto.loadPublic(bundle.identityKeyBytes) + val spkPub = X25519Crypto.loadPublic(bundle.spkPublicBytes) + val opkPub = bundle.opkPublicBytes?.let { X25519Crypto.loadPublic(it) } + + val x3dhResult = X3DH.initiate( + ikPrivateEd = idPriv, + ikPublicRemoteEd = remoteIdPub, + spkRemote = spkPub, + spkSignature = bundle.spkSignatureBytes, + opkRemote = opkPub, + ) + + val ratchet = DoubleRatchet.initAlice(x3dhResult.sharedSecret, spkPub) + sessions[sessionKey] = ratchet + keyStorage.saveSession(userId, bundle.deviceId, ratchet.exportState()) + + // Build X3DH header matching Python/iOS protocol: "ik", "ek", "opk_id" + val x3dhHeader = mutableMapOf( + "ik" to encodeBinary(Ed25519Crypto.serializePublic(idPub)), + "ek" to encodeBinary(X25519Crypto.serializePublic(x3dhResult.ephemeralPublic)), + ) + if (bundle.opkId != null) { + x3dhHeader["opk_id"] = bundle.opkId + } + + Log.d(TAG, "Created new X3DH session for user=$userId device=${bundle.deviceId}") + return SessionWithHeader(ratchet, x3dhHeader) + } + + /** + * Simple loadOrCreateSession (for receiver-side / decryptDm compatibility). + */ private suspend fun loadOrCreateSession( userId: String, deviceId: String, @@ -402,6 +662,10 @@ class ChatClient @Inject constructor( return ratchet } + /** + * Establish a session from an incoming X3DH header (receiver side). + * Field names match Python/iOS protocol: "ik", "ek", "opk_id". + */ private fun establishSessionFromX3DH( senderId: String, senderDeviceId: String, @@ -412,8 +676,9 @@ class ChatClient @Inject constructor( ?: keyStorage.loadSignedPreKey(isCurrent = false) ?: throw CryptoException.X3DHFailed("No SPK available") - val ekPubBytes = decodeBinary(x3dhHeader["ek_pub"] as String) - val ikPubBytes = decodeBinary(x3dhHeader["ik_pub"] as String) + // Protocol field names: "ek" and "ik" (matching Python/iOS) + val ekPubBytes = decodeBinary(x3dhHeader["ek"] as String) + val ikPubBytes = decodeBinary(x3dhHeader["ik"] as String) val opkId = x3dhHeader["opk_id"] as? String val remoteIdPub = Ed25519Crypto.loadPublic(ikPubBytes) @@ -449,6 +714,7 @@ class ChatClient @Inject constructor( ) keyStorage.saveSession(senderId, senderDeviceId, ratchet.exportState()) + Log.d(TAG, "Established X3DH session from incoming header: sender=$senderId device=$senderDeviceId") return ratchet } @@ -493,8 +759,7 @@ class ChatClient @Inject constructor( val existing = tofuRegistry[userId] if (existing != null && !existing.contentEquals(identityKeyBytes)) { // Identity key changed! This is a potential MITM attack. - // TODO: Emit warning event for UI to display - android.util.Log.w("ChatClient", "Identity key changed for user $userId!") + Log.w(TAG, "Identity key changed for user $userId!") } tofuRegistry[userId] = identityKeyBytes keyStorage.saveTofuRegistry(tofuRegistry) @@ -516,10 +781,15 @@ class ChatClient @Inject constructor( notificationRouter.route(json) } - // Handle new_message push + // Handle new_message push — decrypt and emit to newMessageFlow notificationRouter.on(NotificationRouter.NEW_MESSAGE) { data -> - // TODO: Decrypt message and update UI/DB - // This requires async handling - will be wired in Phase 3/4 + scope.launch { + try { + handleNewMessage(data) + } catch (e: Exception) { + Log.e(TAG, "Failed to handle new_message", e) + } + } } // Handle session_reset push @@ -531,9 +801,209 @@ class ChatClient @Inject constructor( sessions.remove(sessionKey) keyStorage.deleteSession(fromUserId, fromDeviceId) } + + // Conversation list update triggers — emit to conversationUpdateFlow so + // ConversationListVM can refresh the list in real-time. + notificationRouter.on(NotificationRouter.CONVERSATION_CREATED) { data -> + scope.launch { + val convId = data.optString("conversation_id", "") + _conversationUpdateFlow.emit(ConversationUpdateEvent(convId, "conversation_created")) + } + } + + notificationRouter.on(NotificationRouter.GROUP_INVITATION) { data -> + scope.launch { + val convId = data.optString("conversation_id", "") + _conversationUpdateFlow.emit(ConversationUpdateEvent(convId, "group_invitation")) + } + } + + notificationRouter.on(NotificationRouter.MEMBER_ADDED) { data -> + scope.launch { + val convId = data.optString("conversation_id", "") + _conversationUpdateFlow.emit(ConversationUpdateEvent(convId, "member_added")) + } + } + + notificationRouter.on(NotificationRouter.MEMBER_REMOVED) { data -> + scope.launch { + val convId = data.optString("conversation_id", "") + _conversationUpdateFlow.emit(ConversationUpdateEvent(convId, "member_removed")) + } + } + + notificationRouter.on(NotificationRouter.CONVERSATION_RENAMED) { data -> + scope.launch { + val convId = data.optString("conversation_id", "") + _conversationUpdateFlow.emit(ConversationUpdateEvent(convId, "conversation_renamed")) + } + } + + notificationRouter.on(NotificationRouter.MESSAGES_READ) { data -> + scope.launch { + val convId = data.optString("conversation_id", "") + _conversationUpdateFlow.emit(ConversationUpdateEvent(convId, "messages_read")) + } + } + } + + /** + * Handle an incoming new_message push notification. + * Decrypts the message and emits it on newMessageFlow. + * + * Supports both multi-device format (device_entries array) and legacy flat format. + * Matches Python decrypt_notification() logic. + */ + private suspend fun handleNewMessage(data: JSONObject) { + val messageId = data.getString("message_id") + val conversationId = data.getString("conversation_id") + val senderUserId = data.getString("sender_id") // Server sends "sender_id", not "sender_user_id" + val senderDeviceId = data.optString("sender_device_id", "") + + val session = sessionManager.currentSession ?: return + val myUserId = session.userId + val myDeviceId = session.deviceId + + // Extract per-device encrypted content from device_entries or flat fields + var encryptedContentB64: String + var nonceB64: String + var ratchetHeaderObj: JSONObject? + var x3dhHeaderObj: JSONObject? + + val deviceEntries = data.optJSONArray("device_entries") + if (deviceEntries != null && deviceEntries.length() > 0) { + // Multi-device format: pick entry matching our device_id or SELF_DEVICE_ID + var chosen: JSONObject? = null + var selfEntry: JSONObject? = null + + for (i in 0 until deviceEntries.length()) { + val entry = deviceEntries.getJSONObject(i) + val eid = entry.optString("device_id", "") + if (eid == myDeviceId) { + chosen = entry + break + } + if (eid == Constants.SELF_DEVICE_ID) { + selfEntry = entry + } + } + + // If sender is us, prefer self-encrypted entry + if (senderUserId == myUserId) { + chosen = selfEntry ?: chosen + } else if (chosen == null) { + chosen = selfEntry + } + + if (chosen == null) { + Log.w(TAG, "No matching device_entry for device $myDeviceId, skipping") + return + } + + encryptedContentB64 = chosen.optString("encrypted_content", "") + nonceB64 = chosen.optString("nonce", "") + ratchetHeaderObj = chosen.optJSONObject("ratchet_header") + ?: data.optJSONObject("ratchet_header") + x3dhHeaderObj = chosen.optJSONObject("x3dh_header") + ?: data.optJSONObject("x3dh_header") + } else { + // Legacy flat format (backward compat) + encryptedContentB64 = data.optString("encrypted_content", "") + nonceB64 = data.optString("nonce", "") + ratchetHeaderObj = data.optJSONObject("ratchet_header") + x3dhHeaderObj = data.optJSONObject("x3dh_header") + } + + if (encryptedContentB64.isEmpty() || nonceB64.isEmpty()) { + Log.w(TAG, "new_message missing encrypted_content or nonce, skipping") + return + } + + val encryptedContent = decodeBinary(encryptedContentB64) + val nonce = decodeBinary(nonceB64) + + val isSelfCopy = ratchetHeaderObj?.optBoolean("self", false) == true + // Group messages have sender_chain_id at the top-level push data + val senderChainIdB64 = data.optString("sender_chain_id", "") + val senderChainN = data.optInt("sender_chain_n", -1) + val isGroupMessage = senderChainIdB64.isNotEmpty() && senderChainN >= 0 && !isSelfCopy + + // Decrypt — priority: self-copy > group > DM + val decryptedBytes: ByteArray = if (isSelfCopy) { + decryptSelf(encryptedContent, nonce) + } else if (isGroupMessage) { + decryptGroup( + conversationId = conversationId, + senderId = senderUserId, + encryptedContent = encryptedContent, + nonce = nonce, + chainIdBase64 = senderChainIdB64, + chainN = senderChainN, + ) + } else { + // Build ratchet header map + val ratchetHeaderMap = mutableMapOf() + if (ratchetHeaderObj != null) { + ratchetHeaderObj.keys().forEach { key -> + ratchetHeaderMap[key] = ratchetHeaderObj.get(key) + } + } + + // Build x3dh header map if present + val x3dhHeaderMap = if (x3dhHeaderObj != null) { + val map = mutableMapOf() + x3dhHeaderObj.keys().forEach { key -> + map[key] = x3dhHeaderObj.get(key) + } + map + } else null + + val deviceId = senderDeviceId.ifEmpty { "default" } + decryptDm(senderUserId, deviceId, encryptedContent, nonce, ratchetHeaderMap, x3dhHeaderMap) + } + + // Parse the decrypted JSON payload + val payloadStr = String(decryptedBytes, Charsets.UTF_8) + val payload = JSONObject(payloadStr) + + // Check for sender key distribution control message + if (payload.has("_sender_key")) { + val skData = payload.getJSONObject("_sender_key") + val skConvId = skData.getString("conv_id") + val skKeyBytes = decodeBinary(skData.getString("key")) + importSenderKey(skConvId, senderUserId, skKeyBytes) + Log.d(TAG, "Imported sender key from $senderUserId for conversation $skConvId") + return // Control message — don't display + } + + // Emit decrypted message for UI consumption + val msg = DecryptedMessage( + messageId = messageId, + conversationId = conversationId, + senderId = senderUserId, + senderUsername = payload.optString("sender", "Unknown"), + text = payload.optString("text", null), + replyTo = if (payload.isNull("reply_to")) null else payload.optString("reply_to", null), + timestamp = payload.optString("timestamp", ""), + ) + + Log.d(TAG, "Decrypted message ${msg.messageId} in conversation ${msg.conversationId}") + _newMessageFlow.emit(msg) + + // Signal conversation list to update (new message affects unread count, last message time, etc.) + _conversationUpdateFlow.emit(ConversationUpdateEvent(msg.conversationId, "new_message")) } } +/** + * Wrapper returned by loadOrCreateSessionWithHeader — contains the ratchet + * and an optional X3DH header (non-null only on first message to a new device). + */ +private data class SessionWithHeader( + val ratchet: DoubleRatchet, + val x3dhHeader: Map?, +) + data class DeviceBundleInfo( val deviceId: String, val identityKeyBytes: ByteArray, @@ -551,6 +1021,27 @@ data class DeviceBundleInfo( override fun hashCode(): Int = deviceId.hashCode() } +/** + * Decrypted message emitted by ChatClient.newMessageFlow for UI consumption. + */ +data class DecryptedMessage( + val messageId: String, + val conversationId: String, + val senderId: String, + val senderUsername: String, + val text: String?, + val replyTo: String?, + val timestamp: String, +) + +/** + * Event emitted on conversationUpdateFlow when the conversation list may need refreshing. + */ +data class ConversationUpdateEvent( + val conversationId: String, + val type: String, +) + private data class SelfEncryptResult( val ciphertext: ByteArray, val nonce: ByteArray, diff --git a/app/src/main/java/com/kecalek/chat/core/KeyStorage.kt b/app/src/main/java/com/kecalek/chat/core/KeyStorage.kt index 016c94a..4e04075 100644 --- a/app/src/main/java/com/kecalek/chat/core/KeyStorage.kt +++ b/app/src/main/java/com/kecalek/chat/core/KeyStorage.kt @@ -151,7 +151,12 @@ class KeyStorage @Inject constructor( if (raw.size < 12) return null val nonce = raw.copyOfRange(0, 12) val ct = raw.copyOfRange(12, raw.size) - return AesGcmCrypto.decryptCombined(key, nonce, ct) + return try { + AesGcmCrypto.decryptCombined(key, nonce, ct) + } catch (e: Exception) { + android.util.Log.w("KeyStorage", "loadSession($userId/$deviceId): ${e.message} — stale file, treating as missing") + null + } } fun deleteSession(userId: String, deviceId: String) { @@ -177,7 +182,12 @@ class KeyStorage @Inject constructor( if (raw.size < 12) return null val nonce = raw.copyOfRange(0, 12) val ct = raw.copyOfRange(12, raw.size) - return AesGcmCrypto.decryptCombined(key, nonce, ct) + return try { + AesGcmCrypto.decryptCombined(key, nonce, ct) + } catch (e: Exception) { + android.util.Log.w("KeyStorage", "loadSenderKey($conversationId/$userId): ${e.message} — stale file, treating as missing") + null + } } // ===== TOFU Registry ===== @@ -225,7 +235,12 @@ class KeyStorage @Inject constructor( if (raw.size < 12) return null val nonce = raw.copyOfRange(0, 12) val ct = raw.copyOfRange(12, raw.size) - return AesGcmCrypto.decryptCombined(key, nonce, ct) + return try { + AesGcmCrypto.decryptCombined(key, nonce, ct) + } catch (e: Exception) { + android.util.Log.w("KeyStorage", "loadAndDecrypt($filename): ${e.message} — stale file, treating as missing") + null + } } private fun requireLocalKey(): ByteArray = diff --git a/app/src/main/java/com/kecalek/chat/core/SessionManager.kt b/app/src/main/java/com/kecalek/chat/core/SessionManager.kt index 560a9f2..40ec1bc 100644 --- a/app/src/main/java/com/kecalek/chat/core/SessionManager.kt +++ b/app/src/main/java/com/kecalek/chat/core/SessionManager.kt @@ -1,5 +1,6 @@ package com.kecalek.chat.core +import android.util.Log import com.kecalek.chat.crypto.RSACrypto import com.kecalek.chat.network.ConnectionManager import com.kecalek.chat.network.ProtocolHandler @@ -28,8 +29,13 @@ import javax.inject.Singleton class SessionManager @Inject constructor( private val connection: ConnectionManager, private val api: ServerApi, + private val keyStorage: KeyStorage, ) { + companion object { + private const val TAG = "SessionManager" + } + data class Session( val userId: String, val username: String, @@ -60,6 +66,12 @@ class SessionManager @Inject constructor( private var lastRsaPrivateKey: RSAPrivateKey? = null private var lastDeviceId: String? = null + // Connection params saved during register() so confirmRegistration() can + // reconnect if the TCP connection dropped while the user read their email. + private var lastRegistrationHost: String? = null + private var lastRegistrationPort: Int? = null + private var lastRegistrationUseTls: Boolean = true + init { // Re-authenticate automatically whenever the connection is (re)established. // During the initial login() call, lastEmail is null (cleared before connect), @@ -119,9 +131,12 @@ class SessionManager @Inject constructor( _authState.value = AuthState.Authenticated(session) return session } catch (e: AuthException) { + // Stop reconnect attempts — credentials aren't stored, reconnect would be useless + connection.disconnect() _authState.value = AuthState.Error(e.message ?: "Login failed") throw e } catch (e: Exception) { + connection.disconnect() _authState.value = AuthState.Error(e.message ?: "Connection failed") throw AuthException("Login failed: ${e.message}", e) } @@ -138,12 +153,29 @@ class SessionManager @Inject constructor( deviceName: String, ): Session { // Step 1: Request challenge + Log.d(TAG, "[AUTH] login_start email=$email") val startResp = api.loginStart(email) if (!startResp.isOk) throw AuthException(startResp.errorMessage) val challengeBytes = decodeBinary(startResp.data.getString("challenge")) + Log.d(TAG, "[AUTH] challenge received: ${challengeBytes.size} bytes") // Step 2: Sign challenge with RSA-PSS val signature = RSACrypto.sign(rsaPrivateKey, challengeBytes) + Log.d(TAG, "[AUTH] signature created: ${signature.size} bytes, " + + "privateKey modulus=${rsaPrivateKey.modulus.bitLength()} bits") + + // Debug: local self-verification to check key pair consistency + try { + val rsaPublic = keyStorage.loadRsaPublic() + val selfVerified = RSACrypto.verify(rsaPublic, signature, challengeBytes) + Log.d(TAG, "[AUTH] LOCAL self-verification: $selfVerified " + + "(publicKey modulus=${rsaPublic.modulus.bitLength()} bits)") + if (!selfVerified) { + Log.e(TAG, "[AUTH] ⚠ LOCAL self-verification FAILED — key pair mismatch!") + } + } catch (e: Exception) { + Log.e(TAG, "[AUTH] LOCAL self-verification error: ${e.message}") + } // Step 3: Complete login val finishResp = api.loginFinish( @@ -153,7 +185,10 @@ class SessionManager @Inject constructor( deviceId = deviceId, deviceName = deviceName, ) - if (!finishResp.isOk) throw AuthException(finishResp.errorMessage) + if (!finishResp.isOk) { + Log.e(TAG, "[AUTH] login_finish FAILED: ${finishResp.errorMessage}") + throw AuthException(finishResp.errorMessage) + } val data = finishResp.data return Session( @@ -177,6 +212,11 @@ class SessionManager @Inject constructor( port: Int, useTls: Boolean = false, ): String? { + // Save connection params for confirmRegistration() reconnect + lastRegistrationHost = host + lastRegistrationPort = port + lastRegistrationUseTls = useTls + if (connection.state.value != ConnectionManager.State.CONNECTED) { connection.connect(host, port, useTls) } @@ -192,8 +232,22 @@ class SessionManager @Inject constructor( /** * Confirm registration with email code. + * Reconnects automatically if the TCP connection dropped while the user read their email. */ suspend fun confirmRegistration(email: String, code: String): String { + // Reconnect if the connection dropped since register() (e.g. server timeout + // while the user was reading the email). Use DISCONNECTED check to avoid + // interfering with an ongoing reconnect attempt. + if (connection.state.value == ConnectionManager.State.DISCONNECTED) { + val h = lastRegistrationHost + val p = lastRegistrationPort + if (h != null && p != null) { + Log.d(TAG, "[CONFIRM] Reconnecting for register_confirm ($h:$p tls=$lastRegistrationUseTls)") + connection.connect(h, p, lastRegistrationUseTls) + } else { + throw AuthException("Connection lost. Please check your network and try again.") + } + } val resp = api.registerConfirm(email, code) if (!resp.isOk) { throw AuthException(resp.errorMessage) diff --git a/app/src/main/java/com/kecalek/chat/crypto/DoubleRatchet.kt b/app/src/main/java/com/kecalek/chat/crypto/DoubleRatchet.kt index 6aaa69e..671b977 100644 --- a/app/src/main/java/com/kecalek/chat/crypto/DoubleRatchet.kt +++ b/app/src/main/java/com/kecalek/chat/crypto/DoubleRatchet.kt @@ -329,11 +329,19 @@ data class RatchetHeader( val pn: Int, ) { fun serialize(): ByteArray { - val json = JSONObject() - json.put("dh_pub", dhPub.toHex()) - json.put("n", n) - json.put("pn", pn) - return json.toString().toByteArray() + // MUST produce exactly the same bytes as Python's json.dumps({"dh_pub":…, "n":…, "pn":…}) + // Python default separators: ", " and ": " → {"dh_pub": "hex", "n": 0, "pn": 0} + // Kotlin JSONObject.toString() omits spaces → {"dh_pub":"hex","n":0,"pn":0} ← WRONG + // Manual construction guarantees byte-exact AAD match with Python/iOS. + return buildString { + append("{\"dh_pub\": \"") + append(dhPub.toHex()) + append("\", \"n\": ") + append(n) + append(", \"pn\": ") + append(pn) + append("}") + }.toByteArray() } fun toMap(): Map = mapOf( diff --git a/app/src/main/java/com/kecalek/chat/crypto/RSACrypto.kt b/app/src/main/java/com/kecalek/chat/crypto/RSACrypto.kt index bd83d6f..555eb8b 100644 --- a/app/src/main/java/com/kecalek/chat/crypto/RSACrypto.kt +++ b/app/src/main/java/com/kecalek/chat/crypto/RSACrypto.kt @@ -1,5 +1,6 @@ package com.kecalek.chat.crypto +import android.util.Log import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.Signature @@ -12,16 +13,19 @@ import java.security.spec.X509EncodedKeySpec /** * RSA-4096 for login challenge-response only. - * Uses RSA-PSS with SHA-256, MGF1-SHA256. + * Uses RSA-PSS with SHA-256, MGF1-SHA256, salt_length = hash_length (32). * * Private key storage: DER PKCS8 raw bytes encrypted via ECP1. * Public key: DER SubjectPublicKeyInfo (X.509). * - * Compatible with Python generate_rsa_keypair, rsa_sign, rsa_verify. - * Sign uses PSS with salt_length=MAX. Verify accepts MAX or hash-length salt. + * Compatible with Python rsa_sign/rsa_verify and iOS SecKeyCreateSignature. + * Sign uses PSS with salt_length=32 (SHA-256 hash length). + * Server verifies with PSS.AUTO which accepts any valid salt length. + * Verify accepts both max-salt (Python) and hash-length salt (iOS/Android). */ object RSACrypto { + private const val TAG = "RSACrypto" private const val KEY_SIZE = 4096 /** @@ -76,20 +80,25 @@ object RSACrypto { } /** - * Sign data with RSA-PSS (SHA-256, MGF1-SHA256, max salt length). - * Compatible with Python rsa_sign. + * Sign data with RSA-PSS (SHA-256, MGF1-SHA256, salt_length=32). + * Matches iOS SecKeyCreateSignature(.rsaSignatureMessagePSSSHA256). + * Server verifies with PSS.AUTO which accepts any valid salt length. */ fun sign(privateKey: RSAPrivateKey, data: ByteArray): ByteArray { - // Max salt length = key size in bytes - hash size - 2 - val maxSaltLen = privateKey.modulus.bitLength() / 8 - 32 - 2 + // Use hash-length salt (32 bytes for SHA-256) — same as iOS. + // Server's PSS.AUTO accepts both hash-length and max-length salt. + val hashSaltLen = 32 val pssSpec = PSSParameterSpec( "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, - maxSaltLen, + hashSaltLen, 1, // trailer field ) - val sig = Signature.getInstance("RSASSA-PSS") + // Explicitly use BouncyCastle — Conscrypt may handle PSS differently + val sig = Signature.getInstance("RSASSA-PSS", "BC") + Log.d(TAG, "[SIGN] provider=${sig.provider.name} (${sig.provider.version}), " + + "saltLen=$hashSaltLen, keyBits=${privateKey.modulus.bitLength()}") sig.setParameter(pssSpec) sig.initSign(privateKey) sig.update(data) @@ -124,7 +133,7 @@ object RSACrypto { saltLen, 1, ) - val sig = Signature.getInstance("RSASSA-PSS") + val sig = Signature.getInstance("RSASSA-PSS", "BC") sig.setParameter(pssSpec) sig.initVerify(publicKey) sig.update(data) diff --git a/app/src/main/java/com/kecalek/chat/di/AppModule.kt b/app/src/main/java/com/kecalek/chat/di/AppModule.kt index 8657cac..5322f45 100644 --- a/app/src/main/java/com/kecalek/chat/di/AppModule.kt +++ b/app/src/main/java/com/kecalek/chat/di/AppModule.kt @@ -1,7 +1,11 @@ package com.kecalek.chat.di import android.content.Context +import android.util.Base64 +import android.util.Log import androidx.room.Room +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey import com.kecalek.chat.data.local.AppDatabase import dagger.Module import dagger.Provides @@ -9,6 +13,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import java.security.SecureRandom import javax.inject.Singleton @Module @@ -20,10 +25,8 @@ object AppModule { fun provideDatabase( @ApplicationContext context: Context, ): AppDatabase { - // TODO: Get database passphrase from secure storage - // For now, use a placeholder. In production, derive from identity key. // Note: System.loadLibrary("sqlcipher") is called in KecalekApp.onCreate() - val passphrase = "TODO_REPLACE_WITH_DERIVED_KEY".toByteArray() + val passphrase = getOrCreateDbPassphrase(context) val factory = SupportOpenHelperFactory(passphrase) return Room.databaseBuilder( @@ -35,4 +38,46 @@ object AppModule { .fallbackToDestructiveMigration() .build() } + + /** + * Returns a stable, device-specific 32-byte passphrase for the SQLCipher database. + * Generated once and stored in EncryptedSharedPreferences (backed by Android Keystore). + * This ensures the DB is encrypted at rest and the key survives app restarts. + * + * Migration note: If no passphrase is stored yet but an old DB file exists + * (created with the hardcoded dev passphrase), the old DB is deleted so the + * new random passphrase can create a fresh one without a decryption error. + * Cached messages will be re-fetched from the server on next launch. + */ + private fun getOrCreateDbPassphrase(context: Context): ByteArray { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + val prefs = EncryptedSharedPreferences.create( + context, + "kecalek_db_key", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + val existing = prefs.getString("passphrase", null) + if (existing != null) { + return Base64.decode(existing, Base64.NO_WRAP) + } + + // First run with the secure passphrase system. + // If an old DB exists (created with a hardcoded dev passphrase), delete it + // to avoid "file is not a database" SQLCipher error when opening with the new key. + val dbFile = context.getDatabasePath("kecalek_chat.db") + if (dbFile.exists()) { + context.deleteDatabase("kecalek_chat.db") + Log.w("AppModule", "Deleted old DB (passphrase migration — messages will reload from server)") + } + + val newKey = ByteArray(32).also { SecureRandom().nextBytes(it) } + prefs.edit().putString("passphrase", Base64.encodeToString(newKey, Base64.NO_WRAP)).apply() + return newKey + } } diff --git a/app/src/main/java/com/kecalek/chat/ui/auth/AuthViewModel.kt b/app/src/main/java/com/kecalek/chat/ui/auth/AuthViewModel.kt index 7875e5d..3208fe8 100644 --- a/app/src/main/java/com/kecalek/chat/ui/auth/AuthViewModel.kt +++ b/app/src/main/java/com/kecalek/chat/ui/auth/AuthViewModel.kt @@ -43,6 +43,7 @@ data class AuthUiState( class AuthViewModel @Inject constructor( private val sessionManager: SessionManager, private val keyStorage: KeyStorage, + private val chatClient: com.kecalek.chat.core.ChatClient, ) : ViewModel() { private val _uiState = MutableStateFlow(AuthUiState()) @@ -99,12 +100,14 @@ class AuthViewModel @Inject constructor( useTls = state.useTls, ) - // Init local DB encryption key from identity key (also PBKDF2, off main thread) + // Initialize ChatClient: loads identity keys, derives encryption keys, + // loads TOFU registry, ensures prekeys, sets up notification handlers. + // Must be called with password (identity key is ECP1-encrypted). + _uiState.update { it.copy(loadingMessage = "Inicializuji šifrování…") } if (keyStorage.hasIdentityKeys()) { - val identityPrivate = withContext(Dispatchers.Default) { - keyStorage.loadIdentityPrivate(password) + withContext(Dispatchers.Default) { + chatClient.initialize(password) } - keyStorage.initLocalKey(Ed25519Crypto.serializePrivate(identityPrivate)) } _uiState.update { it.copy(isLoading = false, loadingMessage = null, isLoggedIn = true) } @@ -261,7 +264,7 @@ class AuthViewModel @Inject constructor( val der = key.encoded val base64 = Base64.encodeToString(der, Base64.NO_WRAP) val lines = base64.chunked(64).joinToString("\n") - return "-----BEGIN PUBLIC KEY-----\n$lines\n-----END PUBLIC KEY-----" + return "-----BEGIN PUBLIC KEY-----\n$lines\n-----END PUBLIC KEY-----\n" } } } diff --git a/app/src/main/java/com/kecalek/chat/ui/auth/LoginScreen.kt b/app/src/main/java/com/kecalek/chat/ui/auth/LoginScreen.kt index eae118c..2b33349 100644 --- a/app/src/main/java/com/kecalek/chat/ui/auth/LoginScreen.kt +++ b/app/src/main/java/com/kecalek/chat/ui/auth/LoginScreen.kt @@ -78,7 +78,7 @@ fun LoginScreen( LaunchedEffect(uiState.isLoggedIn) { if (uiState.isLoggedIn) { navController.navigate(Routes.CONVERSATION_LIST) { - popUpTo(Routes.LOGIN) { inclusive = true } + popUpTo(Routes.AUTH_GRAPH) { inclusive = true } } } } diff --git a/app/src/main/java/com/kecalek/chat/ui/auth/PairingScreen.kt b/app/src/main/java/com/kecalek/chat/ui/auth/PairingScreen.kt index ae71801..aad5fcd 100644 --- a/app/src/main/java/com/kecalek/chat/ui/auth/PairingScreen.kt +++ b/app/src/main/java/com/kecalek/chat/ui/auth/PairingScreen.kt @@ -61,7 +61,7 @@ fun PairingScreen( LaunchedEffect(uiState.isLoggedIn) { if (uiState.isLoggedIn) { navController.navigate(Routes.CONVERSATION_LIST) { - popUpTo(Routes.LOGIN) { inclusive = true } + popUpTo(Routes.AUTH_GRAPH) { inclusive = true } } } } diff --git a/app/src/main/java/com/kecalek/chat/ui/auth/RegisterScreen.kt b/app/src/main/java/com/kecalek/chat/ui/auth/RegisterScreen.kt index 883047d..e77cc2f 100644 --- a/app/src/main/java/com/kecalek/chat/ui/auth/RegisterScreen.kt +++ b/app/src/main/java/com/kecalek/chat/ui/auth/RegisterScreen.kt @@ -1,5 +1,6 @@ package com.kecalek.chat.ui.auth +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -43,6 +44,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -58,6 +60,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import com.kecalek.chat.ui.navigation.Routes import com.kecalek.chat.ui.theme.CatppuccinMocha +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -67,6 +70,8 @@ fun RegisterScreen( ) { val uiState by viewModel.uiState.collectAsState() val focusManager = LocalFocusManager.current + val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() var username by rememberSaveable { mutableStateOf("") } var email by rememberSaveable { mutableStateOf("") } @@ -80,11 +85,27 @@ fun RegisterScreen( LaunchedEffect(uiState.isLoggedIn) { if (uiState.isLoggedIn) { navController.navigate(Routes.CONVERSATION_LIST) { - popUpTo(Routes.LOGIN) { inclusive = true } + popUpTo(Routes.AUTH_GRAPH) { inclusive = true } } } } + // When the verification code section becomes visible, scroll to top so + // the user sees it immediately without having to scroll up manually. + LaunchedEffect(uiState.needsConfirmation) { + if (uiState.needsConfirmation) { + coroutineScope.launch { scrollState.animateScrollTo(0) } + } + } + + // Block the system back button / gesture while awaiting the email code. + // Prevents accidentally navigating away and losing the confirmation state. + BackHandler(enabled = uiState.needsConfirmation) { + // Do nothing — keep the user on this screen until they confirm or navigate + // deliberately via the top-bar back arrow (which also calls popBackStack, + // but that navigation is explicit user intent rather than accidental swipe). + } + val textFieldColors = OutlinedTextFieldDefaults.colors( focusedTextColor = CatppuccinMocha.Text, unfocusedTextColor = CatppuccinMocha.Text, @@ -141,7 +162,7 @@ fun RegisterScreen( modifier = Modifier .widthIn(max = 400.dp) .fillMaxWidth() - .verticalScroll(rememberScrollState()), + .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, ) { diff --git a/app/src/main/java/com/kecalek/chat/ui/chat/ChatViewModel.kt b/app/src/main/java/com/kecalek/chat/ui/chat/ChatViewModel.kt index 2a92971..74e427e 100644 --- a/app/src/main/java/com/kecalek/chat/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/kecalek/chat/ui/chat/ChatViewModel.kt @@ -1,14 +1,30 @@ package com.kecalek.chat.ui.chat +import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kecalek.chat.core.ChatClient +import com.kecalek.chat.core.SessionManager +import com.kecalek.chat.data.model.Conversation +import com.kecalek.chat.data.model.ConversationMember +import com.kecalek.chat.data.model.Message +import com.kecalek.chat.data.model.MessageReaction +import com.kecalek.chat.data.repository.MessageRepository +import com.kecalek.chat.network.ServerApi +import com.kecalek.chat.network.decodeBinary +import com.kecalek.chat.util.Constants +import com.kecalek.chat.util.DateFormatter import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import com.kecalek.chat.data.model.Conversation -import com.kecalek.chat.data.model.ConversationMember -import com.kecalek.chat.data.model.Message +import kotlinx.coroutines.launch +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone import javax.inject.Inject data class ChatUiState( @@ -29,44 +45,485 @@ data class ChatUiState( @HiltViewModel class ChatViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - // TODO: Inject repositories + private val chatClient: ChatClient, + private val api: ServerApi, + private val messageRepository: MessageRepository, + private val sessionManager: SessionManager, ) : ViewModel() { + companion object { + private const val TAG = "ChatViewModel" + } + val conversationId: String = savedStateHandle["conversationId"] ?: "" private val _uiState = MutableStateFlow(ChatUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadMessages() { - // TODO: Load from cache + incremental sync from server + init { + val userId = sessionManager.currentSession?.userId ?: "" + _uiState.value = _uiState.value.copy(currentUserId = userId) + + // Load conversation info and messages + viewModelScope.launch { + loadConversationInfo() + loadMessages() + } + + // Observe incoming messages from ChatClient + viewModelScope.launch { + chatClient.newMessageFlow.collect { decryptedMsg -> + if (decryptedMsg.conversationId == conversationId) { + val message = Message( + id = decryptedMsg.messageId, + conversationId = decryptedMsg.conversationId, + senderId = decryptedMsg.senderId, + senderUsername = decryptedMsg.senderUsername, + createdAt = parseTimestamp(decryptedMsg.timestamp) ?: Date(), + text = decryptedMsg.text, + replyTo = decryptedMsg.replyTo, + ) + messageRepository.insertMessage(message) + refreshMessagesFromDb() + } + } + } + + // Observe messages from Room database (reactive) + viewModelScope.launch { + messageRepository.getMessagesFlow(conversationId).collect { messages -> + _uiState.value = _uiState.value.copy(messages = messages) + } + } } + /** + * Load conversation info (members, name) for the current conversation. + */ + private suspend fun loadConversationInfo() { + try { + val resp = api.listConversations() + if (resp.isOk) { + val jsonArray = resp.data.optJSONArray("conversations") + if (jsonArray != null) { + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + if (obj.getString("conversation_id") == conversationId) { + val members = mutableListOf() + val membersArray = obj.optJSONArray("members") + if (membersArray != null) { + for (j in 0 until membersArray.length()) { + val m = membersArray.getJSONObject(j) + members.add(ConversationMember( + userId = m.getString("user_id"), + username = if (m.isNull("username")) "Unknown" else m.optString("username", "Unknown"), + email = if (m.isNull("email")) "" else m.optString("email", ""), + )) + } + } + val conv = Conversation( + id = obj.getString("conversation_id"), + name = if (obj.isNull("name")) null else obj.optString("name", null), + members = members, + createdBy = if (obj.isNull("created_by")) null else obj.optString("created_by", null), + unreadCount = obj.optInt("unread_count", 0), + ) + _uiState.value = _uiState.value.copy( + conversation = conv, + members = members, + ) + break + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load conversation info", e) + } + } + + /** + * Load messages from server, decrypt, and store in local DB. + */ + fun loadMessages() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + try { + val myUserId = sessionManager.currentSession?.userId ?: return@launch + + val resp = api.getMessages(conversationId, limit = 50) + if (!resp.isOk) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = resp.errorMessage, + ) + return@launch + } + + val messagesArray = resp.data.optJSONArray("messages") + if (messagesArray == null) { + _uiState.value = _uiState.value.copy(isLoading = false) + return@launch + } + + val decryptedMessages = mutableListOf() + + for (i in 0 until messagesArray.length()) { + val msgObj = messagesArray.getJSONObject(i) + val messageId = msgObj.getString("message_id") + + // Skip messages already successfully decrypted and stored in local DB. + // This prevents Double Ratchet state corruption from re-decrypting + // the same message (push handler + loadMessages race condition). + val existing = messageRepository.getMessage(messageId) + if (existing != null && existing.text != "[Unable to decrypt message]") { + continue + } + + try { + val message = decryptServerMessage(msgObj, myUserId) + if (message != null) { + decryptedMessages.add(message) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to decrypt message ${msgObj.optString("message_id")}", e) + // Only add placeholder if no good version exists in DB + if (existing == null) { + decryptedMessages.add(Message( + id = messageId, + conversationId = conversationId, + senderId = msgObj.optString("sender_id", ""), + senderUsername = "Unknown", + createdAt = parseTimestamp(msgObj.optString("created_at", "")) ?: Date(), + text = "[Unable to decrypt message]", + )) + } + } + } + + // Save to local DB + if (decryptedMessages.isNotEmpty()) { + messageRepository.insertMessages(decryptedMessages) + } + + _uiState.value = _uiState.value.copy(isLoading = false) + + } catch (e: Exception) { + Log.e(TAG, "loadMessages failed", e) + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Failed to load messages: ${e.message}", + ) + } + } + } + + /** + * Decrypt a single message from the server's get_messages response. + * Handles both device_entries array format and legacy flat format. + */ + private suspend fun decryptServerMessage( + msgObj: JSONObject, + myUserId: String, + ): Message? { + val messageId = msgObj.getString("message_id") + val senderId = msgObj.optString("sender_id", "") + val senderDeviceId = msgObj.optString("sender_device_id", "default") + val createdAt = msgObj.optString("created_at", "") + + val myDeviceId = sessionManager.currentSession?.deviceId ?: "" + + // Pick encrypted content: device_entries array or flat fields + var encryptedContentB64: String + var nonceB64: String + var ratchetHeaderObj: JSONObject? + var x3dhHeaderObj: JSONObject? + + val deviceEntries = msgObj.optJSONArray("device_entries") + if (deviceEntries != null && deviceEntries.length() > 0) { + var chosen: JSONObject? = null + var selfEntry: JSONObject? = null + for (i in 0 until deviceEntries.length()) { + val entry = deviceEntries.getJSONObject(i) + val eid = entry.optString("device_id", "") + if (eid == myDeviceId) { chosen = entry; break } + if (eid == Constants.SELF_DEVICE_ID) selfEntry = entry + } + if (senderId == myUserId) chosen = selfEntry ?: chosen + else if (chosen == null) chosen = selfEntry + if (chosen == null) { + Log.w(TAG, "No matching device_entry for message $messageId, skipping") + return null + } + encryptedContentB64 = chosen.optString("encrypted_content", "") + nonceB64 = chosen.optString("nonce", "") + ratchetHeaderObj = chosen.optJSONObject("ratchet_header") ?: msgObj.optJSONObject("ratchet_header") + x3dhHeaderObj = chosen.optJSONObject("x3dh_header") ?: msgObj.optJSONObject("x3dh_header") + } else { + encryptedContentB64 = msgObj.optString("encrypted_content", "") + nonceB64 = msgObj.optString("nonce", "") + ratchetHeaderObj = msgObj.optJSONObject("ratchet_header") + x3dhHeaderObj = msgObj.optJSONObject("x3dh_header") + } + + if (encryptedContentB64.isEmpty() || nonceB64.isEmpty()) { + Log.w(TAG, "Message $messageId has no encrypted content, skipping") + return null + } + + val encryptedContent = decodeBinary(encryptedContentB64) + val nonce = decodeBinary(nonceB64) + val isSelfCopy = ratchetHeaderObj?.optBoolean("self", false) == true + val senderChainIdB64 = msgObj.optString("sender_chain_id", "") + val senderChainN = msgObj.optInt("sender_chain_n", -1) + val isGroupMessage = senderChainIdB64.isNotEmpty() && senderChainN >= 0 && !isSelfCopy + + // Decrypt — priority: self-copy > group > DM + val decryptedBytes: ByteArray = if (isSelfCopy) { + chatClient.decryptSelf(encryptedContent, nonce) + } else if (isGroupMessage) { + chatClient.decryptGroup( + conversationId = conversationId, + senderId = senderId, + encryptedContent = encryptedContent, + nonce = nonce, + chainIdBase64 = senderChainIdB64, + chainN = senderChainN, + ) + } else { + val ratchetHeaderMap = jsonObjectToMap(ratchetHeaderObj ?: JSONObject()) + val x3dhHeaderMap = x3dhHeaderObj?.let { jsonObjectToMap(it) } + chatClient.decryptDm( + senderId = senderId, + senderDeviceId = senderDeviceId, + encryptedContent = encryptedContent, + nonce = nonce, + ratchetHeaderMap = ratchetHeaderMap, + x3dhHeaderMap = x3dhHeaderMap, + ) + } + + // Parse decrypted JSON payload + val payloadStr = String(decryptedBytes, Charsets.UTF_8) + val payload = JSONObject(payloadStr) + + // Check for control message (sender key distribution) + if (payload.has("_sender_key")) { + return null // Don't display control messages + } + + return Message( + id = messageId, + conversationId = conversationId, + senderId = senderId, + senderUsername = payload.optString("sender", "Unknown"), + createdAt = parseTimestamp(createdAt) ?: Date(), + text = payload.optString("text", null), + replyTo = if (payload.isNull("reply_to")) null else payload.optString("reply_to", null), + ) + } + + /** + * Send a text message. + */ fun sendMessage(text: String) { - // TODO: Encrypt and send message + if (text.isBlank()) return + + viewModelScope.launch { + try { + val session = sessionManager.currentSession ?: return@launch + val members = _uiState.value.members + + // Build plaintext JSON payload matching Python/iOS protocol + val payload = JSONObject().apply { + put("sender", session.username) + put("text", text) + put("reply_to", _uiState.value.replyingTo?.id ?: JSONObject.NULL) + put("timestamp", formatIsoTimestamp(Date())) + } + val plaintextBytes = payload.toString().toByteArray(Charsets.UTF_8) + + val replyToId = _uiState.value.replyingTo?.id + + // Send via ChatClient (handles padding + encryption + server call) + val messageId = chatClient.sendDm( + conversationId = conversationId, + plaintext = plaintextBytes, + memberUserIds = members.map { it.userId }, + replyTo = replyToId, + ) + + // Insert sent message into local DB for immediate display + val sentMessage = Message( + id = messageId, + conversationId = conversationId, + senderId = session.userId, + senderUsername = session.username, + createdAt = Date(), + text = text, + replyTo = replyToId, + ) + messageRepository.insertMessage(sentMessage) + + // Clear reply state + _uiState.value = _uiState.value.copy(replyingTo = null) + + Log.d(TAG, "Message sent: $messageId") + + } catch (e: Exception) { + Log.e(TAG, "sendMessage failed", e) + _uiState.value = _uiState.value.copy( + error = "Failed to send message: ${e.message}", + ) + } + } } fun sendImage(uri: String) { - // TODO: Encrypt and upload image + // TODO: AES-encrypt image → chunked upload via uploadImageStart/Chunk/End + // Payload: {"sender", "text", "image": {"file_id", "key": b64, "iv": b64}, "timestamp"} + Log.w(TAG, "sendImage: not yet implemented") } fun sendFile(uri: String) { - // TODO: Encrypt and upload file + // TODO: AES-encrypt file → chunked upload (same as sendImage) + Log.w(TAG, "sendFile: not yet implemented") } + /** + * Soft-delete a message. Calls server, then marks deleted in local DB. + */ fun deleteMessage(messageId: String) { - // TODO: Soft-delete message + viewModelScope.launch { + try { + val resp = api.deleteMessage(messageId) + if (resp.isOk) { + messageRepository.markDeleted(messageId) + } else { + _uiState.value = _uiState.value.copy(error = resp.errorMessage) + } + } catch (e: Exception) { + Log.e(TAG, "deleteMessage failed", e) + _uiState.value = _uiState.value.copy(error = "Failed to delete: ${e.message}") + } + } } + /** + * Toggle emoji reaction on a message. Adds if not present, removes if already added by me. + */ fun reactToMessage(messageId: String, reaction: String) { - // TODO: Add/remove reaction + viewModelScope.launch { + try { + val myUserId = sessionManager.currentSession?.userId ?: return@launch + val msg = _uiState.value.messages.find { it.id == messageId } + val alreadyReacted = msg?.reactions?.any { it.userId == myUserId && it.reaction == reaction } == true + val action = if (alreadyReacted) "remove" else "add" + + val resp = api.reactMessage(messageId, reaction, action = action) + if (resp.isOk) { + // Parse updated reactions list from server response + val reactionsArray = resp.data.optJSONArray("reactions") + if (reactionsArray != null) { + val reactions = mutableListOf() + for (i in 0 until reactionsArray.length()) { + val r = reactionsArray.getJSONObject(i) + reactions.add(MessageReaction( + userId = r.optString("user_id", ""), + reaction = r.optString("reaction", ""), + createdAt = parseTimestamp(r.optString("created_at", "")) ?: Date(), + )) + } + messageRepository.updateReactions(messageId, reactions) + } + } else { + _uiState.value = _uiState.value.copy(error = resp.errorMessage) + } + } catch (e: Exception) { + Log.e(TAG, "reactToMessage failed", e) + } + } } + /** + * Pin or unpin a message. Automatically detects current state from local DB. + */ fun pinMessage(messageId: String) { - // TODO: Pin/unpin message + viewModelScope.launch { + try { + val myUserId = sessionManager.currentSession?.userId ?: return@launch + val msg = _uiState.value.messages.find { it.id == messageId } + val action = if (msg?.pinnedAt != null) "unpin" else "pin" + + val resp = api.pinMessage(messageId, conversationId, action = action) + if (resp.isOk) { + val pinnedAt = if (action == "pin") Date() else null + val pinnedBy = if (action == "pin") myUserId else null + messageRepository.updatePinStatus(messageId, pinnedAt, pinnedBy) + } else { + _uiState.value = _uiState.value.copy(error = resp.errorMessage) + } + } catch (e: Exception) { + Log.e(TAG, "pinMessage failed", e) + } + } } + /** + * Forward a decrypted message to another conversation. + * Re-fetches target conversation members and re-encrypts the plaintext. + */ fun forwardMessage(messageId: String, targetConversationId: String) { - // TODO: Forward message to another conversation + viewModelScope.launch { + try { + val session = sessionManager.currentSession ?: return@launch + val originalMsg = messageRepository.getMessage(messageId) ?: return@launch + + // Fetch target conversation members + val resp = api.listConversations() + if (!resp.isOk) return@launch + val convArray = resp.data.optJSONArray("conversations") ?: return@launch + val targetMemberIds = mutableListOf() + for (i in 0 until convArray.length()) { + val conv = convArray.getJSONObject(i) + if (conv.getString("conversation_id") == targetConversationId) { + val membersArray = conv.optJSONArray("members") ?: break + for (j in 0 until membersArray.length()) { + targetMemberIds.add(membersArray.getJSONObject(j).getString("user_id")) + } + break + } + } + if (targetMemberIds.isEmpty()) { + Log.w(TAG, "forwardMessage: target conversation not found or has no members") + return@launch + } + + // Build forwarded payload + val payload = JSONObject().apply { + put("sender", session.username) + put("text", originalMsg.text ?: "") + put("reply_to", JSONObject.NULL) + put("timestamp", formatIsoTimestamp(Date())) + put("forwarded_from", JSONObject().apply { + put("message_id", messageId) + put("conversation_id", conversationId) + put("sender", originalMsg.senderUsername) + }) + } + + chatClient.sendDm( + conversationId = targetConversationId, + plaintext = payload.toString().toByteArray(Charsets.UTF_8), + memberUserIds = targetMemberIds, + ) + + Log.d(TAG, "Message $messageId forwarded to $targetConversationId") + } catch (e: Exception) { + Log.e(TAG, "forwardMessage failed", e) + _uiState.value = _uiState.value.copy(error = "Failed to forward: ${e.message}") + } + } } fun setReplyTo(message: Message?) { @@ -83,23 +540,97 @@ class ChatViewModel @Inject constructor( ) } + /** + * Search local message cache. Updates searchResults with list of message indices in the list. + */ fun search(query: String) { - // TODO: Search through local message cache + _uiState.value = _uiState.value.copy(searchQuery = query) + if (query.isBlank()) { + _uiState.value = _uiState.value.copy( + searchResults = emptyList(), + currentSearchIndex = -1, + ) + return + } + viewModelScope.launch { + try { + val results = messageRepository.searchMessages(conversationId, query) + val messages = _uiState.value.messages + val indices = results.mapNotNull { result -> + messages.indexOfFirst { it.id == result.id }.takeIf { it >= 0 } + } + _uiState.value = _uiState.value.copy( + searchResults = indices, + currentSearchIndex = if (indices.isNotEmpty()) 0 else -1, + ) + } catch (e: Exception) { + Log.e(TAG, "search failed", e) + } + } } fun nextSearchResult() { - // TODO: Navigate to next search result + val state = _uiState.value + if (state.searchResults.isEmpty()) return + val next = (state.currentSearchIndex + 1) % state.searchResults.size + _uiState.value = state.copy(currentSearchIndex = next) } fun prevSearchResult() { - // TODO: Navigate to previous search result + val state = _uiState.value + if (state.searchResults.isEmpty()) return + val prev = (state.currentSearchIndex - 1 + state.searchResults.size) % state.searchResults.size + _uiState.value = state.copy(currentSearchIndex = prev) } + /** + * Mark all visible messages in this conversation as read on the server. + */ fun markAsRead() { - // TODO: Mark visible messages as read + viewModelScope.launch { + try { + api.markConversationRead(conversationId) + } catch (e: Exception) { + Log.e(TAG, "markAsRead failed", e) + } + } } fun downloadFile(fileId: String) { - // TODO: Download and decrypt file + // TODO: Loop downloadImage(fileId, offset) until done → AES decrypt → save to storage + Log.w(TAG, "downloadFile: not yet implemented") + } + + // ===== Private Helpers ===== + + private suspend fun refreshMessagesFromDb() { + val messages = messageRepository.getMessages(conversationId) + _uiState.value = _uiState.value.copy(messages = messages) + } + + private fun jsonObjectToMap(obj: JSONObject): Map { + val map = mutableMapOf() + obj.keys().forEach { key -> + map[key] = obj.get(key) + } + return map + } + + private fun parseTimestamp(ts: String?): Date? { + if (ts.isNullOrEmpty()) return null + return DateFormatter.parse(ts) ?: try { + // Try standard ISO format without millis + val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) + fmt.timeZone = TimeZone.getTimeZone("UTC") + fmt.parse(ts) + } catch (_: Exception) { + null + } + } + + private fun formatIsoTimestamp(date: Date): String { + val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + fmt.timeZone = TimeZone.getTimeZone("UTC") + return fmt.format(date) } } diff --git a/app/src/main/java/com/kecalek/chat/ui/conversations/ConversationListVM.kt b/app/src/main/java/com/kecalek/chat/ui/conversations/ConversationListVM.kt index 53d8c82..e4e884d 100644 --- a/app/src/main/java/com/kecalek/chat/ui/conversations/ConversationListVM.kt +++ b/app/src/main/java/com/kecalek/chat/ui/conversations/ConversationListVM.kt @@ -3,12 +3,15 @@ package com.kecalek.chat.ui.conversations import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.kecalek.chat.core.ChatClient import com.kecalek.chat.core.SessionManager import com.kecalek.chat.data.model.Conversation import com.kecalek.chat.data.model.ConversationMember import com.kecalek.chat.data.model.Invitation import com.kecalek.chat.network.ServerApi import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -37,6 +40,7 @@ data class ConversationListState( class ConversationListVM @Inject constructor( private val api: ServerApi, private val sessionManager: SessionManager, + private val chatClient: ChatClient, ) : ViewModel() { private val _uiState = MutableStateFlow(ConversationListState()) @@ -49,11 +53,35 @@ class ConversationListVM @Inject constructor( private val _navigateToChat = MutableSharedFlow() val navigateToChat: SharedFlow = _navigateToChat.asSharedFlow() + // Debounce job for conversation list refresh — prevents rapid-fire API calls + // when multiple push notifications arrive in quick succession. + private var refreshDebounceJob: Job? = null + init { val userId = sessionManager.currentSession?.userId ?: "" _uiState.update { it.copy(currentUserId = userId) } loadConversations() loadInvitations() + + // Observe real-time push notifications for conversation list updates. + // Debounced: if multiple events arrive within 500ms, only one API call fires. + viewModelScope.launch { + chatClient.conversationUpdateFlow.collect { event -> + Log.d(TAG, "Conversation update: type=${event.type}, convId=${event.conversationId}") + + // Invitation-related events also refresh invitations list + if (event.type == "group_invitation") { + loadInvitations() + } + + // Debounce conversation list refresh + refreshDebounceJob?.cancel() + refreshDebounceJob = viewModelScope.launch { + delay(500L) // 500ms debounce + loadConversations() + } + } + } } fun onSearchQueryChanged(query: String) { @@ -261,8 +289,8 @@ class ConversationListVM @Inject constructor( members.add( ConversationMember( userId = m.getString("user_id"), - username = m.optString("username", "Unknown"), - email = m.optString("email", ""), + username = if (m.isNull("username")) "Unknown" else m.optString("username", "Unknown"), + email = if (m.isNull("email")) "" else m.optString("email", ""), ) ) } @@ -270,11 +298,13 @@ class ConversationListVM @Inject constructor( return Conversation( id = json.getString("conversation_id"), - name = json.optString("name", null), + name = if (json.isNull("name")) null else json.optString("name", null), members = members, - createdBy = json.optString("created_by", null), + createdBy = if (json.isNull("created_by")) null else json.optString("created_by", null), unreadCount = json.optInt("unread_count", 0), - lastMessageTime = parseIsoDate(json.optString("last_message_time", null)), + lastMessageTime = parseIsoDate( + if (json.isNull("last_message_time")) null else json.optString("last_message_time", null) + ), ) } diff --git a/app/src/main/java/com/kecalek/chat/ui/navigation/NavGraph.kt b/app/src/main/java/com/kecalek/chat/ui/navigation/NavGraph.kt index cb1dbf5..5259f60 100644 --- a/app/src/main/java/com/kecalek/chat/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/kecalek/chat/ui/navigation/NavGraph.kt @@ -1,19 +1,23 @@ package com.kecalek.chat.ui.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.kecalek.chat.core.SessionManager +import com.kecalek.chat.ui.auth.AuthViewModel import com.kecalek.chat.ui.auth.LoginScreen import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent +import androidx.hilt.navigation.compose.hiltViewModel import com.kecalek.chat.ui.auth.PairingScreen import com.kecalek.chat.ui.auth.RegisterScreen import com.kecalek.chat.ui.chat.ChatScreen @@ -29,6 +33,7 @@ import java.net.URLDecoder import java.net.URLEncoder object Routes { + const val AUTH_GRAPH = "auth" const val LOGIN = "login" const val REGISTER = "register" const val PAIRING = "pairing" @@ -58,7 +63,7 @@ interface NavGraphEntryPoint { @Composable fun KecalekNavGraph( navController: NavHostController = rememberNavController(), - startDestination: String = Routes.LOGIN, + startDestination: String = Routes.AUTH_GRAPH, ) { val context = LocalContext.current val entryPoint = EntryPointAccessors.fromApplication(context, NavGraphEntryPoint::class.java) @@ -68,14 +73,34 @@ fun KecalekNavGraph( navController = navController, startDestination = startDestination, ) { - composable(Routes.LOGIN) { - LoginScreen(navController = navController) - } - composable(Routes.REGISTER) { - RegisterScreen(navController = navController) - } - composable(Routes.PAIRING) { - PairingScreen(navController = navController) + // Auth screens share a single AuthViewModel scoped to this nested graph. + // This ensures server config (host/port/TLS) set on LoginScreen is + // visible on RegisterScreen and PairingScreen. + navigation( + startDestination = Routes.LOGIN, + route = Routes.AUTH_GRAPH, + ) { + composable(Routes.LOGIN) { backStackEntry -> + val authEntry = remember(backStackEntry) { + navController.getBackStackEntry(Routes.AUTH_GRAPH) + } + val sharedVm: AuthViewModel = hiltViewModel(authEntry) + LoginScreen(navController = navController, viewModel = sharedVm) + } + composable(Routes.REGISTER) { backStackEntry -> + val authEntry = remember(backStackEntry) { + navController.getBackStackEntry(Routes.AUTH_GRAPH) + } + val sharedVm: AuthViewModel = hiltViewModel(authEntry) + RegisterScreen(navController = navController, viewModel = sharedVm) + } + composable(Routes.PAIRING) { backStackEntry -> + val authEntry = remember(backStackEntry) { + navController.getBackStackEntry(Routes.AUTH_GRAPH) + } + val sharedVm: AuthViewModel = hiltViewModel(authEntry) + PairingScreen(navController = navController, viewModel = sharedVm) + } } composable(Routes.CONVERSATION_LIST) { ConversationListScreen(navController = navController) @@ -133,7 +158,7 @@ fun KecalekNavGraph( navController = navController, onLogout = { sessionManager.logout() - navController.navigate(Routes.LOGIN) { + navController.navigate(Routes.AUTH_GRAPH) { popUpTo(0) { inclusive = true } } },