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>
658 lines
22 KiB
Markdown
658 lines
22 KiB
Markdown
# 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
|