Start preview
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user