# 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> // reactive Room getMessages(conversationId): List // suspend getMessage(messageId): Message? // suspend getPinnedMessages(conversationId): List // suspend searchMessages(conversationId, query): List // suspend, full-text insertMessage(message) // suspend insertMessages(messages) // suspend markDeleted(messageId) // suspend, soft-delete updateReactions(messageId, reactions: List) updatePinStatus(messageId, pinnedAt: Date?, pinnedBy: String?) updateReadBy(messageId, readBy: Set) 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 ```