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:
280
specs/agent-h-repositories.md
Normal file
280
specs/agent-h-repositories.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# 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
|
||||
```kotlin
|
||||
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
|
||||
```kotlin
|
||||
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
|
||||
```kotlin
|
||||
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
|
||||
Reference in New Issue
Block a user