Compare commits
4 Commits
b6529dedfc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c73820b9ce | ||
|
|
3204bd6605 | ||
|
|
3d935dcfbf | ||
|
|
e36dfe1cee |
231
ARCHITECTURE.md
Normal file
231
ARCHITECTURE.md
Normal 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 64B–64KB |
|
||||||
|
| `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
164
TODO.md
Normal 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 64B–64KB
|
||||||
|
- [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)
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
package com.kecalek.chat.core
|
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.crypto.*
|
||||||
import com.kecalek.chat.network.ConnectionManager
|
import com.kecalek.chat.network.ConnectionManager
|
||||||
import com.kecalek.chat.network.ServerApi
|
import com.kecalek.chat.network.ServerApi
|
||||||
import com.kecalek.chat.network.decodeBinary
|
import com.kecalek.chat.network.decodeBinary
|
||||||
import com.kecalek.chat.network.encodeBinary
|
import com.kecalek.chat.network.encodeBinary
|
||||||
import com.kecalek.chat.util.Constants
|
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.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||||
@@ -39,18 +51,22 @@ class ChatClient @Inject constructor(
|
|||||||
private val notificationRouter: NotificationRouter,
|
private val notificationRouter: NotificationRouter,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ChatClient"
|
||||||
|
}
|
||||||
|
|
||||||
private var identityPrivate: Ed25519PrivateKeyParameters? = null
|
private var identityPrivate: Ed25519PrivateKeyParameters? = null
|
||||||
private var identityPublic: Ed25519PublicKeyParameters? = null
|
private var identityPublic: Ed25519PublicKeyParameters? = null
|
||||||
private var selfEncryptionKey: ByteArray? = null
|
private var selfEncryptionKey: ByteArray? = null
|
||||||
|
|
||||||
// Session cache: (userId, deviceId) -> DoubleRatchet
|
// Session cache: "userId_deviceId" -> DoubleRatchet
|
||||||
private val sessions = mutableMapOf<String, DoubleRatchet>()
|
private val sessions = mutableMapOf<String, DoubleRatchet>()
|
||||||
private val sessionMutex = Mutex()
|
private val sessionMutex = Mutex()
|
||||||
|
|
||||||
// Device bundle cache: userId -> (bundles, timestamp)
|
// Device bundle cache: userId -> (bundles, timestamp)
|
||||||
private val bundleCache = mutableMapOf<String, Pair<List<DeviceBundleInfo>, Long>>()
|
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>()
|
private val senderKeys = mutableMapOf<String, SenderKeyState>()
|
||||||
|
|
||||||
// TOFU registry: userId -> identityKeyBytes
|
// TOFU registry: userId -> identityKeyBytes
|
||||||
@@ -61,6 +77,19 @@ class ChatClient @Inject constructor(
|
|||||||
|
|
||||||
private val mutex = Mutex()
|
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.
|
* Initialize after login. Loads keys and sets up notification handlers.
|
||||||
*/
|
*/
|
||||||
@@ -85,53 +114,98 @@ class ChatClient @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Send an encrypted DM (direct message).
|
* Send an encrypted DM (direct message).
|
||||||
* Encrypts per-device with Double Ratchet + self-encryption for multi-device.
|
* 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(
|
suspend fun sendDm(
|
||||||
conversationId: String,
|
conversationId: String,
|
||||||
plaintext: ByteArray,
|
plaintext: ByteArray,
|
||||||
|
memberUserIds: List<String>,
|
||||||
replyTo: String? = null,
|
replyTo: String? = null,
|
||||||
imageFileId: String? = null,
|
imageFileId: String? = null,
|
||||||
): String {
|
): String = sessionMutex.withLock {
|
||||||
val session = sessionManager.currentSession
|
val session = sessionManager.currentSession
|
||||||
?: throw IllegalStateException("Not logged in")
|
?: throw IllegalStateException("Not logged in")
|
||||||
|
val myUserId = session.userId
|
||||||
|
val idPub = identityPublic
|
||||||
|
?: throw IllegalStateException("Identity key not loaded")
|
||||||
|
|
||||||
// Pad plaintext
|
// Pad plaintext
|
||||||
val padded = MessagePadding.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?>>()
|
val recipientEntries = mutableListOf<Map<String, Any?>>()
|
||||||
|
var firstPeerHeader: Map<String, Any>? = null
|
||||||
|
|
||||||
// Get device bundles for all members
|
// Encrypt for each recipient's devices (excluding self)
|
||||||
val convResp = api.getMessages(conversationId, limit = 0)
|
for (memberId in memberUserIds) {
|
||||||
|
if (memberId == myUserId) continue
|
||||||
|
|
||||||
// Encrypt for each recipient device
|
val bundles = getDeviceBundles(memberId)
|
||||||
val deviceBundles = getDeviceBundles(session.userId) // self bundles
|
if (bundles.isEmpty()) {
|
||||||
// TODO: Get bundles for all conversation members and encrypt per-device
|
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
|
// Self-encryption for multi-device access
|
||||||
val selfResult = encryptSelf(padded)
|
val selfResult = encryptSelf(padded)
|
||||||
recipientEntries.add(mapOf(
|
recipientEntries.add(mapOf(
|
||||||
"user_id" to session.userId,
|
"user_id" to myUserId,
|
||||||
"device_id" to Constants.SELF_DEVICE_ID,
|
"device_id" to Constants.SELF_DEVICE_ID,
|
||||||
"encrypted_content" to encodeBinary(selfResult.ciphertext),
|
"encrypted_content" to encodeBinary(selfResult.ciphertext),
|
||||||
"nonce" to encodeBinary(selfResult.nonce),
|
"nonce" to encodeBinary(selfResult.nonce),
|
||||||
|
"ratchet_header" to mapOf("self" to true),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Build ratchet header from first recipient encryption
|
// Top-level ratchet_header: use first peer's header, or self marker if no peers
|
||||||
// TODO: Use actual ratchet header from per-device encryption
|
val topLevelHeader = firstPeerHeader ?: mapOf("self" to true)
|
||||||
val ratchetHeader = mapOf(
|
|
||||||
"dh_pub" to "TODO",
|
|
||||||
"n" to 0,
|
|
||||||
"pn" to 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
val resp = api.sendMessage(
|
val resp = api.sendMessage(
|
||||||
conversationId = conversationId,
|
conversationId = conversationId,
|
||||||
ratchetHeader = ratchetHeader,
|
ratchetHeader = topLevelHeader,
|
||||||
recipients = recipientEntries,
|
recipients = recipientEntries,
|
||||||
imageFileId = imageFileId,
|
imageFileId = imageFileId,
|
||||||
)
|
)
|
||||||
@@ -142,51 +216,158 @@ class ChatClient @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an encrypted group message using sender keys.
|
* 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(
|
suspend fun sendGroupMessage(
|
||||||
conversationId: String,
|
conversationId: String,
|
||||||
plaintext: ByteArray,
|
plaintext: ByteArray,
|
||||||
memberIds: List<String>,
|
memberIds: List<String>,
|
||||||
): String {
|
): String = sessionMutex.withLock {
|
||||||
val session = sessionManager.currentSession
|
val session = sessionManager.currentSession
|
||||||
?: throw IllegalStateException("Not logged in")
|
?: throw IllegalStateException("Not logged in")
|
||||||
|
|
||||||
val padded = MessagePadding.pad(plaintext)
|
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
|
// Get or create sender key for this conversation
|
||||||
val senderKeyState = getOrCreateSenderKey(conversationId, session.userId)
|
val senderKeyState = getOrCreateSenderKey(conversationId, session.userId)
|
||||||
|
|
||||||
// Encrypt with sender key (symmetric)
|
// If new sender key: distribute to all members first (before sending group msg)
|
||||||
val skMessage = senderKeyState.encrypt(padded)
|
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())
|
keyStorage.saveSenderKey(conversationId, session.userId, senderKeyState.exportState())
|
||||||
|
|
||||||
// Distribute sender key to members who don't have it yet
|
// Build recipients: same ciphertext per member user_id (no device_id for group peers)
|
||||||
// TODO: Track which members have received the sender key
|
|
||||||
|
|
||||||
// Build recipients list with per-device encrypted sender key distribution
|
|
||||||
val recipientEntries = mutableListOf<Map<String, Any?>>()
|
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)
|
val selfResult = encryptSelf(padded)
|
||||||
recipientEntries.add(mapOf(
|
recipientEntries.add(mapOf(
|
||||||
"user_id" to session.userId,
|
"user_id" to session.userId,
|
||||||
"device_id" to Constants.SELF_DEVICE_ID,
|
"device_id" to Constants.SELF_DEVICE_ID,
|
||||||
"encrypted_content" to encodeBinary(selfResult.ciphertext),
|
"encrypted_content" to encodeBinary(selfResult.ciphertext),
|
||||||
"nonce" to encodeBinary(selfResult.nonce),
|
"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(
|
val resp = api.sendMessage(
|
||||||
conversationId = conversationId,
|
conversationId = conversationId,
|
||||||
ratchetHeader = mapOf("dh_pub" to "group", "n" to 0, "pn" to 0),
|
ratchetHeader = dummyRatchetHeader,
|
||||||
recipients = recipientEntries,
|
recipients = recipientEntries,
|
||||||
senderChainId = encodeBinary(skMessage.chainIdHex.hexToBytes()),
|
senderChainId = encodeBinary(skMessage.chainIdHex.hexToBytes()),
|
||||||
senderChainN = skMessage.n,
|
senderChainN = skMessage.n,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!resp.isOk) throw Exception("Send failed: ${resp.errorMessage}")
|
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,
|
x3dhHeaderMap: Map<String, Any>? = null,
|
||||||
): ByteArray = sessionMutex.withLock {
|
): ByteArray = sessionMutex.withLock {
|
||||||
val sessionKey = "${senderId}_${senderDeviceId}"
|
val sessionKey = "${senderId}_${senderDeviceId}"
|
||||||
|
val header = RatchetHeader.fromMap(ratchetHeaderMap)
|
||||||
|
|
||||||
// If X3DH header present, establish new session
|
// 1) Try existing session first (in memory or storage)
|
||||||
if (x3dhHeaderMap != null) {
|
var ratchet = sessions[sessionKey]
|
||||||
val ratchet = establishSessionFromX3DH(senderId, senderDeviceId, x3dhHeaderMap)
|
if (ratchet == null) {
|
||||||
|
val stored = keyStorage.loadSession(senderId, senderDeviceId)
|
||||||
|
if (stored != null) {
|
||||||
|
ratchet = DoubleRatchet.importState(stored)
|
||||||
sessions[sessionKey] = ratchet
|
sessions[sessionKey] = ratchet
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val ratchet = sessions[sessionKey]
|
if (ratchet != null) {
|
||||||
?: loadOrCreateSession(senderId, senderDeviceId)
|
try {
|
||||||
|
|
||||||
val header = RatchetHeader.fromMap(ratchetHeaderMap)
|
|
||||||
val padded = ratchet.decrypt(header, encryptedContent, nonce)
|
val padded = ratchet.decrypt(header, encryptedContent, nonce)
|
||||||
|
|
||||||
// Save updated session state
|
|
||||||
keyStorage.saveSession(senderId, senderDeviceId, ratchet.exportState())
|
keyStorage.saveSession(senderId, senderDeviceId, ratchet.exportState())
|
||||||
sessions[sessionKey] = ratchet
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return MessagePadding.unpad(padded)
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
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(
|
private suspend fun loadOrCreateSession(
|
||||||
userId: String,
|
userId: String,
|
||||||
deviceId: String,
|
deviceId: String,
|
||||||
@@ -402,6 +662,10 @@ class ChatClient @Inject constructor(
|
|||||||
return ratchet
|
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(
|
private fun establishSessionFromX3DH(
|
||||||
senderId: String,
|
senderId: String,
|
||||||
senderDeviceId: String,
|
senderDeviceId: String,
|
||||||
@@ -412,8 +676,9 @@ class ChatClient @Inject constructor(
|
|||||||
?: keyStorage.loadSignedPreKey(isCurrent = false)
|
?: keyStorage.loadSignedPreKey(isCurrent = false)
|
||||||
?: throw CryptoException.X3DHFailed("No SPK available")
|
?: throw CryptoException.X3DHFailed("No SPK available")
|
||||||
|
|
||||||
val ekPubBytes = decodeBinary(x3dhHeader["ek_pub"] as String)
|
// Protocol field names: "ek" and "ik" (matching Python/iOS)
|
||||||
val ikPubBytes = decodeBinary(x3dhHeader["ik_pub"] as String)
|
val ekPubBytes = decodeBinary(x3dhHeader["ek"] as String)
|
||||||
|
val ikPubBytes = decodeBinary(x3dhHeader["ik"] as String)
|
||||||
val opkId = x3dhHeader["opk_id"] as? String
|
val opkId = x3dhHeader["opk_id"] as? String
|
||||||
|
|
||||||
val remoteIdPub = Ed25519Crypto.loadPublic(ikPubBytes)
|
val remoteIdPub = Ed25519Crypto.loadPublic(ikPubBytes)
|
||||||
@@ -449,6 +714,7 @@ class ChatClient @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
keyStorage.saveSession(senderId, senderDeviceId, ratchet.exportState())
|
keyStorage.saveSession(senderId, senderDeviceId, ratchet.exportState())
|
||||||
|
Log.d(TAG, "Established X3DH session from incoming header: sender=$senderId device=$senderDeviceId")
|
||||||
return ratchet
|
return ratchet
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,8 +759,7 @@ class ChatClient @Inject constructor(
|
|||||||
val existing = tofuRegistry[userId]
|
val existing = tofuRegistry[userId]
|
||||||
if (existing != null && !existing.contentEquals(identityKeyBytes)) {
|
if (existing != null && !existing.contentEquals(identityKeyBytes)) {
|
||||||
// Identity key changed! This is a potential MITM attack.
|
// Identity key changed! This is a potential MITM attack.
|
||||||
// TODO: Emit warning event for UI to display
|
Log.w(TAG, "Identity key changed for user $userId!")
|
||||||
android.util.Log.w("ChatClient", "Identity key changed for user $userId!")
|
|
||||||
}
|
}
|
||||||
tofuRegistry[userId] = identityKeyBytes
|
tofuRegistry[userId] = identityKeyBytes
|
||||||
keyStorage.saveTofuRegistry(tofuRegistry)
|
keyStorage.saveTofuRegistry(tofuRegistry)
|
||||||
@@ -516,10 +781,15 @@ class ChatClient @Inject constructor(
|
|||||||
notificationRouter.route(json)
|
notificationRouter.route(json)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new_message push
|
// Handle new_message push — decrypt and emit to newMessageFlow
|
||||||
notificationRouter.on(NotificationRouter.NEW_MESSAGE) { data ->
|
notificationRouter.on(NotificationRouter.NEW_MESSAGE) { data ->
|
||||||
// TODO: Decrypt message and update UI/DB
|
scope.launch {
|
||||||
// This requires async handling - will be wired in Phase 3/4
|
try {
|
||||||
|
handleNewMessage(data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to handle new_message", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle session_reset push
|
// Handle session_reset push
|
||||||
@@ -531,9 +801,209 @@ class ChatClient @Inject constructor(
|
|||||||
sessions.remove(sessionKey)
|
sessions.remove(sessionKey)
|
||||||
keyStorage.deleteSession(fromUserId, fromDeviceId)
|
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(
|
data class DeviceBundleInfo(
|
||||||
val deviceId: String,
|
val deviceId: String,
|
||||||
val identityKeyBytes: ByteArray,
|
val identityKeyBytes: ByteArray,
|
||||||
@@ -551,6 +1021,27 @@ data class DeviceBundleInfo(
|
|||||||
override fun hashCode(): Int = deviceId.hashCode()
|
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(
|
private data class SelfEncryptResult(
|
||||||
val ciphertext: ByteArray,
|
val ciphertext: ByteArray,
|
||||||
val nonce: ByteArray,
|
val nonce: ByteArray,
|
||||||
|
|||||||
@@ -151,7 +151,12 @@ class KeyStorage @Inject constructor(
|
|||||||
if (raw.size < 12) return null
|
if (raw.size < 12) return null
|
||||||
val nonce = raw.copyOfRange(0, 12)
|
val nonce = raw.copyOfRange(0, 12)
|
||||||
val ct = raw.copyOfRange(12, raw.size)
|
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) {
|
fun deleteSession(userId: String, deviceId: String) {
|
||||||
@@ -177,7 +182,12 @@ class KeyStorage @Inject constructor(
|
|||||||
if (raw.size < 12) return null
|
if (raw.size < 12) return null
|
||||||
val nonce = raw.copyOfRange(0, 12)
|
val nonce = raw.copyOfRange(0, 12)
|
||||||
val ct = raw.copyOfRange(12, raw.size)
|
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 =====
|
// ===== TOFU Registry =====
|
||||||
@@ -225,7 +235,12 @@ class KeyStorage @Inject constructor(
|
|||||||
if (raw.size < 12) return null
|
if (raw.size < 12) return null
|
||||||
val nonce = raw.copyOfRange(0, 12)
|
val nonce = raw.copyOfRange(0, 12)
|
||||||
val ct = raw.copyOfRange(12, raw.size)
|
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 =
|
private fun requireLocalKey(): ByteArray =
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.kecalek.chat.core
|
package com.kecalek.chat.core
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import com.kecalek.chat.crypto.RSACrypto
|
import com.kecalek.chat.crypto.RSACrypto
|
||||||
import com.kecalek.chat.network.ConnectionManager
|
import com.kecalek.chat.network.ConnectionManager
|
||||||
import com.kecalek.chat.network.ProtocolHandler
|
import com.kecalek.chat.network.ProtocolHandler
|
||||||
@@ -28,8 +29,13 @@ import javax.inject.Singleton
|
|||||||
class SessionManager @Inject constructor(
|
class SessionManager @Inject constructor(
|
||||||
private val connection: ConnectionManager,
|
private val connection: ConnectionManager,
|
||||||
private val api: ServerApi,
|
private val api: ServerApi,
|
||||||
|
private val keyStorage: KeyStorage,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SessionManager"
|
||||||
|
}
|
||||||
|
|
||||||
data class Session(
|
data class Session(
|
||||||
val userId: String,
|
val userId: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
@@ -60,13 +66,19 @@ class SessionManager @Inject constructor(
|
|||||||
private var lastRsaPrivateKey: RSAPrivateKey? = null
|
private var lastRsaPrivateKey: RSAPrivateKey? = null
|
||||||
private var lastDeviceId: String? = 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 {
|
init {
|
||||||
// Re-authenticate automatically whenever the connection is (re)established.
|
// Re-authenticate automatically whenever the connection is (re)established.
|
||||||
// During the initial login() call, lastEmail is null (cleared before connect),
|
// During the initial login() call, lastEmail is null (cleared before connect),
|
||||||
// so this handler is a no-op for the first connection.
|
// so this handler is a no-op for the first connection.
|
||||||
connection.onConnected = {
|
connection.onConnected = reconnect@{
|
||||||
val email = lastEmail ?: return@onConnected
|
val email = lastEmail ?: return@reconnect
|
||||||
val key = lastRsaPrivateKey ?: return@onConnected
|
val key = lastRsaPrivateKey ?: return@reconnect
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val session = performAuthHandshake(email, key, lastDeviceId, "Android")
|
val session = performAuthHandshake(email, key, lastDeviceId, "Android")
|
||||||
@@ -100,9 +112,13 @@ class SessionManager @Inject constructor(
|
|||||||
lastRsaPrivateKey = null
|
lastRsaPrivateKey = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (connection.state.value != ConnectionManager.State.CONNECTED) {
|
// Always start with a fresh connection.
|
||||||
connection.connect(host, port, useTls)
|
// 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)
|
val session = performAuthHandshake(email, rsaPrivateKey, deviceId, deviceName)
|
||||||
|
|
||||||
@@ -115,9 +131,12 @@ class SessionManager @Inject constructor(
|
|||||||
_authState.value = AuthState.Authenticated(session)
|
_authState.value = AuthState.Authenticated(session)
|
||||||
return session
|
return session
|
||||||
} catch (e: AuthException) {
|
} catch (e: AuthException) {
|
||||||
|
// Stop reconnect attempts — credentials aren't stored, reconnect would be useless
|
||||||
|
connection.disconnect()
|
||||||
_authState.value = AuthState.Error(e.message ?: "Login failed")
|
_authState.value = AuthState.Error(e.message ?: "Login failed")
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
connection.disconnect()
|
||||||
_authState.value = AuthState.Error(e.message ?: "Connection failed")
|
_authState.value = AuthState.Error(e.message ?: "Connection failed")
|
||||||
throw AuthException("Login failed: ${e.message}", e)
|
throw AuthException("Login failed: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -134,12 +153,29 @@ class SessionManager @Inject constructor(
|
|||||||
deviceName: String,
|
deviceName: String,
|
||||||
): Session {
|
): Session {
|
||||||
// Step 1: Request challenge
|
// Step 1: Request challenge
|
||||||
|
Log.d(TAG, "[AUTH] login_start email=$email")
|
||||||
val startResp = api.loginStart(email)
|
val startResp = api.loginStart(email)
|
||||||
if (!startResp.isOk) throw AuthException(startResp.errorMessage)
|
if (!startResp.isOk) throw AuthException(startResp.errorMessage)
|
||||||
val challengeBytes = decodeBinary(startResp.data.getString("challenge"))
|
val challengeBytes = decodeBinary(startResp.data.getString("challenge"))
|
||||||
|
Log.d(TAG, "[AUTH] challenge received: ${challengeBytes.size} bytes")
|
||||||
|
|
||||||
// Step 2: Sign challenge with RSA-PSS
|
// Step 2: Sign challenge with RSA-PSS
|
||||||
val signature = RSACrypto.sign(rsaPrivateKey, challengeBytes)
|
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
|
// Step 3: Complete login
|
||||||
val finishResp = api.loginFinish(
|
val finishResp = api.loginFinish(
|
||||||
@@ -149,7 +185,10 @@ class SessionManager @Inject constructor(
|
|||||||
deviceId = deviceId,
|
deviceId = deviceId,
|
||||||
deviceName = deviceName,
|
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
|
val data = finishResp.data
|
||||||
return Session(
|
return Session(
|
||||||
@@ -173,6 +212,11 @@ class SessionManager @Inject constructor(
|
|||||||
port: Int,
|
port: Int,
|
||||||
useTls: Boolean = false,
|
useTls: Boolean = false,
|
||||||
): String? {
|
): String? {
|
||||||
|
// Save connection params for confirmRegistration() reconnect
|
||||||
|
lastRegistrationHost = host
|
||||||
|
lastRegistrationPort = port
|
||||||
|
lastRegistrationUseTls = useTls
|
||||||
|
|
||||||
if (connection.state.value != ConnectionManager.State.CONNECTED) {
|
if (connection.state.value != ConnectionManager.State.CONNECTED) {
|
||||||
connection.connect(host, port, useTls)
|
connection.connect(host, port, useTls)
|
||||||
}
|
}
|
||||||
@@ -188,8 +232,22 @@ class SessionManager @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirm registration with email code.
|
* 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 {
|
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)
|
val resp = api.registerConfirm(email, code)
|
||||||
if (!resp.isOk) {
|
if (!resp.isOk) {
|
||||||
throw AuthException(resp.errorMessage)
|
throw AuthException(resp.errorMessage)
|
||||||
|
|||||||
@@ -329,11 +329,19 @@ data class RatchetHeader(
|
|||||||
val pn: Int,
|
val pn: Int,
|
||||||
) {
|
) {
|
||||||
fun serialize(): ByteArray {
|
fun serialize(): ByteArray {
|
||||||
val json = JSONObject()
|
// MUST produce exactly the same bytes as Python's json.dumps({"dh_pub":…, "n":…, "pn":…})
|
||||||
json.put("dh_pub", dhPub.toHex())
|
// Python default separators: ", " and ": " → {"dh_pub": "hex", "n": 0, "pn": 0}
|
||||||
json.put("n", n)
|
// Kotlin JSONObject.toString() omits spaces → {"dh_pub":"hex","n":0,"pn":0} ← WRONG
|
||||||
json.put("pn", pn)
|
// Manual construction guarantees byte-exact AAD match with Python/iOS.
|
||||||
return json.toString().toByteArray()
|
return buildString {
|
||||||
|
append("{\"dh_pub\": \"")
|
||||||
|
append(dhPub.toHex())
|
||||||
|
append("\", \"n\": ")
|
||||||
|
append(n)
|
||||||
|
append(", \"pn\": ")
|
||||||
|
append(pn)
|
||||||
|
append("}")
|
||||||
|
}.toByteArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toMap(): Map<String, Any> = mapOf(
|
fun toMap(): Map<String, Any> = mapOf(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.kecalek.chat.crypto
|
package com.kecalek.chat.crypto
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import java.security.KeyFactory
|
import java.security.KeyFactory
|
||||||
import java.security.KeyPairGenerator
|
import java.security.KeyPairGenerator
|
||||||
import java.security.Signature
|
import java.security.Signature
|
||||||
@@ -12,16 +13,19 @@ import java.security.spec.X509EncodedKeySpec
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* RSA-4096 for login challenge-response only.
|
* 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.
|
* Private key storage: DER PKCS8 raw bytes encrypted via ECP1.
|
||||||
* Public key: DER SubjectPublicKeyInfo (X.509).
|
* Public key: DER SubjectPublicKeyInfo (X.509).
|
||||||
*
|
*
|
||||||
* Compatible with Python generate_rsa_keypair, rsa_sign, rsa_verify.
|
* Compatible with Python rsa_sign/rsa_verify and iOS SecKeyCreateSignature.
|
||||||
* Sign uses PSS with salt_length=MAX. Verify accepts MAX or hash-length salt.
|
* 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 {
|
object RSACrypto {
|
||||||
|
|
||||||
|
private const val TAG = "RSACrypto"
|
||||||
private const val KEY_SIZE = 4096
|
private const val KEY_SIZE = 4096
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,20 +80,25 @@ object RSACrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign data with RSA-PSS (SHA-256, MGF1-SHA256, max salt length).
|
* Sign data with RSA-PSS (SHA-256, MGF1-SHA256, salt_length=32).
|
||||||
* Compatible with Python rsa_sign.
|
* Matches iOS SecKeyCreateSignature(.rsaSignatureMessagePSSSHA256).
|
||||||
|
* Server verifies with PSS.AUTO which accepts any valid salt length.
|
||||||
*/
|
*/
|
||||||
fun sign(privateKey: RSAPrivateKey, data: ByteArray): ByteArray {
|
fun sign(privateKey: RSAPrivateKey, data: ByteArray): ByteArray {
|
||||||
// Max salt length = key size in bytes - hash size - 2
|
// Use hash-length salt (32 bytes for SHA-256) — same as iOS.
|
||||||
val maxSaltLen = privateKey.modulus.bitLength() / 8 - 32 - 2
|
// Server's PSS.AUTO accepts both hash-length and max-length salt.
|
||||||
|
val hashSaltLen = 32
|
||||||
val pssSpec = PSSParameterSpec(
|
val pssSpec = PSSParameterSpec(
|
||||||
"SHA-256",
|
"SHA-256",
|
||||||
"MGF1",
|
"MGF1",
|
||||||
MGF1ParameterSpec.SHA256,
|
MGF1ParameterSpec.SHA256,
|
||||||
maxSaltLen,
|
hashSaltLen,
|
||||||
1, // trailer field
|
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.setParameter(pssSpec)
|
||||||
sig.initSign(privateKey)
|
sig.initSign(privateKey)
|
||||||
sig.update(data)
|
sig.update(data)
|
||||||
@@ -124,7 +133,7 @@ object RSACrypto {
|
|||||||
saltLen,
|
saltLen,
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
val sig = Signature.getInstance("RSASSA-PSS")
|
val sig = Signature.getInstance("RSASSA-PSS", "BC")
|
||||||
sig.setParameter(pssSpec)
|
sig.setParameter(pssSpec)
|
||||||
sig.initVerify(publicKey)
|
sig.initVerify(publicKey)
|
||||||
sig.update(data)
|
sig.update(data)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.kecalek.chat.di
|
package com.kecalek.chat.di
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
import com.kecalek.chat.data.local.AppDatabase
|
import com.kecalek.chat.data.local.AppDatabase
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -9,6 +13,7 @@ import dagger.hilt.InstallIn
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||||
|
import java.security.SecureRandom
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -20,10 +25,8 @@ object AppModule {
|
|||||||
fun provideDatabase(
|
fun provideDatabase(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): AppDatabase {
|
): 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()
|
// 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)
|
val factory = SupportOpenHelperFactory(passphrase)
|
||||||
|
|
||||||
return Room.databaseBuilder(
|
return Room.databaseBuilder(
|
||||||
@@ -35,4 +38,46 @@ object AppModule {
|
|||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.build()
|
.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.kecalek.chat.ui.auth
|
package com.kecalek.chat.ui.auth
|
||||||
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.kecalek.chat.core.AuthException
|
import com.kecalek.chat.core.AuthException
|
||||||
@@ -17,16 +18,12 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.security.interfaces.RSAPrivateKey
|
|
||||||
import java.security.interfaces.RSAPublicKey
|
import java.security.interfaces.RSAPublicKey
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
|
||||||
* UI state for all auth screens (Login, Register, Pairing).
|
|
||||||
*/
|
|
||||||
data class AuthUiState(
|
data class AuthUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val loadingMessage: String? = null, // e.g. "Generating keys…", "Connecting…"
|
val loadingMessage: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val isLoggedIn: Boolean = false,
|
val isLoggedIn: Boolean = false,
|
||||||
val isRegistered: Boolean = false,
|
val isRegistered: Boolean = false,
|
||||||
@@ -42,60 +39,54 @@ data class AuthUiState(
|
|||||||
val registeredEmail: String? = null,
|
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
|
@HiltViewModel
|
||||||
class AuthViewModel @Inject constructor(
|
class AuthViewModel @Inject constructor(
|
||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val keyStorage: KeyStorage,
|
private val keyStorage: KeyStorage,
|
||||||
|
private val chatClient: com.kecalek.chat.core.ChatClient,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(AuthUiState())
|
private val _uiState = MutableStateFlow(AuthUiState())
|
||||||
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
// Holds key material between register() and confirmRegistration(). Never leaves memory.
|
// Stored temporarily between register() and confirmRegistration() so we can
|
||||||
private var pendingAuth: PendingAuth? = null
|
// 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 {
|
init {
|
||||||
_uiState.update { it.copy(hasExistingAccount = keyStorage.hasRsaKeys()) }
|
_uiState.update { it.copy(hasExistingAccount = keyStorage.hasRsaKeys()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── LOGIN ─────────────────────────────
|
||||||
|
|
||||||
fun login(emailOrUsername: String, password: String) {
|
fun login(emailOrUsername: String, password: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
performLogin(emailOrUsername, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) }
|
_uiState.update { it.copy(isLoading = true, loadingMessage = "Dešifruji klíče…", error = null) }
|
||||||
try {
|
try {
|
||||||
if (!keyStorage.hasRsaKeys()) {
|
if (!keyStorage.hasRsaKeys()) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(isLoading = false, error = "No account on this device. Register or pair first.")
|
||||||
isLoading = false,
|
|
||||||
error = "No account on this device. Register or pair first."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return@launch
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load RSA private key (decrypted with user's password via ECP1).
|
// Load RSA private key — PBKDF2 600k iterations, must run off the main thread
|
||||||
// PBKDF2 600k iterations — must run off the main thread.
|
|
||||||
val rsaPrivate = try {
|
val rsaPrivate = try {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) { keyStorage.loadRsaPrivate(password) }
|
||||||
keyStorage.loadRsaPrivate(password)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(isLoading = false, error = "Wrong password or corrupted key.") }
|
||||||
it.copy(isLoading = false, error = "Wrong password or corrupted key.")
|
return
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
|
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
|
||||||
@@ -109,15 +100,18 @@ class AuthViewModel @Inject constructor(
|
|||||||
useTls = state.useTls,
|
useTls = state.useTls,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load identity key and init local storage key (also PBKDF2 — off main thread)
|
// Initialize ChatClient: loads identity keys, derives encryption keys,
|
||||||
|
// loads TOFU registry, ensures prekeys, sets up notification handlers.
|
||||||
|
// Must be called with password (identity key is ECP1-encrypted).
|
||||||
|
_uiState.update { it.copy(loadingMessage = "Inicializuji šifrování…") }
|
||||||
if (keyStorage.hasIdentityKeys()) {
|
if (keyStorage.hasIdentityKeys()) {
|
||||||
val identityPrivate = withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
keyStorage.loadIdentityPrivate(password)
|
chatClient.initialize(password)
|
||||||
}
|
}
|
||||||
keyStorage.initLocalKey(Ed25519Crypto.serializePrivate(identityPrivate))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update { it.copy(isLoading = false, loadingMessage = null, isLoggedIn = true) }
|
_uiState.update { it.copy(isLoading = false, loadingMessage = null, isLoggedIn = true) }
|
||||||
|
|
||||||
} catch (e: AuthException) {
|
} catch (e: AuthException) {
|
||||||
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -126,60 +120,38 @@ class AuthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// ───────────────────────────── REGISTER ─────────────────────────────
|
||||||
|
|
||||||
fun register(username: String, email: String, password: String) {
|
fun register(username: String, email: String, password: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, loadingMessage = "Generuji klíče…", error = null) }
|
_uiState.update { it.copy(isLoading = true, loadingMessage = "Generuji klíče…", error = null) }
|
||||||
try {
|
try {
|
||||||
// Steps 1-4 are CPU-intensive (RSA-4096 keygen + 2× PBKDF2 600k iters).
|
// CPU-intensive: RSA-4096 keygen + 2× PBKDF2 600k iterations
|
||||||
// Run on Default dispatcher to avoid blocking the UI thread.
|
val (rsaPublicPem, identityKeyBase64) = withContext(Dispatchers.Default) {
|
||||||
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)
|
|
||||||
val (rsaPrivate, rsaPublic) = RSACrypto.generateKeypair()
|
val (rsaPrivate, rsaPublic) = RSACrypto.generateKeypair()
|
||||||
|
|
||||||
// 2. Generate Ed25519 identity keypair (fast)
|
|
||||||
val (identityPrivate, identityPublic) = Ed25519Crypto.generateKeypair()
|
val (identityPrivate, identityPublic) = Ed25519Crypto.generateKeypair()
|
||||||
|
|
||||||
// 3. Save keys encrypted with password (PBKDF2 600k iters each)
|
|
||||||
keyStorage.saveRsaKeys(rsaPrivate, rsaPublic, password)
|
keyStorage.saveRsaKeys(rsaPrivate, rsaPublic, password)
|
||||||
keyStorage.saveIdentityKeys(identityPrivate, identityPublic, password)
|
keyStorage.saveIdentityKeys(identityPrivate, identityPublic, password)
|
||||||
|
|
||||||
// 4. Convert to server format and capture private key material
|
Pair(
|
||||||
KeyMaterial(
|
rsaPublicKeyToPem(rsaPublic),
|
||||||
rsaPublicPem = rsaPublicKeyToPem(rsaPublic),
|
Base64.encodeToString(Ed25519Crypto.serializePublic(identityPublic), Base64.NO_WRAP),
|
||||||
identityKeyBase64 = Base64.encodeToString(
|
|
||||||
Ed25519Crypto.serializePublic(identityPublic),
|
|
||||||
Base64.NO_WRAP,
|
|
||||||
),
|
|
||||||
rsaPrivate = rsaPrivate,
|
|
||||||
identityPrivateBytes = Ed25519Crypto.serializePrivate(identityPrivate),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save decrypted keys for use in confirmRegistration (auto-login).
|
// Save password for auto-login after email confirmation (same pattern as iOS)
|
||||||
// pendingAuth is cleared after use or when this ViewModel is destroyed.
|
tempPassword = password
|
||||||
pendingAuth = PendingAuth(
|
|
||||||
email = email,
|
|
||||||
rsaPrivate = keys.rsaPrivate,
|
|
||||||
identityPrivateBytes = keys.identityPrivateBytes,
|
|
||||||
)
|
|
||||||
|
|
||||||
_uiState.update { it.copy(loadingMessage = "Připojuji se k serveru…") }
|
_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
|
val state = _uiState.value
|
||||||
sessionManager.register(
|
sessionManager.register(
|
||||||
username = username,
|
username = username,
|
||||||
email = email,
|
email = email,
|
||||||
rsaPublicKeyPem = keys.rsaPublicPem,
|
rsaPublicKeyPem = rsaPublicPem,
|
||||||
identityKeyBase64 = keys.identityKeyBase64,
|
identityKeyBase64 = identityKeyBase64,
|
||||||
host = state.serverHost,
|
host = state.serverHost,
|
||||||
port = state.serverPort,
|
port = state.serverPort,
|
||||||
useTls = state.useTls,
|
useTls = state.useTls,
|
||||||
@@ -196,10 +168,10 @@ class AuthViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: AuthException) {
|
} catch (e: AuthException) {
|
||||||
pendingAuth = null
|
tempPassword = null
|
||||||
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
pendingAuth = null
|
tempPassword = null
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(isLoading = false, loadingMessage = null, error = "Registration failed: ${e.message}")
|
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) {
|
fun confirmRegistration(email: String, code: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, loadingMessage = "Potvrzuji kód…", error = null) }
|
_uiState.update { it.copy(isLoading = true, loadingMessage = "Potvrzuji kód…", error = null) }
|
||||||
try {
|
try {
|
||||||
|
// Step 1: Verify the email code — errors here are genuinely "wrong code"
|
||||||
sessionManager.confirmRegistration(email, code)
|
sessionManager.confirmRegistration(email, code)
|
||||||
|
|
||||||
// Auto-login immediately after confirmation using the already-decrypted
|
// Step 2: Auto-login exactly as iOS does:
|
||||||
// key material from register(). This avoids re-asking for the password.
|
// call login() with the stored password (keys are already on disk from register())
|
||||||
val auth = pendingAuth
|
val pwd = tempPassword
|
||||||
if (auth != null && auth.email == email) {
|
tempPassword = null
|
||||||
_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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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 {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
loadingMessage = null,
|
loadingMessage = null,
|
||||||
isLoggedIn = true,
|
|
||||||
needsConfirmation = false,
|
needsConfirmation = false,
|
||||||
|
hasExistingAccount = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: AuthException) {
|
} catch (e: AuthException) {
|
||||||
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -249,32 +219,28 @@ class AuthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── PAIRING / BIOMETRIC ─────────────────────────────
|
||||||
|
|
||||||
fun startPairing() {
|
fun startPairing() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(isLoading = false, error = "Device pairing not yet implemented.") }
|
||||||
it.copy(isLoading = false, error = "Device pairing not yet implemented.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelPairing() {
|
fun cancelPairing() {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(isPairingWaiting = false, pairingCode = null, isPairingComplete = false) }
|
||||||
it.copy(isPairingWaiting = false, pairingCode = null, isPairingComplete = false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loginWithBiometric() {
|
fun loginWithBiometric() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(isLoading = false, error = "Biometric login not yet implemented.") }
|
||||||
it.copy(isLoading = false, error = "Biometric login not yet implemented.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── MISC ─────────────────────────────
|
||||||
|
|
||||||
fun updateServerConfig(host: String, port: Int, useTls: Boolean) {
|
fun updateServerConfig(host: String, port: Int, useTls: Boolean) {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(serverHost = host, serverPort = port, useTls = useTls) }
|
||||||
it.copy(serverHost = host, serverPort = port, useTls = useTls)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
@@ -282,21 +248,23 @@ class AuthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun resetState() {
|
fun resetState() {
|
||||||
pendingAuth = null
|
tempPassword = null
|
||||||
_uiState.value = AuthUiState(hasExistingAccount = keyStorage.hasRsaKeys())
|
_uiState.value = AuthUiState(hasExistingAccount = keyStorage.hasRsaKeys())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.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 {
|
companion object {
|
||||||
|
private const val TAG = "AuthViewModel"
|
||||||
|
|
||||||
fun rsaPublicKeyToPem(key: RSAPublicKey): String {
|
fun rsaPublicKeyToPem(key: RSAPublicKey): String {
|
||||||
val der = key.encoded
|
val der = key.encoded
|
||||||
val base64 = Base64.encodeToString(der, Base64.NO_WRAP)
|
val base64 = Base64.encodeToString(der, Base64.NO_WRAP)
|
||||||
val lines = base64.chunked(64).joinToString("\n")
|
val lines = base64.chunked(64).joinToString("\n")
|
||||||
return "-----BEGIN PUBLIC KEY-----\n$lines\n-----END PUBLIC KEY-----"
|
return "-----BEGIN PUBLIC KEY-----\n$lines\n-----END PUBLIC KEY-----\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ fun LoginScreen(
|
|||||||
LaunchedEffect(uiState.isLoggedIn) {
|
LaunchedEffect(uiState.isLoggedIn) {
|
||||||
if (uiState.isLoggedIn) {
|
if (uiState.isLoggedIn) {
|
||||||
navController.navigate(Routes.CONVERSATION_LIST) {
|
navController.navigate(Routes.CONVERSATION_LIST) {
|
||||||
popUpTo(Routes.LOGIN) { inclusive = true }
|
popUpTo(Routes.AUTH_GRAPH) { inclusive = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ fun PairingScreen(
|
|||||||
LaunchedEffect(uiState.isLoggedIn) {
|
LaunchedEffect(uiState.isLoggedIn) {
|
||||||
if (uiState.isLoggedIn) {
|
if (uiState.isLoggedIn) {
|
||||||
navController.navigate(Routes.CONVERSATION_LIST) {
|
navController.navigate(Routes.CONVERSATION_LIST) {
|
||||||
popUpTo(Routes.LOGIN) { inclusive = true }
|
popUpTo(Routes.AUTH_GRAPH) { inclusive = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.kecalek.chat.ui.auth
|
package com.kecalek.chat.ui.auth
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -43,6 +44,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -58,6 +60,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.kecalek.chat.ui.navigation.Routes
|
import com.kecalek.chat.ui.navigation.Routes
|
||||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -67,6 +70,8 @@ fun RegisterScreen(
|
|||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
var username by rememberSaveable { mutableStateOf("") }
|
var username by rememberSaveable { mutableStateOf("") }
|
||||||
var email by rememberSaveable { mutableStateOf("") }
|
var email by rememberSaveable { mutableStateOf("") }
|
||||||
@@ -80,11 +85,27 @@ fun RegisterScreen(
|
|||||||
LaunchedEffect(uiState.isLoggedIn) {
|
LaunchedEffect(uiState.isLoggedIn) {
|
||||||
if (uiState.isLoggedIn) {
|
if (uiState.isLoggedIn) {
|
||||||
navController.navigate(Routes.CONVERSATION_LIST) {
|
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(
|
val textFieldColors = OutlinedTextFieldDefaults.colors(
|
||||||
focusedTextColor = CatppuccinMocha.Text,
|
focusedTextColor = CatppuccinMocha.Text,
|
||||||
unfocusedTextColor = CatppuccinMocha.Text,
|
unfocusedTextColor = CatppuccinMocha.Text,
|
||||||
@@ -141,7 +162,7 @@ fun RegisterScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 400.dp)
|
.widthIn(max = 400.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(scrollState),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Top,
|
verticalArrangement = Arrangement.Top,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
package com.kecalek.chat.ui.chat
|
package com.kecalek.chat.ui.chat
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import com.kecalek.chat.data.model.Conversation
|
import kotlinx.coroutines.launch
|
||||||
import com.kecalek.chat.data.model.ConversationMember
|
import org.json.JSONObject
|
||||||
import com.kecalek.chat.data.model.Message
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class ChatUiState(
|
data class ChatUiState(
|
||||||
@@ -29,44 +45,485 @@ data class ChatUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ChatViewModel @Inject constructor(
|
class ChatViewModel @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
// TODO: Inject repositories
|
private val chatClient: ChatClient,
|
||||||
|
private val api: ServerApi,
|
||||||
|
private val messageRepository: MessageRepository,
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ChatViewModel"
|
||||||
|
}
|
||||||
|
|
||||||
val conversationId: String = savedStateHandle["conversationId"] ?: ""
|
val conversationId: String = savedStateHandle["conversationId"] ?: ""
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ChatUiState())
|
private val _uiState = MutableStateFlow(ChatUiState())
|
||||||
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
fun loadMessages() {
|
init {
|
||||||
// TODO: Load from cache + incremental sync from server
|
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) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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?) {
|
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) {
|
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() {
|
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() {
|
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() {
|
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) {
|
fun downloadFile(fileId: String) {
|
||||||
// TODO: Download and decrypt file
|
// TODO: Loop downloadImage(fileId, offset) until done → AES decrypt → save to storage
|
||||||
|
Log.w(TAG, "downloadFile: not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Private Helpers =====
|
||||||
|
|
||||||
|
private suspend fun refreshMessagesFromDb() {
|
||||||
|
val messages = messageRepository.getMessages(conversationId)
|
||||||
|
_uiState.value = _uiState.value.copy(messages = messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonObjectToMap(obj: JSONObject): Map<String, Any> {
|
||||||
|
val map = mutableMapOf<String, Any>()
|
||||||
|
obj.keys().forEach { key ->
|
||||||
|
map[key] = obj.get(key)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTimestamp(ts: String?): Date? {
|
||||||
|
if (ts.isNullOrEmpty()) return null
|
||||||
|
return DateFormatter.parse(ts) ?: try {
|
||||||
|
// Try standard ISO format without millis
|
||||||
|
val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
|
||||||
|
fmt.timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
fmt.parse(ts)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatIsoTimestamp(date: Date): String {
|
||||||
|
val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||||
|
fmt.timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
return fmt.format(date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package com.kecalek.chat.ui.conversations
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.kecalek.chat.core.ChatClient
|
||||||
import com.kecalek.chat.core.SessionManager
|
import com.kecalek.chat.core.SessionManager
|
||||||
import com.kecalek.chat.data.model.Conversation
|
import com.kecalek.chat.data.model.Conversation
|
||||||
import com.kecalek.chat.data.model.ConversationMember
|
import com.kecalek.chat.data.model.ConversationMember
|
||||||
import com.kecalek.chat.data.model.Invitation
|
import com.kecalek.chat.data.model.Invitation
|
||||||
import com.kecalek.chat.network.ServerApi
|
import com.kecalek.chat.network.ServerApi
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
@@ -37,6 +40,7 @@ data class ConversationListState(
|
|||||||
class ConversationListVM @Inject constructor(
|
class ConversationListVM @Inject constructor(
|
||||||
private val api: ServerApi,
|
private val api: ServerApi,
|
||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
|
private val chatClient: ChatClient,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ConversationListState())
|
private val _uiState = MutableStateFlow(ConversationListState())
|
||||||
@@ -49,11 +53,35 @@ class ConversationListVM @Inject constructor(
|
|||||||
private val _navigateToChat = MutableSharedFlow<String>()
|
private val _navigateToChat = MutableSharedFlow<String>()
|
||||||
val navigateToChat: SharedFlow<String> = _navigateToChat.asSharedFlow()
|
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 {
|
init {
|
||||||
val userId = sessionManager.currentSession?.userId ?: ""
|
val userId = sessionManager.currentSession?.userId ?: ""
|
||||||
_uiState.update { it.copy(currentUserId = userId) }
|
_uiState.update { it.copy(currentUserId = userId) }
|
||||||
loadConversations()
|
loadConversations()
|
||||||
loadInvitations()
|
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) {
|
fun onSearchQueryChanged(query: String) {
|
||||||
@@ -261,8 +289,8 @@ class ConversationListVM @Inject constructor(
|
|||||||
members.add(
|
members.add(
|
||||||
ConversationMember(
|
ConversationMember(
|
||||||
userId = m.getString("user_id"),
|
userId = m.getString("user_id"),
|
||||||
username = m.optString("username", "Unknown"),
|
username = if (m.isNull("username")) "Unknown" else m.optString("username", "Unknown"),
|
||||||
email = m.optString("email", ""),
|
email = if (m.isNull("email")) "" else m.optString("email", ""),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -270,11 +298,13 @@ class ConversationListVM @Inject constructor(
|
|||||||
|
|
||||||
return Conversation(
|
return Conversation(
|
||||||
id = json.getString("conversation_id"),
|
id = json.getString("conversation_id"),
|
||||||
name = json.optString("name", null),
|
name = if (json.isNull("name")) null else json.optString("name", null),
|
||||||
members = members,
|
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),
|
unreadCount = json.optInt("unread_count", 0),
|
||||||
lastMessageTime = parseIsoDate(json.optString("last_message_time", null)),
|
lastMessageTime = parseIsoDate(
|
||||||
|
if (json.isNull("last_message_time")) null else json.optString("last_message_time", null)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
package com.kecalek.chat.ui.navigation
|
package com.kecalek.chat.ui.navigation
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.navigation
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.kecalek.chat.core.SessionManager
|
import com.kecalek.chat.core.SessionManager
|
||||||
|
import com.kecalek.chat.ui.auth.AuthViewModel
|
||||||
import com.kecalek.chat.ui.auth.LoginScreen
|
import com.kecalek.chat.ui.auth.LoginScreen
|
||||||
import dagger.hilt.EntryPoint
|
import dagger.hilt.EntryPoint
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.kecalek.chat.ui.auth.PairingScreen
|
import com.kecalek.chat.ui.auth.PairingScreen
|
||||||
import com.kecalek.chat.ui.auth.RegisterScreen
|
import com.kecalek.chat.ui.auth.RegisterScreen
|
||||||
import com.kecalek.chat.ui.chat.ChatScreen
|
import com.kecalek.chat.ui.chat.ChatScreen
|
||||||
@@ -29,6 +33,7 @@ import java.net.URLDecoder
|
|||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
|
const val AUTH_GRAPH = "auth"
|
||||||
const val LOGIN = "login"
|
const val LOGIN = "login"
|
||||||
const val REGISTER = "register"
|
const val REGISTER = "register"
|
||||||
const val PAIRING = "pairing"
|
const val PAIRING = "pairing"
|
||||||
@@ -58,7 +63,7 @@ interface NavGraphEntryPoint {
|
|||||||
@Composable
|
@Composable
|
||||||
fun KecalekNavGraph(
|
fun KecalekNavGraph(
|
||||||
navController: NavHostController = rememberNavController(),
|
navController: NavHostController = rememberNavController(),
|
||||||
startDestination: String = Routes.LOGIN,
|
startDestination: String = Routes.AUTH_GRAPH,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val entryPoint = EntryPointAccessors.fromApplication(context, NavGraphEntryPoint::class.java)
|
val entryPoint = EntryPointAccessors.fromApplication(context, NavGraphEntryPoint::class.java)
|
||||||
@@ -68,14 +73,34 @@ fun KecalekNavGraph(
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination,
|
startDestination = startDestination,
|
||||||
) {
|
) {
|
||||||
composable(Routes.LOGIN) {
|
// Auth screens share a single AuthViewModel scoped to this nested graph.
|
||||||
LoginScreen(navController = navController)
|
// 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)
|
||||||
}
|
}
|
||||||
composable(Routes.REGISTER) {
|
val sharedVm: AuthViewModel = hiltViewModel(authEntry)
|
||||||
RegisterScreen(navController = navController)
|
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.PAIRING) {
|
|
||||||
PairingScreen(navController = navController)
|
|
||||||
}
|
}
|
||||||
composable(Routes.CONVERSATION_LIST) {
|
composable(Routes.CONVERSATION_LIST) {
|
||||||
ConversationListScreen(navController = navController)
|
ConversationListScreen(navController = navController)
|
||||||
@@ -133,7 +158,7 @@ fun KecalekNavGraph(
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
onLogout = {
|
onLogout = {
|
||||||
sessionManager.logout()
|
sessionManager.logout()
|
||||||
navController.navigate(Routes.LOGIN) {
|
navController.navigate(Routes.AUTH_GRAPH) {
|
||||||
popUpTo(0) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user