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>
9.7 KiB
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
suspendfunctions - 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