Files
Kecalek/specs/agent-h-repositories.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

9.7 KiB

Agent H: Repository Implementations

Phase: 3 (Core Logic)

Depends on: Agent A, Agent C (models + Room database), Agent I (DI modules)

Context

Repositories provide the data layer between ViewModels and data sources (Room DB + server). They handle caching, data transformation between entities and domain models. Server communication is delegated to ChatClient (not implemented here).

Task

Create repository implementations for messages, conversations, and users.

Files to Create

1. data/repository/MessageRepository.kt

package com.kecalek.chat.data.repository

import com.kecalek.chat.data.local.dao.MessageDao
import com.kecalek.chat.data.local.entity.MessageEntity
import com.kecalek.chat.data.model.*
import com.kecalek.chat.util.DateFormatter
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class MessageRepository @Inject constructor(
    private val messageDao: MessageDao,
) {
    private val json = Json { ignoreUnknownKeys = true }

    fun getMessagesFlow(conversationId: String): Flow<List<Message>> =
        messageDao.getMessagesFlow(conversationId).map { entities ->
            entities.map { it.toDomain() }
        }

    suspend fun getMessages(conversationId: String): List<Message> =
        messageDao.getMessages(conversationId).map { it.toDomain() }

    suspend fun getMessage(messageId: String): Message? =
        messageDao.getMessage(messageId)?.toDomain()

    suspend fun saveMessages(messages: List<Message>) {
        messageDao.insertAll(messages.map { it.toEntity() })
    }

    suspend fun saveMessage(message: Message) {
        messageDao.insert(message.toEntity())
    }

    suspend fun markDeleted(messageId: String) {
        messageDao.markDeleted(messageId)
    }

    suspend fun updateReactions(messageId: String, reactions: List<MessageReaction>) {
        val reactionsJson = reactions.joinToString(",", "[", "]") { r ->
            """{"user_id":"${r.userId}","reaction":"${r.reaction}","created_at":"${DateFormatter.format(r.createdAt)}"}"""
        }
        messageDao.updateReactions(messageId, reactionsJson)
    }

    suspend fun updatePinStatus(messageId: String, pinnedAt: Date?, pinnedBy: String?) {
        messageDao.updatePinStatus(messageId, pinnedAt?.time, pinnedBy)
    }

    suspend fun updateReadBy(messageId: String, readBy: Set<String>) {
        val readByJson = readBy.joinToString(",", "[", "]") { "\"$it\"" }
        messageDao.updateReadBy(messageId, readByJson)
    }

    suspend fun deleteByConversation(conversationId: String) {
        messageDao.deleteByConversation(conversationId)
    }

    suspend fun getLatestTimestamp(conversationId: String): Long? =
        messageDao.getLatestTimestamp(conversationId)

    suspend fun getPinnedMessages(conversationId: String): List<Message> =
        messageDao.getPinnedMessages(conversationId).map { it.toDomain() }

    suspend fun searchMessages(conversationId: String, query: String): List<Message> =
        messageDao.searchMessages(conversationId, query).map { it.toDomain() }

    // --- Entity <-> Domain mapping ---

    private fun MessageEntity.toDomain(): Message = Message(
        id = id,
        conversationId = conversationId,
        senderId = senderId,
        senderUsername = senderUsername,
        createdAt = Date(createdAt),
        text = text,
        replyTo = replyTo,
        imageFileId = imageFileId,
        file = fileJson?.let { parseFileInfo(it) },
        image = imageJson?.let { parseImageInfo(it) },
        isDeleted = isDeleted,
        readBy = readByJson?.let { parseStringSet(it) } ?: emptySet(),
        reactions = reactionsJson?.let { parseReactions(it) } ?: emptyList(),
        forwardedFrom = forwardedFromJson?.let { parseForwardedFrom(it) },
        pinnedAt = pinnedAt?.let { Date(it) },
        pinnedBy = pinnedBy,
    )

    private fun Message.toEntity(): MessageEntity = MessageEntity(
        id = id,
        conversationId = conversationId,
        senderId = senderId,
        senderUsername = senderUsername,
        createdAt = createdAt.time,
        text = text,
        replyTo = replyTo,
        imageFileId = imageFileId,
        fileJson = file?.let { serializeFileInfo(it) },
        imageJson = image?.let { serializeImageInfo(it) },
        isDeleted = isDeleted,
        readByJson = if (readBy.isNotEmpty()) readBy.joinToString(",", "[", "]") { "\"$it\"" } else null,
        reactionsJson = if (reactions.isNotEmpty()) serializeReactions(reactions) else null,
        forwardedFromJson = forwardedFrom?.let { serializeForwardedFrom(it) },
        pinnedAt = pinnedAt?.time,
        pinnedBy = pinnedBy,
    )

    // TODO: Implement JSON serialization helpers
    private fun parseFileInfo(json: String): FileInfo? = null // TODO
    private fun parseImageInfo(json: String): ImageInfo? = null // TODO
    private fun parseStringSet(json: String): Set<String> = emptySet() // TODO
    private fun parseReactions(json: String): List<MessageReaction> = emptyList() // TODO
    private fun parseForwardedFrom(json: String): ForwardedFrom? = null // TODO
    private fun serializeFileInfo(info: FileInfo): String = "" // TODO
    private fun serializeImageInfo(info: ImageInfo): String = "" // TODO
    private fun serializeReactions(reactions: List<MessageReaction>): String = "" // TODO
    private fun serializeForwardedFrom(fwd: ForwardedFrom): String = "" // TODO
}

2. data/repository/ConversationRepository.kt

package com.kecalek.chat.data.repository

import com.kecalek.chat.data.local.dao.ConversationDao
import com.kecalek.chat.data.local.entity.ConversationEntity
import com.kecalek.chat.data.model.Conversation
import com.kecalek.chat.data.model.ConversationMember
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class ConversationRepository @Inject constructor(
    private val conversationDao: ConversationDao,
) {
    fun getAllFlow(): Flow<List<Conversation>> =
        conversationDao.getAllFlow().map { entities ->
            entities.map { it.toDomain() }
        }

    suspend fun getAll(): List<Conversation> =
        conversationDao.getAll().map { it.toDomain() }

    suspend fun getById(conversationId: String): Conversation? =
        conversationDao.getById(conversationId)?.toDomain()

    suspend fun saveAll(conversations: List<Conversation>) {
        conversationDao.insertAll(conversations.map { it.toEntity() })
    }

    suspend fun save(conversation: Conversation) {
        conversationDao.insert(conversation.toEntity())
    }

    suspend fun updateUnreadCount(conversationId: String, count: Int) {
        conversationDao.updateUnreadCount(conversationId, count)
    }

    suspend fun toggleFavorite(conversationId: String, isFavorite: Boolean) {
        conversationDao.updateFavorite(conversationId, isFavorite)
    }

    suspend fun updateName(conversationId: String, name: String) {
        conversationDao.updateName(conversationId, name)
    }

    suspend fun delete(conversationId: String) {
        conversationDao.delete(conversationId)
    }

    // --- Entity <-> Domain mapping ---

    private fun ConversationEntity.toDomain(): Conversation = Conversation(
        id = id,
        name = name,
        members = membersJson?.let { parseMembers(it) } ?: emptyList(),
        createdBy = createdBy,
        avatarFile = avatarFile,
        unreadCount = unreadCount,
        isFavorite = isFavorite,
        lastMessageTime = lastMessageTime?.let { Date(it) },
    )

    private fun Conversation.toEntity(): ConversationEntity = ConversationEntity(
        id = id,
        name = name,
        createdBy = createdBy,
        avatarFile = avatarFile,
        unreadCount = unreadCount,
        isFavorite = isFavorite,
        lastMessageTime = lastMessageTime?.time,
        membersJson = serializeMembers(members),
    )

    // TODO: Implement JSON serialization helpers
    private fun parseMembers(json: String): List<ConversationMember> = emptyList() // TODO
    private fun serializeMembers(members: List<ConversationMember>): String = "" // TODO
}

3. data/repository/UserRepository.kt

package com.kecalek.chat.data.repository

import com.kecalek.chat.data.local.dao.UserCacheDao
import com.kecalek.chat.data.local.entity.UserCacheEntity
import com.kecalek.chat.data.model.User
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class UserRepository @Inject constructor(
    private val userCacheDao: UserCacheDao,
) {
    suspend fun getUser(userId: String): User? =
        userCacheDao.getById(userId)?.toDomain()

    suspend fun getUserByEmail(email: String): User? =
        userCacheDao.getByEmail(email)?.toDomain()

    suspend fun cacheUser(user: User) {
        userCacheDao.insert(user.toEntity())
    }

    suspend fun clearCache() {
        userCacheDao.deleteAll()
    }

    private fun UserCacheEntity.toDomain(): User = User(
        id = id,
        username = username,
        email = email,
        identityKey = identityKey,
    )

    private fun User.toEntity(): UserCacheEntity = UserCacheEntity(
        id = id,
        username = username,
        email = email,
        identityKey = identityKey,
    )
}

Constraints

  • All repositories are @Singleton (Hilt scope)
  • All data operations are suspend functions
  • Flow-based observers for real-time UI updates
  • JSON serialization for complex fields (reactions, members, file info)
  • Entity <-> Domain model mapping is bidirectional

DO NOT

  • Implement actual server API calls (that's in ChatClient)
  • Implement any crypto operations
  • Create new Room entities or DAOs (use existing from Agent C)
  • Add UI code