Start preview

This commit is contained in:
filip
2026-03-12 19:30:19 +01:00
parent 3d935dcfbf
commit 3204bd6605
13 changed files with 1349 additions and 117 deletions

View File

@@ -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<String, DoubleRatchet>()
private val sessionMutex = Mutex()
// Device bundle cache: userId -> (bundles, timestamp)
private val bundleCache = mutableMapOf<String, Pair<List<DeviceBundleInfo>, Long>>()
// Sender key cache: (conversationId, userId) -> SenderKeyState
// Sender key cache: "conversationId_userId" -> SenderKeyState
private val senderKeys = mutableMapOf<String, SenderKeyState>()
// 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<DecryptedMessage>(extraBufferCapacity = 64)
val newMessageFlow: SharedFlow<DecryptedMessage> = _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<ConversationUpdateEvent>(extraBufferCapacity = 64)
val conversationUpdateFlow: SharedFlow<ConversationUpdateEvent> = _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<String>,
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<Map<String, Any?>>()
var firstPeerHeader: Map<String, Any>? = 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<String, Any?>(
"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 {
): 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<Map<String, Any?>>()
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<String>,
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<Map<String, Any?>>()
var firstPeerHeader: Map<String, Any>? = 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<String, Any?>(
"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<String, Any>? = 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<String, Any>(
"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<String, Any>()
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<String, Any>()
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<String, Any>?,
)
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,

View File

@@ -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 =

View File

@@ -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)

View File

@@ -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<String, Any> = mapOf(

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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"
}
}
}

View File

@@ -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 }
}
}
}

View File

@@ -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 }
}
}
}

View File

@@ -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,
) {

View File

@@ -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<ChatUiState> = _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<ConversationMember>()
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<Message>()
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<MessageReaction>()
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<String>()
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<String, Any> {
val map = mutableMapOf<String, Any>()
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)
}
}

View File

@@ -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<String>()
val navigateToChat: SharedFlow<String> = _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)
),
)
}

View File

@@ -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 }
}
},