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:
657
specs/agent-c-data-models-room.md
Normal file
657
specs/agent-c-data-models-room.md
Normal 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
|
||||
Reference in New Issue
Block a user