Files
Kecalek/specs/agent-c-data-models-room.md
filip fe861cfafa 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>
2026-03-11 01:19:17 +01:00

22 KiB

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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:

// 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