# 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 = emptySet(), // server: "read_by" (list of user_ids) var reactions: List = 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 = 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, 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> @Query("SELECT * FROM messages WHERE conversation_id = :conversationId ORDER BY created_at ASC") suspend fun getMessages(conversationId: String): List @Query("SELECT * FROM messages WHERE id = :messageId") suspend fun getMessage(messageId: String): MessageEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(messages: List) @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 @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 } ``` ### 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> @Query("SELECT * FROM conversations ORDER BY is_favorite DESC, last_message_time DESC") suspend fun getAll(): List @Query("SELECT * FROM conversations WHERE id = :conversationId") suspend fun getById(conversationId: String): ConversationEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(conversations: List) @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