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:
filip
2026-03-11 01:19:17 +01:00
commit fe861cfafa
134 changed files with 19078 additions and 0 deletions

View 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