Initial commit: Kecalek Android client

Complete Android client for encrypted chat platform.
78+ Kotlin files: crypto (X3DH, Double Ratchet, AES-GCM, Ed25519, X25519,
RSA-PSS), network (TCP/TLS, 50 endpoints), Hilt DI, Room+SQLCipher DB,
Jetpack Compose UI with Catppuccin Mocha theme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
filip
2026-03-11 01:19:17 +01:00
commit fe861cfafa
134 changed files with 19078 additions and 0 deletions

View File

@@ -0,0 +1,657 @@
# Agent C: Data Models + Room Database
## Phase: 0 (Scaffolding)
## Depends on: Agent A (project structure must exist)
## Context
You are creating data models and Room database for "Kecalek" — an encrypted chat app.
Models must match the server JSON format exactly for wire compatibility.
The server uses snake_case JSON keys. Kotlin models use camelCase with JSON mapping.
## Task
1. Create all domain model data classes
2. Create Room entities and DAOs
3. Create AppDatabase with SQLCipher encryption support
## Files to Create
### 1. data/model/Message.kt
```kotlin
package com.kecalek.chat.data.model
import java.util.Date
data class Message(
val id: String, // server: "message_id"
val conversationId: String, // server: "conversation_id"
val senderId: String, // server: "sender_id"
var senderUsername: String, // server: "sender_username"
val createdAt: Date, // server: "created_at" (ISO 8601)
var text: String? = null,
var replyTo: String? = null, // server: "reply_to" (message_id)
var imageFileId: String? = null, // server: "image_file_id"
var file: FileInfo? = null,
var image: ImageInfo? = null,
var isDeleted: Boolean = false, // server: "is_deleted"
var readBy: Set<String> = emptySet(), // server: "read_by" (list of user_ids)
var reactions: List<MessageReaction> = emptyList(),
var forwardedFrom: ForwardedFrom? = null, // server: "forwarded_from"
var pinnedAt: Date? = null, // server: "pinned_at"
var pinnedBy: String? = null, // server: "pinned_by"
) {
fun isMine(currentUserId: String): Boolean = senderId == currentUserId
}
data class MessageReaction(
val userId: String, // server: "user_id"
val reaction: String, // server: "reaction" (e.g. "thumbsup")
val createdAt: Date, // server: "created_at"
)
data class ForwardedFrom(
val sender: String, // server: "sender" (username)
val conversationId: String, // server: "conversation_id"
val messageId: String, // server: "message_id"
)
data class FileInfo(
val fileId: String, // server: "file_id"
val aesKey: String, // server: "aes_key" (base64)
val iv: String, // server: "iv" (base64)
val filename: String,
val size: Int,
val mimeType: String, // server: "mime_type"
)
data class ImageInfo(
val fileId: String, // server: "file_id"
val aesKey: String, // server: "aes_key" (base64)
val iv: String, // server: "iv" (base64)
val thumbnail: String?, // server: "thumbnail" (base64 JPEG)
val filename: String,
val size: Int,
)
object ReactionEmoji {
val allowed = listOf("thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown")
val display = mapOf(
"thumbsup" to "\uD83D\uDC4D",
"heart" to "\u2764\uFE0F",
"laugh" to "\uD83D\uDE02",
"surprised" to "\uD83D\uDE2E",
"sad" to "\uD83D\uDE22",
"thumbsdown" to "\uD83D\uDC4E",
)
}
```
### 2. data/model/Conversation.kt
```kotlin
package com.kecalek.chat.data.model
import java.util.Date
data class Conversation(
val id: String,
var name: String? = null,
var members: List<ConversationMember> = emptyList(),
var createdBy: String? = null, // server: "created_by"
var avatarFile: String? = null, // server: "avatar_file"
var unreadCount: Int = 0,
var isFavorite: Boolean = false,
var lastMessageTime: Date? = null,
) {
val isGroup: Boolean
get() = name != null || members.size > 2
fun displayName(currentUserId: String): String {
if (!name.isNullOrEmpty()) return name!!
return members.firstOrNull { it.userId != currentUserId }?.username ?: "Unknown"
}
fun dmPartnerId(currentUserId: String): String? {
if (isGroup) return null
return members.firstOrNull { it.userId != currentUserId }?.userId
}
}
data class ConversationMember(
val userId: String, // server: "user_id"
var username: String,
var email: String,
)
```
### 3. data/model/User.kt
```kotlin
package com.kecalek.chat.data.model
data class User(
val id: String,
var username: String,
var email: String,
var identityKey: ByteArray? = null, // Ed25519 public key (32 bytes)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is User) return false
return id == other.id
}
override fun hashCode(): Int = id.hashCode()
}
data class UserProfile(
val userId: String, // server: "user_id"
var username: String? = null,
var email: String? = null,
var phone: String? = null,
var phoneVisible: Boolean = true, // server: "phone_visible"
var location: String? = null,
var locationVisible: Boolean = true, // server: "location_visible"
var avatarFile: String? = null, // server: "avatar_file"
)
```
### 4. data/model/DeviceBundle.kt
```kotlin
package com.kecalek.chat.data.model
/**
* Key bundle for one device, used in X3DH session initialization.
* All keys are raw bytes (32 bytes for X25519/Ed25519, 64 bytes for signatures).
*/
data class DeviceBundle(
val deviceId: String, // server: "device_id"
val identityKey: ByteArray, // Ed25519 public key (32 bytes)
val spk: ByteArray, // X25519 signed pre-key (32 bytes)
val spkSignature: ByteArray, // Ed25519 signature (64 bytes)
val spkId: String, // server: "signed_prekey_id" or "spk_id"
val opk: ByteArray? = null, // X25519 one-time pre-key (32 bytes, optional)
val opkId: String? = null, // server: "one_time_prekey_id" or "opk_id"
) {
companion object {
/**
* Parse from server response dictionary.
* Server uses: signed_prekey, signed_prekey_id, one_time_prekey, one_time_prekey_id (base64)
*/
fun fromMap(map: Map<String, Any?>, identityKeyOverride: ByteArray? = null): DeviceBundle {
val deviceId = map["device_id"] as? String
?: throw IllegalArgumentException("Missing device_id")
val ik = identityKeyOverride
?: android.util.Base64.decode(
map["identity_key"] as? String
?: throw IllegalArgumentException("Missing identity_key"),
android.util.Base64.DEFAULT
)
// SPK - try both naming conventions
val spkB64 = (map["signed_prekey"] as? String) ?: (map["spk"] as? String)
?: throw IllegalArgumentException("Missing signed_prekey")
val spk = android.util.Base64.decode(spkB64, android.util.Base64.DEFAULT)
val spkSigB64 = map["spk_signature"] as? String
?: throw IllegalArgumentException("Missing spk_signature")
val spkSig = android.util.Base64.decode(spkSigB64, android.util.Base64.DEFAULT)
val spkId = (map["signed_prekey_id"] as? String) ?: (map["spk_id"] as? String)
?: throw IllegalArgumentException("Missing signed_prekey_id")
// OPK - optional
val opkB64 = (map["one_time_prekey"] as? String) ?: (map["opk"] as? String)
val opk = opkB64?.let { android.util.Base64.decode(it, android.util.Base64.DEFAULT) }
val opkId = (map["one_time_prekey_id"] as? String) ?: (map["opk_id"] as? String)
return DeviceBundle(deviceId, ik, spk, spkSig, spkId, opk, opkId)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is DeviceBundle) return false
return deviceId == other.deviceId
}
override fun hashCode(): Int = deviceId.hashCode()
}
```
### 5. data/model/Invitation.kt
```kotlin
package com.kecalek.chat.data.model
data class Invitation(
val id: String,
val conversationId: String, // server: "conversation_id"
val conversationName: String, // server: "conversation_name"
val invitedBy: String, // server: "invited_by"
val invitedByUsername: String, // server: "invited_by_username"
)
```
### 6. util/Constants.kt
```kotlin
package com.kecalek.chat.util
/**
* Application-wide constants matching Python server + iOS client.
* CRITICAL: These values MUST match exactly for protocol compatibility.
*/
object Constants {
const val VERSION = "0.8.5"
const val MAX_MESSAGE_BYTES = 65536 // 64 KB
const val MAX_IMAGE_BYTES = 5 * 1024 * 1024 // 5 MB
const val MAX_FILE_BYTES = 50 * 1024 * 1024 // 50 MB
const val IMAGE_CHUNK_SIZE = 32768 // 32 KB
const val SELF_DEVICE_ID = "00000000-0000-0000-0000-000000000000"
const val OPK_REPLENISH_THRESHOLD = 20
const val OPK_BATCH_SIZE = 50
const val SPK_ROTATION_DAYS = 7
const val MAX_SKIP = 256
const val MAX_SENDER_KEY_SKIP = 256
const val DEVICE_BUNDLE_CACHE_TTL_MS = 300_000L // 5 minutes
const val SEND_RECEIVE_TIMEOUT_MS = 30_000L // 30 seconds
const val RECONNECT_BASE_DELAY_MS = 1_000L // 1 second
const val RECONNECT_MAX_DELAY_MS = 30_000L // 30 seconds
const val PBKDF2_ITERATIONS = 600_000
val ECP1_MAGIC = byteArrayOf(0x45, 0x43, 0x50, 0x31) // "ECP1"
// HKDF info/salt strings — MUST match Python/iOS exactly
const val X3DH_INFO = "EncryptedChat_X3DH"
const val ROOT_KEY_INFO = "EncryptedChat_RootKey"
const val SELF_ENCRYPTION_SALT = "self_encryption"
const val SELF_ENCRYPTION_INFO = "EncryptedChat_SelfKey"
const val LOCAL_STORAGE_SALT = "local_storage"
const val LOCAL_STORAGE_INFO = "EncryptedChat_LocalStorage"
const val SENDER_KEY_CHAIN_INFO = "SenderKeyChain"
// Default server connection
const val DEFAULT_HOST = "chat.ai-tech.news"
const val DEFAULT_PORT = 9999
}
```
### 7. data/local/entity/MessageEntity.kt
```kotlin
package com.kecalek.chat.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey
val id: String,
@ColumnInfo(name = "conversation_id", index = true)
val conversationId: String,
@ColumnInfo(name = "sender_id")
val senderId: String,
@ColumnInfo(name = "sender_username")
val senderUsername: String,
@ColumnInfo(name = "created_at")
val createdAt: Long, // epoch millis
val text: String? = null,
@ColumnInfo(name = "reply_to")
val replyTo: String? = null,
@ColumnInfo(name = "image_file_id")
val imageFileId: String? = null,
@ColumnInfo(name = "file_json")
val fileJson: String? = null, // JSON-serialized FileInfo
@ColumnInfo(name = "image_json")
val imageJson: String? = null, // JSON-serialized ImageInfo
@ColumnInfo(name = "is_deleted")
val isDeleted: Boolean = false,
@ColumnInfo(name = "read_by_json")
val readByJson: String? = null, // JSON array of user_ids
@ColumnInfo(name = "reactions_json")
val reactionsJson: String? = null, // JSON array of reactions
@ColumnInfo(name = "forwarded_from_json")
val forwardedFromJson: String? = null,
@ColumnInfo(name = "pinned_at")
val pinnedAt: Long? = null,
@ColumnInfo(name = "pinned_by")
val pinnedBy: String? = null,
)
```
### 8. data/local/entity/ConversationEntity.kt
```kotlin
package com.kecalek.chat.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "conversations")
data class ConversationEntity(
@PrimaryKey
val id: String,
val name: String? = null,
@ColumnInfo(name = "created_by")
val createdBy: String? = null,
@ColumnInfo(name = "avatar_file")
val avatarFile: String? = null,
@ColumnInfo(name = "unread_count")
val unreadCount: Int = 0,
@ColumnInfo(name = "is_favorite")
val isFavorite: Boolean = false,
@ColumnInfo(name = "last_message_time")
val lastMessageTime: Long? = null,
@ColumnInfo(name = "members_json")
val membersJson: String? = null, // JSON array of ConversationMember
)
```
### 9. data/local/entity/UserCacheEntity.kt
```kotlin
package com.kecalek.chat.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "user_cache")
data class UserCacheEntity(
@PrimaryKey
val id: String,
val username: String,
val email: String,
@ColumnInfo(name = "identity_key", typeAffinity = ColumnInfo.BLOB)
val identityKey: ByteArray? = null,
@ColumnInfo(name = "updated_at")
val updatedAt: Long = System.currentTimeMillis(),
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is UserCacheEntity) return false
return id == other.id
}
override fun hashCode(): Int = id.hashCode()
}
```
### 10. data/local/dao/MessageDao.kt
```kotlin
package com.kecalek.chat.data.local.dao
import androidx.room.*
import com.kecalek.chat.data.local.entity.MessageEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId ORDER BY created_at ASC")
fun getMessagesFlow(conversationId: String): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId ORDER BY created_at ASC")
suspend fun getMessages(conversationId: String): List<MessageEntity>
@Query("SELECT * FROM messages WHERE id = :messageId")
suspend fun getMessage(messageId: String): MessageEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(messages: List<MessageEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(message: MessageEntity)
@Update
suspend fun update(message: MessageEntity)
@Query("UPDATE messages SET is_deleted = 1 WHERE id = :messageId")
suspend fun markDeleted(messageId: String)
@Query("UPDATE messages SET reactions_json = :reactionsJson WHERE id = :messageId")
suspend fun updateReactions(messageId: String, reactionsJson: String)
@Query("UPDATE messages SET pinned_at = :pinnedAt, pinned_by = :pinnedBy WHERE id = :messageId")
suspend fun updatePinStatus(messageId: String, pinnedAt: Long?, pinnedBy: String?)
@Query("UPDATE messages SET read_by_json = :readByJson WHERE id = :messageId")
suspend fun updateReadBy(messageId: String, readByJson: String)
@Query("DELETE FROM messages WHERE conversation_id = :conversationId")
suspend fun deleteByConversation(conversationId: String)
@Query("SELECT MAX(created_at) FROM messages WHERE conversation_id = :conversationId")
suspend fun getLatestTimestamp(conversationId: String): Long?
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId AND pinned_at IS NOT NULL ORDER BY pinned_at DESC")
suspend fun getPinnedMessages(conversationId: String): List<MessageEntity>
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId AND text LIKE '%' || :query || '%' ORDER BY created_at ASC")
suspend fun searchMessages(conversationId: String, query: String): List<MessageEntity>
}
```
### 11. data/local/dao/ConversationDao.kt
```kotlin
package com.kecalek.chat.data.local.dao
import androidx.room.*
import com.kecalek.chat.data.local.entity.ConversationEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ConversationDao {
@Query("SELECT * FROM conversations ORDER BY is_favorite DESC, last_message_time DESC")
fun getAllFlow(): Flow<List<ConversationEntity>>
@Query("SELECT * FROM conversations ORDER BY is_favorite DESC, last_message_time DESC")
suspend fun getAll(): List<ConversationEntity>
@Query("SELECT * FROM conversations WHERE id = :conversationId")
suspend fun getById(conversationId: String): ConversationEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(conversations: List<ConversationEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(conversation: ConversationEntity)
@Update
suspend fun update(conversation: ConversationEntity)
@Query("UPDATE conversations SET unread_count = :count WHERE id = :conversationId")
suspend fun updateUnreadCount(conversationId: String, count: Int)
@Query("UPDATE conversations SET is_favorite = :isFavorite WHERE id = :conversationId")
suspend fun updateFavorite(conversationId: String, isFavorite: Boolean)
@Query("UPDATE conversations SET name = :name WHERE id = :conversationId")
suspend fun updateName(conversationId: String, name: String)
@Query("DELETE FROM conversations WHERE id = :conversationId")
suspend fun delete(conversationId: String)
}
```
### 12. data/local/dao/UserCacheDao.kt
```kotlin
package com.kecalek.chat.data.local.dao
import androidx.room.*
import com.kecalek.chat.data.local.entity.UserCacheEntity
@Dao
interface UserCacheDao {
@Query("SELECT * FROM user_cache WHERE id = :userId")
suspend fun getById(userId: String): UserCacheEntity?
@Query("SELECT * FROM user_cache WHERE email = :email")
suspend fun getByEmail(email: String): UserCacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(user: UserCacheEntity)
@Query("DELETE FROM user_cache")
suspend fun deleteAll()
}
```
### 13. data/local/AppDatabase.kt
```kotlin
package com.kecalek.chat.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import com.kecalek.chat.data.local.dao.ConversationDao
import com.kecalek.chat.data.local.dao.MessageDao
import com.kecalek.chat.data.local.dao.UserCacheDao
import com.kecalek.chat.data.local.entity.ConversationEntity
import com.kecalek.chat.data.local.entity.MessageEntity
import com.kecalek.chat.data.local.entity.UserCacheEntity
@Database(
entities = [
MessageEntity::class,
ConversationEntity::class,
UserCacheEntity::class,
],
version = 1,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
abstract fun conversationDao(): ConversationDao
abstract fun userCacheDao(): UserCacheDao
}
```
### 14. util/Base64Utils.kt
```kotlin
package com.kecalek.chat.util
import android.util.Base64
/**
* Base64 encoding/decoding matching Python protocol.py encode_binary/decode_binary.
*/
object Base64Utils {
fun encode(data: ByteArray): String =
Base64.encodeToString(data, Base64.NO_WRAP)
fun decode(data: String): ByteArray =
Base64.decode(data, Base64.DEFAULT)
}
```
### 15. util/DateFormatter.kt
```kotlin
package com.kecalek.chat.util
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Date parsing/formatting matching server ISO 8601 format.
*/
object DateFormatter {
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private val isoFormatWithMillis = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
fun parse(dateString: String): Date? {
return try {
if (dateString.contains(".")) {
isoFormatWithMillis.parse(dateString)
} else {
isoFormat.parse(dateString)
}
} catch (e: Exception) {
null
}
}
fun format(date: Date): String = isoFormat.format(date)
fun formatRelative(date: Date): String {
val now = System.currentTimeMillis()
val diff = now - date.time
return when {
diff < 60_000 -> "now"
diff < 3_600_000 -> "${diff / 60_000}m"
diff < 86_400_000 -> "${diff / 3_600_000}h"
diff < 604_800_000 -> "${diff / 86_400_000}d"
else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(date)
}
}
fun formatTime(date: Date): String =
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
}
```
## Server JSON Field Mapping (CRITICAL)
These are the exact field names the server uses. Models must parse these correctly:
```json
// Message from get_messages
{
"message_id": "uuid",
"conversation_id": "uuid",
"sender_id": "uuid",
"sender_username": "name",
"created_at": "2024-01-01T12:00:00",
"text": "hello",
"reply_to": "uuid or null",
"image_file_id": "uuid or null",
"is_deleted": false,
"read_by": ["uuid1", "uuid2"],
"reactions": [{"user_id": "uuid", "reaction": "heart", "created_at": "..."}],
"forwarded_from": {"sender": "username", "conversation_id": "uuid", "message_id": "uuid"},
"pinned_at": "2024-01-01T12:00:00 or null",
"pinned_by": "uuid or null",
"file": {"file_id": "uuid", "aes_key": "b64", "iv": "b64", "filename": "doc.pdf", "size": 1234, "mime_type": "application/pdf"},
"image": {"file_id": "uuid", "aes_key": "b64", "iv": "b64", "thumbnail": "b64_jpeg", "filename": "photo.jpg", "size": 5678}
}
// Conversation from list_conversations
{
"conversation_id": "uuid",
"name": "Group Name or null",
"created_by": "uuid",
"avatar_file": "filename or null",
"unread_count": 3,
"members": [{"user_id": "uuid", "username": "name", "email": "email"}]
}
// Key bundle from get_key_bundle
{
"device_id": "uuid",
"identity_key": "base64",
"signed_prekey": "base64", // also accepts "spk"
"spk_signature": "base64",
"signed_prekey_id": "string", // also accepts "spk_id"
"one_time_prekey": "base64", // also accepts "opk", optional
"one_time_prekey_id": "string" // also accepts "opk_id", optional
}
```
## Constraints
- Use `Long` (epoch millis) for dates in Room entities, `Date` in domain models
- JSON serialization for complex fields in Room (reactions, members, file info)
- Room entity field names use snake_case (matching SQL conventions)
- Domain model field names use camelCase (Kotlin conventions)
- All DAO query methods must be `suspend fun` or return `Flow`
- Index `conversation_id` on messages table for query performance
## DO NOT
- Implement database encryption setup (that's in DI module)
- Create repository implementations (that's Agent H)
- Implement any cryptographic operations
- Add any UI code