Start preview
This commit is contained in:
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
|
||||
```
|
||||
Reference in New Issue
Block a user