Compare commits

...

4 Commits

Author SHA1 Message Date
filip
c73820b9ce Start preview 2026-03-12 19:30:43 +01:00
filip
3204bd6605 Start preview 2026-03-12 19:30:19 +01:00
filip
3d935dcfbf Refactor AuthViewModel to match iOS auth flow
Replace PendingAuth/RSA key passing with tempPassword pattern:
- register(): saves keys to disk, stores password temporarily
- confirmRegistration(): verifies code, then calls performLogin(email, tempPassword)
  which re-loads keys from disk - exactly as iOS does
- Extract shared performLogin() suspend fn used by both login() and post-confirm flow
- Remove PendingAuth data class entirely

Simpler, correct, matches reference iOS implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 23:26:56 +01:00
filip
e36dfe1cee Fix: isolate auto-login errors from confirmation code errors
- confirmRegistration: wrap auto-login in separate try-catch so a failed
  login after confirmation never shows as 'wrong code' to the user.
  If auto-login fails, user is sent to LoginScreen to log in manually.
- SessionManager.login: always reconnect fresh (disconnect + enableReconnect
  + connect) instead of reusing the stale post-registration TCP connection.
  Fixes login failures caused by server closing the registration connection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 23:26:56 +01:00
15 changed files with 1854 additions and 258 deletions

231
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,231 @@
# Kecalek Android — Architecture Reference
## Project
- Package: `com.kecalek.chat`
- Root: `E:\encrypted_chat_vyvoj\android\`
- Server: `chat.ai-tech.news:9999` (TCP, newline-delimited JSON)
- iOS ref: `E:\encrypted_chat_vyvoj\ios\Kecalek\Kecalek\`
- Python ref: `E:\encrypted_chat_vyvoj\python\encrypted_chat\`
## Tech Stack
- Kotlin, Jetpack Compose + Material 3, Catppuccin Mocha dark theme
- Crypto: Tink + Bouncy Castle (Ed25519, X25519, AES-256-GCM, HKDF, RSA-PSS)
- DB: Room + SQLCipher (`kecalek_chat.db`), DI: Hilt, Network: Raw TCP
- Coroutines + Flow throughout
## Critical Constants
```kotlin
// Constants.kt
SELF_DEVICE_ID = "00000000-0000-0000-0000-000000000000" // self-copy sentinel
DEFAULT_HOST = "chat.ai-tech.news"
DEFAULT_PORT = 9999
```
## Protocol Field Names (MUST match server exactly)
| Field | Correct | Wrong |
|-------|---------|-------|
| X3DH identity key | `"ik"` | ~~`"ik_pub"`~~ |
| X3DH ephemeral key | `"ek"` | ~~`"ek_pub"`~~ |
| Push sender field | `"sender_id"` | ~~`"sender_user_id"`~~ |
| Ratchet header | `{dh_pub: hex, n: int, pn: int}` | — |
| Self-copy ratchet | `{"self": true}` | — |
| Multi-device push | `"device_entries"` array | — |
## Encryption Protocol
```
X3DH (new session):
4 DH ops, HKDF salt=0x00*32, info="EncryptedChat_X3DH"
Header sent: {"ik": b64(idPub), "ek": b64(ephPub), "opk_id": id?}
Double Ratchet (per-message):
Chain KDF: msg_key=HMAC(ck, 0x01), next_ck=HMAC(ck, 0x02)
encrypt() -> RatchetMessage(header, ciphertext, nonce)
header.toMap() -> {"dh_pub": hex, "n": int, "pn": int}
AES-256-GCM: 12-byte nonce, 16-byte tag
MessagePadding: pad/unpad with 64B..64KB buckets
Self-copy: HKDF(identityPrivate, "self_encryption") -> static AES key
SELF_DEVICE_ID = "00000000-0000-0000-0000-000000000000"
Plaintext payload JSON:
{"sender": "username", "text": "hello", "reply_to": null, "timestamp": "2026-...Z"}
Control message: {"_sender_key": {"conv_id": "...", "key": b64}} → import silently
```
## Key Files — ALL DONE
| File | Purpose |
|------|---------|
| `crypto/AesGcm.kt` | AES-256-GCM encrypt/decrypt |
| `crypto/Hkdf.kt` | HKDF key derivation |
| `crypto/ECP1.kt` / `KeyEncryption.kt` | Password-based key encryption (magic+salt+nonce+ct) |
| `crypto/Ed25519Crypto.kt` | Ed25519 sign/verify, serialize/deserialize |
| `crypto/X25519Crypto.kt` | X25519 DH, Ed25519→X25519 conversion |
| `crypto/RSACrypto.kt` | RSA-PSS for server login signature |
| `crypto/MessagePadding.kt` | Bucketed padding 64B64KB |
| `crypto/X3DH.kt` | X3DH initiate (client) + respond (server) |
| `crypto/DoubleRatchet.kt` | Double Ratchet encrypt/decrypt/import/export |
| `crypto/SenderKey.kt` | Group sender key: AAD = chain_id(32B)+n(4B big-endian) |
| `crypto/ContactVerification.kt` | Safety numbers: SHA-512 × 5200, 60 digits (12×5) |
| `network/ConnectionManager.kt` | Raw TCP connection, newline-delimited JSON |
| `network/ProtocolHandler.kt` | Request/response + push notification dispatch |
| `network/ServerApi.kt` | 50 API endpoints |
| `core/SessionManager.kt` | Login/register, session persistence |
| `core/KeyStorage.kt` | Encrypted key persistence (SharedPreferences + ECP1) |
| `core/ChatClient.kt` | Main messaging engine |
| `core/NotificationRouter.kt` | Routes 18 push notification types |
| `data/repository/MessageRepository.kt` | Message CRUD + search + reactions |
| `data/repository/ConversationRepository.kt` | Conversation CRUD |
| `ui/chat/ChatViewModel.kt` | Chat screen ViewModel |
| `ui/conversations/ConversationListViewModel.kt` | Conversation list ViewModel |
| `ui/auth/AuthViewModel.kt` | Auth ViewModel |
| `di/AppModule.kt` | AppDatabase singleton (SQLCipher) |
| `di/DatabaseModule.kt` | DAO providers |
| `di/NetworkModule.kt` | Placeholder (net classes auto-wired) |
| `di/CryptoModule.kt` | Placeholder (crypto = Kotlin objects) |
## DI Architecture
- All `@Singleton @Inject constructor` classes: auto-wired by Hilt (no @Provides needed)
- `ChatClient`, `ServerApi`, `ConnectionManager`, `SessionManager`, `KeyStorage`,
`NotificationRouter`, `MessageRepository`, `ConversationRepository`
- `AppModule`: provides `AppDatabase` (SQLCipher with passphrase from KeyStorage)
- `DatabaseModule`: provides DAOs (`MessageDao`, `ConversationDao`, `UserCacheDao`)
## ChatClient — Core Flow
```
initialize(password):
ECP1.decrypt(encryptedIdentityKey, password) -> identityPriv/Pub
Ed25519→X25519 conversion for DH
selfEncryptionKey = HKDF(identityPriv bytes, "self_encryption")
ensurePrekeys() → upload signed prekey + one-time prekeys if low
setupNotificationHandlers() → register all push handlers
sendDm(conversationId, plaintext, memberUserIds):
padded = MessagePadding.pad(plaintext)
for each memberId != self:
bundles = getDeviceBundles(memberId) // api.getKeyBundle()
for each bundle:
loadOrCreateSessionWithHeader(memberId, bundle)
→ if no session: X3DH.initiate() + build x3dhHeader {ik, ek, opk_id?}
→ if existing: load ratchet from keyStorage
ratchetMsg = ratchet.encrypt(padded)
save session
build recipient entry {user_id, device_id, encrypted_content, nonce,
ratchet_header, x3dh_header?}
self-copy: encryptSelf(padded) + SELF_DEVICE_ID + {ratchet_header: {self: true}}
api.sendMessage(conversationId, topLevelHeader, recipients=[...])
handleNewMessage(data): // triggered by new_message push
pick entry from device_entries matching myDeviceId or SELF_DEVICE_ID
if ratchet_header.self == true: decryptSelf()
else: decryptDm(senderId, senderDeviceId, ct, nonce, ratchetHeader, x3dhHeader?)
parse JSON payload
if _sender_key: importSenderKey(), return
emit DecryptedMessage on newMessageFlow
```
## ChatViewModel — Core Flow
```
init:
loadConversationInfo() → api.listConversations() → find conv → set members
loadMessages() → api.getMessages() → for each: decryptServerMessage() → Room insert
collect chatClient.newMessageFlow → insert to Room → UI updates via Room Flow
collect messageRepository.getMessagesFlow() → _uiState.messages
sendMessage(text):
Build JSON {sender, text, reply_to, timestamp}
chatClient.sendDm(conversationId, payload, memberUserIds)
messageRepository.insertMessage(sent msg) → immediate local display
decryptServerMessage(msgObj):
Handle device_entries OR flat fields (same as handleNewMessage logic)
if self-copy: decryptSelf(); else: decryptDm()
Parse JSON payload → Message domain object
```
## Data Classes
```kotlin
Session(userId, username, email, deviceId, serverVersion)
DecryptedMessage(messageId, conversationId, senderId, senderUsername, text?, replyTo?, timestamp)
Message(id, conversationId, senderId, senderUsername, createdAt, text?, replyTo?,
imageFileId?, file?, image?, isDeleted, readBy, reactions, forwardedFrom?, pinnedAt?, pinnedBy?)
MessageReaction(userId, reaction, createdAt)
ConversationMember(userId, username, email)
```
## MessageRepository Methods
```kotlin
getMessagesFlow(conversationId): Flow<List<Message>> // reactive Room
getMessages(conversationId): List<Message> // suspend
getMessage(messageId): Message? // suspend
getPinnedMessages(conversationId): List<Message> // suspend
searchMessages(conversationId, query): List<Message> // suspend, full-text
insertMessage(message) // suspend
insertMessages(messages) // suspend
markDeleted(messageId) // suspend, soft-delete
updateReactions(messageId, reactions: List<MessageReaction>)
updatePinStatus(messageId, pinnedAt: Date?, pinnedBy: String?)
updateReadBy(messageId, readBy: Set<String>)
deleteByConversation(conversationId)
```
## ServerApi Key Endpoints
```kotlin
// Messaging
sendMessage(conversationId, ratchetHeader, recipients, x3dhHeader?, imageFileId?)
getMessages(conversationId, limit=50, offset=0, afterTs?)
markRead(conversationId, messageIds)
markConversationRead(conversationId)
deleteMessage(messageId)
reactMessage(messageId, reaction, action="add"/"remove")
pinMessage(messageId, conversationId, action="pin"/"unpin")
getPinnedMessages(conversationId)
// Keys
getKeyBundle(userId) // returns device bundles for user
uploadPrekeys(signedPrekey, oneTimePrekeys?)
getPrekeyCount()
// Conversations
listConversations()
createConversation(members, name?)
findConversation(email)
deleteConversation(conversationId)
addMember(conversationId, email)
removeMember(conversationId, userId)
leaveGroup(conversationId)
// Invitations
listInvitations()
acceptInvitation(conversationId)
declineInvitation(conversationId)
// Files
uploadImageStart(conversationId, fileId, fileSize, fileType)
uploadImageChunk(fileId, dataBase64)
uploadImageEnd(fileId)
downloadImage(fileId, offset=0)
// Session
sessionReset(peerUserId, peerDeviceId?)
```
## NotificationRouter — 18 Push Types
```kotlin
NEW_MESSAGE, MESSAGES_READ, MESSAGE_DELETED, MESSAGE_DELIVERED,
CONVERSATION_CREATED, MEMBER_ADDED, MEMBER_REMOVED, GROUP_INVITATION,
CONVERSATION_RENAMED, SESSION_RESET, MESSAGE_REACTED, MESSAGE_PINNED,
MESSAGE_UNPINNED, USER_ONLINE, USER_OFFLINE, ONLINE_USERS,
USERNAME_CHANGED, PROTOCOL_ERROR
```
## ECP1 Key Format
```
magic(4B "ECP1") + salt(16B) + nonce(12B) + ciphertext+tag
PBKDF2: 600k iterations, SHA-256, AAD = magic bytes
```
## Ed25519 → X25519 Conversion
```
X25519 private: SHA-512(ed25519_seed)[0:32] clamped (RFC 7748)
X25519 public: Montgomery u = (1+y)/(1-y) mod p
```

164
TODO.md Normal file
View File

@@ -0,0 +1,164 @@
# Kecalek Android — TODO & Implementation Status
## ✅ DONE — Infrastructure (Phase 0-3)
### Phase 0 — Gradle + Base
- [x] Gradle setup (Tink, Bouncy Castle, Room/SQLCipher, Hilt, Compose, OkHttp)
- [x] Catppuccin Mocha dark theme + Material 3
- [x] Navigation (NavHost, routes)
- [x] Domain models (Message, Conversation, User, MessageReaction, etc.)
- [x] Room entities + DAOs (MessageDao, ConversationDao, UserCacheDao)
- [x] AppDatabase with SQLCipher
### Phase 1 — Crypto (11 files)
- [x] AesGcm.kt — AES-256-GCM
- [x] Hkdf.kt — HKDF
- [x] ECP1.kt / KeyEncryption.kt — password-based key encryption
- [x] Ed25519Crypto.kt — Ed25519 sign/verify + Ed→X25519 conversion
- [x] X25519Crypto.kt — X25519 DH
- [x] RSACrypto.kt — RSA-PSS for login
- [x] MessagePadding.kt — bucketed padding 64B64KB
- [x] X3DH.kt — X3DH initiate/respond
- [x] DoubleRatchet.kt — Double Ratchet encrypt/decrypt/import/export
- [x] SenderKey.kt — group sender key protocol
- [x] ContactVerification.kt — safety numbers (SHA-512 × 5200, 60 digits)
### Phase 2 — Network (3 files)
- [x] ConnectionManager.kt — raw TCP, newline-delimited JSON
- [x] ProtocolHandler.kt — request/response + push dispatch
- [x] ServerApi.kt — 50 endpoints
### Phase 3 — Core + Repos + DI
- [x] SessionManager.kt — login/register/session persistence
- [x] KeyStorage.kt — encrypted key persistence
- [x] ChatClient.kt — messaging engine (sendDm, decrypt, new_message handler)
- [x] NotificationRouter.kt — routes 18 push types
- [x] MessageRepository.kt — message CRUD + search + reactions
- [x] ConversationRepository.kt — conversation CRUD
- [x] AppModule.kt — AppDatabase singleton
- [x] DatabaseModule.kt — DAO providers
- [x] NetworkModule.kt — placeholder (net classes auto-wired)
- [x] CryptoModule.kt — placeholder (crypto = Kotlin objects)
### Phase 4 — Service + Feature UI
- [x] ChatPushService.kt (FCM/push forwarding to NotificationRouter)
- [x] AuthViewModel.kt
- [x] ConversationListViewModel.kt
- [x] ChatViewModel.kt — core (loadMessages, sendMessage, incoming flow)
- [x] ChatScreen.kt
- [x] ConversationListScreen.kt
- [x] Auth screens (login, register)
---
## 🔧 READY TO BUILD & TEST
### Build
- [ ] Build in Android Studio (Ctrl+F9 / Build > Make Project)
- Note: Cannot build from CLI (JAVA_HOME not set on Windows)
- Expected: clean build with no compilation errors
### Integration Tests
- [ ] Send DM: type message on Android → verify arrives on Python/iOS client
- [ ] Receive DM: send from Python/iOS → verify appears on Android
- [ ] Self-copy: message I send appears in my own chat view immediately
- [ ] X3DH new session: first message to new user triggers X3DH + works correctly
- [ ] Group message: send in group conversation → all members receive
- [ ] Reaction: add emoji reaction → reflects on all clients
- [ ] Pin: pin a message → shows in pinned list
- [ ] Delete: delete message → removed from all clients
---
## 🟡 TODO — ChatViewModel Secondary Features
### Simple API calls (ready to implement)
- [x] deleteMessage(messageId) — `api.deleteMessage()` + `messageRepository.markDeleted()`
- [x] reactToMessage(messageId, reaction) — `api.reactMessage()` + `messageRepository.updateReactions()`
- [x] pinMessage(messageId) — `api.pinMessage()` + `messageRepository.updatePinStatus()`
- [x] markAsRead() — `api.markConversationRead(conversationId)`
- [x] search(query) — `messageRepository.searchMessages()` + update searchResults in state
- [x] nextSearchResult() / prevSearchResult() — navigate currentSearchIndex
- [x] forwardMessage(messageId, targetConversationId) — re-encrypt plaintext for target conv
### Complex (need encryption pipeline)
- [ ] sendImage(uri) — AES encrypt image → chunked upload via uploadImageStart/Chunk/End
- ImageInfo {fileId, aesKey, iv, thumbnail, filename, size} stored in message
- Payload: {"sender", "text", "image": {"file_id": ..., "key": b64, "iv": b64}, "timestamp"}
- [ ] sendFile(uri) — similar to sendImage but with FileInfo
- [ ] downloadFile(fileId) — downloadImage() chunks → reassemble → AES decrypt
- Use offset pagination: loop downloadImage(fileId, offset) until complete
---
## 🟡 TODO — ChatClient Group Messaging
- [ ] sendGroupMessage() — sender key protocol
- Check if each member has received sender key
- If not: send `_sender_key` control message first (DM encrypted per device)
- Then encrypt group message with SenderKey.encrypt()
- Track which members have the key in keyStorage or DB
- [ ] Group member add: resend sender key to new member via DM
- [ ] Group member remove: rotate sender key, resend to remaining members
- [ ] Handle `member_added` / `member_removed` push notifications in ChatClient
---
## 🟠 KNOWN ISSUES
### ✅ FIXED: SQLCipher passphrase (AppModule.kt)
- **Was**: hardcoded `"TODO_REPLACE_WITH_DERIVED_KEY"` passphrase
- **Now**: random 32-byte key generated once, stored in EncryptedSharedPreferences (Android Keystore)
- **Migration**: if old DB exists without stored passphrase → old DB deleted, new one created
- Note: Could use HKDF-derived key from identity private (like Python), but Android Keystore approach is simpler and equally secure
### ✅ FIXED: AuthUiState.useTls = true (registration bug)
- **Was**: `AuthUiState.useTls = true` → registration always tried TLS → SSL error if server is plain TCP
- **Now**: `AuthUiState.useTls = false` — consistent with LoginScreen local default
- LoginScreen always calls `updateServerConfig(useTls=false)` before login anyway
- If server requires TLS, user can toggle it in Server Configuration section on LoginScreen
### Push notification handler registration
- ChatClient.setupNotificationHandlers() is called from initialize()
- If ChatClient is not initialized (user not logged in), push notifications are silently dropped
- Fix: Check session state before trying to decrypt; store encrypted push and decrypt on next init
### Session persistence
- Sessions (DoubleRatchet state) are saved to KeyStorage per device
- If app is reinstalled, all sessions are lost → first message after reinstall needs X3DH
- This is expected behavior but worth testing
---
## 📋 PROTOCOL COMPATIBILITY CHECKLIST
### sendDm()
- [x] Recipients per-device (not per-user)
- [x] Self-copy: device_id = SELF_DEVICE_ID, ratchet_header = {"self": true}
- [x] X3DH header fields: "ik", "ek", "opk_id" (NOT "ik_pub", "ek_pub")
- [x] Binary encoding: base64 via encodeBinary()/decodeBinary()
- [x] Ratchet header: dh_pub (hex), n, pn via header.toMap()
- [x] Plaintext payload: JSON {"sender", "text", "reply_to", "timestamp"}
- [x] Message padding: MessagePadding.pad() before encrypt, unpad() after decrypt
### handleNewMessage() / decryptServerMessage()
- [x] Push field: "sender_id" (NOT "sender_user_id")
- [x] device_entries array: pick by myDeviceId or SELF_DEVICE_ID
- [x] Self-copy detection: ratchet_header.self == true
- [x] Control messages (_sender_key): import silently, don't display
- [x] Flat fallback fields for backward compat
---
## 🔮 FUTURE — Nice to Have
- [ ] Voice messages — record, encrypt with AES, upload as file
- [ ] Disappearing messages — server-side TTL
- [ ] Message editing — re-encrypt + server update
- [ ] Key rotation — rotate RSA/Ed25519 identity keys
- [ ] Device pairing — link second device (pairingStart/pairingSend)
- [ ] Contact verification — UI to display safety numbers
- [ ] Backup / restore — export encrypted sessions
- [ ] Push notification channels — Android notification priorities
- [ ] App lock (biometric / PIN)

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,13 +66,19 @@ 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),
// so this handler is a no-op for the first connection.
connection.onConnected = {
val email = lastEmail ?: return@onConnected
val key = lastRsaPrivateKey ?: return@onConnected
connection.onConnected = reconnect@{
val email = lastEmail ?: return@reconnect
val key = lastRsaPrivateKey ?: return@reconnect
scope.launch {
try {
val session = performAuthHandshake(email, key, lastDeviceId, "Android")
@@ -100,9 +112,13 @@ class SessionManager @Inject constructor(
lastRsaPrivateKey = null
try {
if (connection.state.value != ConnectionManager.State.CONNECTED) {
connection.connect(host, port, useTls)
// Always start with a fresh connection.
// This handles stale post-registration connections and ensures reconnect is armed.
if (connection.state.value == ConnectionManager.State.CONNECTED) {
connection.disconnect()
}
connection.enableReconnect()
connection.connect(host, port, useTls)
val session = performAuthHandshake(email, rsaPrivateKey, deviceId, deviceName)
@@ -115,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)
}
@@ -134,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(
@@ -149,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(
@@ -173,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)
}
@@ -188,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

@@ -1,6 +1,7 @@
package com.kecalek.chat.ui.auth
import android.util.Base64
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kecalek.chat.core.AuthException
@@ -17,16 +18,12 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import javax.inject.Inject
/**
* UI state for all auth screens (Login, Register, Pairing).
*/
data class AuthUiState(
val isLoading: Boolean = false,
val loadingMessage: String? = null, // e.g. "Generating keys…", "Connecting…"
val loadingMessage: String? = null,
val error: String? = null,
val isLoggedIn: Boolean = false,
val isRegistered: Boolean = false,
@@ -42,144 +39,119 @@ data class AuthUiState(
val registeredEmail: String? = null,
)
/**
* Holds the already-decrypted key material between register() and confirmRegistration()
* so we can auto-login immediately after email confirmation without re-asking for the
* password or repeating the expensive PBKDF2 derivation.
*
* Cleared as soon as it is consumed (or when the ViewModel is cleared).
*/
private data class PendingAuth(
val email: String,
val rsaPrivate: RSAPrivateKey,
val identityPrivateBytes: ByteArray, // raw Ed25519 seed — used for initLocalKey
)
@HiltViewModel
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())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
// Holds key material between register() and confirmRegistration(). Never leaves memory.
private var pendingAuth: PendingAuth? = null
// Stored temporarily between register() and confirmRegistration() so we can
// auto-login after confirmation without asking the user for their password again.
// Cleared immediately after use (or in onCleared).
// Matches the iOS pattern: AuthViewModel stores self.password for this purpose.
private var tempPassword: String? = null
init {
_uiState.update { it.copy(hasExistingAccount = keyStorage.hasRsaKeys()) }
}
// ───────────────────────────── LOGIN ─────────────────────────────
fun login(emailOrUsername: String, password: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, loadingMessage = "Dešifruji klíče…", error = null) }
try {
if (!keyStorage.hasRsaKeys()) {
_uiState.update {
it.copy(
isLoading = false,
error = "No account on this device. Register or pair first."
)
}
return@launch
}
performLogin(emailOrUsername, password)
}
}
// Load RSA private key (decrypted with user's password via ECP1).
// PBKDF2 600k iterations — must run off the main thread.
val rsaPrivate = try {
withContext(Dispatchers.Default) {
keyStorage.loadRsaPrivate(password)
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, error = "Wrong password or corrupted key.")
}
return@launch
}
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
val state = _uiState.value
sessionManager.login(
email = emailOrUsername,
rsaPrivateKey = rsaPrivate,
host = state.serverHost,
port = state.serverPort,
useTls = state.useTls,
)
// Load identity key and init local storage key (also PBKDF2 — off main thread)
if (keyStorage.hasIdentityKeys()) {
val identityPrivate = withContext(Dispatchers.Default) {
keyStorage.loadIdentityPrivate(password)
}
keyStorage.initLocalKey(Ed25519Crypto.serializePrivate(identityPrivate))
}
_uiState.update { it.copy(isLoading = false, loadingMessage = null, isLoggedIn = true) }
} catch (e: AuthException) {
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
} catch (e: Exception) {
/**
* Core login logic shared by login() and the auto-login after confirmRegistration().
* Matches iOS ChatClient.login() + AuthViewModel.login(appState:).
*/
private suspend fun performLogin(emailOrUsername: String, password: String) {
_uiState.update { it.copy(isLoading = true, loadingMessage = "Dešifruji klíče…", error = null) }
try {
if (!keyStorage.hasRsaKeys()) {
_uiState.update {
it.copy(isLoading = false, loadingMessage = null, error = "Connection failed: ${e.message}")
it.copy(isLoading = false, error = "No account on this device. Register or pair first.")
}
return
}
// Load RSA private key — PBKDF2 600k iterations, must run off the main thread
val rsaPrivate = try {
withContext(Dispatchers.Default) { keyStorage.loadRsaPrivate(password) }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = "Wrong password or corrupted key.") }
return
}
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
val state = _uiState.value
sessionManager.login(
email = emailOrUsername,
rsaPrivateKey = rsaPrivate,
host = state.serverHost,
port = state.serverPort,
useTls = state.useTls,
)
// 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()) {
withContext(Dispatchers.Default) {
chatClient.initialize(password)
}
}
_uiState.update { it.copy(isLoading = false, loadingMessage = null, isLoggedIn = true) }
} catch (e: AuthException) {
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, loadingMessage = null, error = "Connection failed: ${e.message}")
}
}
}
// ───────────────────────────── REGISTER ─────────────────────────────
fun register(username: String, email: String, password: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, loadingMessage = "Generuji klíče…", error = null) }
try {
// Steps 1-4 are CPU-intensive (RSA-4096 keygen + 2× PBKDF2 600k iters).
// Run on Default dispatcher to avoid blocking the UI thread.
data class KeyMaterial(
val rsaPublicPem: String,
val identityKeyBase64: String,
val rsaPrivate: RSAPrivateKey,
val identityPrivateBytes: ByteArray,
)
val keys = withContext(Dispatchers.Default) {
// 1. Generate RSA-4096 keypair (~5-30 seconds)
// CPU-intensive: RSA-4096 keygen + 2× PBKDF2 600k iterations
val (rsaPublicPem, identityKeyBase64) = withContext(Dispatchers.Default) {
val (rsaPrivate, rsaPublic) = RSACrypto.generateKeypair()
// 2. Generate Ed25519 identity keypair (fast)
val (identityPrivate, identityPublic) = Ed25519Crypto.generateKeypair()
// 3. Save keys encrypted with password (PBKDF2 600k iters each)
keyStorage.saveRsaKeys(rsaPrivate, rsaPublic, password)
keyStorage.saveIdentityKeys(identityPrivate, identityPublic, password)
// 4. Convert to server format and capture private key material
KeyMaterial(
rsaPublicPem = rsaPublicKeyToPem(rsaPublic),
identityKeyBase64 = Base64.encodeToString(
Ed25519Crypto.serializePublic(identityPublic),
Base64.NO_WRAP,
),
rsaPrivate = rsaPrivate,
identityPrivateBytes = Ed25519Crypto.serializePrivate(identityPrivate),
Pair(
rsaPublicKeyToPem(rsaPublic),
Base64.encodeToString(Ed25519Crypto.serializePublic(identityPublic), Base64.NO_WRAP),
)
}
// Save decrypted keys for use in confirmRegistration (auto-login).
// pendingAuth is cleared after use or when this ViewModel is destroyed.
pendingAuth = PendingAuth(
email = email,
rsaPrivate = keys.rsaPrivate,
identityPrivateBytes = keys.identityPrivateBytes,
)
// Save password for auto-login after email confirmation (same pattern as iOS)
tempPassword = password
_uiState.update { it.copy(loadingMessage = "Připojuji se k serveru…") }
// 5. Register on server (network I/O — SessionManager uses IO dispatcher internally)
val state = _uiState.value
sessionManager.register(
username = username,
email = email,
rsaPublicKeyPem = keys.rsaPublicPem,
identityKeyBase64 = keys.identityKeyBase64,
rsaPublicKeyPem = rsaPublicPem,
identityKeyBase64 = identityKeyBase64,
host = state.serverHost,
port = state.serverPort,
useTls = state.useTls,
@@ -196,10 +168,10 @@ class AuthViewModel @Inject constructor(
)
}
} catch (e: AuthException) {
pendingAuth = null
tempPassword = null
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
} catch (e: Exception) {
pendingAuth = null
tempPassword = null
_uiState.update {
it.copy(isLoading = false, loadingMessage = null, error = "Registration failed: ${e.message}")
}
@@ -207,38 +179,36 @@ class AuthViewModel @Inject constructor(
}
}
// ───────────────────────────── CONFIRM ─────────────────────────────
fun confirmRegistration(email: String, code: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, loadingMessage = "Potvrzuji kód…", error = null) }
try {
// Step 1: Verify the email code — errors here are genuinely "wrong code"
sessionManager.confirmRegistration(email, code)
// Auto-login immediately after confirmation using the already-decrypted
// key material from register(). This avoids re-asking for the password.
val auth = pendingAuth
if (auth != null && auth.email == email) {
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
val state = _uiState.value
sessionManager.login(
email = email,
rsaPrivateKey = auth.rsaPrivate,
host = state.serverHost,
port = state.serverPort,
useTls = state.useTls,
)
// Init local DB encryption key (no password needed — bytes already decrypted)
keyStorage.initLocalKey(auth.identityPrivateBytes)
pendingAuth = null // consumed — clear for security
// Step 2: Auto-login exactly as iOS does:
// call login() with the stored password (keys are already on disk from register())
val pwd = tempPassword
tempPassword = null
if (pwd != null) {
// performLogin updates uiState (isLoggedIn = true on success, error on failure)
performLogin(email, pwd)
} else {
// No stored password (e.g. app restarted between register and confirm).
// Mark registration done and send to LoginScreen.
_uiState.update {
it.copy(
isLoading = false,
loadingMessage = null,
needsConfirmation = false,
hasExistingAccount = true,
)
}
}
_uiState.update {
it.copy(
isLoading = false,
loadingMessage = null,
isLoggedIn = true,
needsConfirmation = false,
)
}
} catch (e: AuthException) {
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
} catch (e: Exception) {
@@ -249,32 +219,28 @@ class AuthViewModel @Inject constructor(
}
}
// ───────────────────────────── PAIRING / BIOMETRIC ─────────────────────────────
fun startPairing() {
viewModelScope.launch {
_uiState.update {
it.copy(isLoading = false, error = "Device pairing not yet implemented.")
}
_uiState.update { it.copy(isLoading = false, error = "Device pairing not yet implemented.") }
}
}
fun cancelPairing() {
_uiState.update {
it.copy(isPairingWaiting = false, pairingCode = null, isPairingComplete = false)
}
_uiState.update { it.copy(isPairingWaiting = false, pairingCode = null, isPairingComplete = false) }
}
fun loginWithBiometric() {
viewModelScope.launch {
_uiState.update {
it.copy(isLoading = false, error = "Biometric login not yet implemented.")
}
_uiState.update { it.copy(isLoading = false, error = "Biometric login not yet implemented.") }
}
}
// ───────────────────────────── MISC ─────────────────────────────
fun updateServerConfig(host: String, port: Int, useTls: Boolean) {
_uiState.update {
it.copy(serverHost = host, serverPort = port, useTls = useTls)
}
_uiState.update { it.copy(serverHost = host, serverPort = port, useTls = useTls) }
}
fun clearError() {
@@ -282,21 +248,23 @@ class AuthViewModel @Inject constructor(
}
fun resetState() {
pendingAuth = null
tempPassword = null
_uiState.value = AuthUiState(hasExistingAccount = keyStorage.hasRsaKeys())
}
override fun onCleared() {
super.onCleared()
pendingAuth = null // Ensure key material doesn't linger after ViewModel is destroyed
tempPassword = null // don't let the password linger after ViewModel is destroyed
}
companion object {
private const val TAG = "AuthViewModel"
fun rsaPublicKeyToPem(key: RSAPublicKey): String {
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 }
}
},