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,341 @@
# Agent A: Gradle + Project Setup
## Phase: 0 (Scaffolding)
## Priority: FIRST — blocks all other agents
## Context
You are setting up an Android project for "Kecalek" — an end-to-end encrypted chat application.
The app uses Signal Protocol (X3DH + Double Ratchet + Sender Keys) for encryption.
This is a Kotlin-first project with Jetpack Compose UI.
## Task
Create the complete Android project structure with Gradle build files, manifest, and empty package directories.
## Files to Create
### Root Level
```
android/
├── build.gradle.kts (project-level)
├── settings.gradle.kts
├── gradle.properties
├── gradle/
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── app/
├── build.gradle.kts (app-level)
└── src/
├── main/
│ ├── AndroidManifest.xml
│ ├── java/com/kecalek/chat/ (package dirs)
│ └── res/
│ ├── values/
│ │ ├── strings.xml
│ │ ├── themes.xml
│ │ └── colors.xml
│ ├── mipmap-hdpi/
│ ├── mipmap-mdpi/
│ ├── mipmap-xhdpi/
│ ├── mipmap-xxhdpi/
│ └── mipmap-xxxhdpi/
└── test/
└── java/com/kecalek/chat/
```
### Project-Level build.gradle.kts
```kotlin
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
id("com.google.dagger.hilt.android") version "2.50" apply false
id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false
}
```
### settings.gradle.kts
```kotlin
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolution {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Kecalek"
include(":app")
```
### App-Level build.gradle.kts
```kotlin
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
android {
namespace = "com.kecalek.chat"
compileSdk = 34
defaultConfig {
applicationId = "com.kecalek.chat"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.8.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
}
dependencies {
// Compose BOM
val composeBom = platform("androidx.compose:compose-bom:2024.02.00")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.navigation:navigation-compose:2.7.7")
// Hilt DI
implementation("com.google.dagger:hilt-android:2.50")
ksp("com.google.dagger:hilt-compiler:2.50")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
// Room + SQLCipher
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("net.zetetic:android-database-sqlcipher:4.5.4")
implementation("androidx.sqlite:sqlite-ktx:2.4.0")
// Crypto: Tink + Bouncy Castle
implementation("com.google.crypto.tink:tink-android:1.12.0")
implementation("org.bouncycastle:bcprov-jdk18on:1.77")
implementation("org.bouncycastle:bcpkix-jdk18on:1.77")
// Image loading
implementation("io.coil-kt:coil-compose:2.5.0")
// QR code
implementation("com.google.zxing:core:3.5.3")
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
// Camera (for QR scanning)
implementation("androidx.camera:camera-camera2:1.3.1")
implementation("androidx.camera:camera-lifecycle:1.3.1")
implementation("androidx.camera:camera-view:1.3.1")
// Biometric
implementation("androidx.biometric:biometric:1.1.0")
// DataStore (encrypted preferences)
implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// JSON
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
```
### AndroidManifest.xml
```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application
android:name=".KecalekApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Kecalek"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Kecalek">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
```
### gradle.properties
```properties
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
```
### res/values/strings.xml
```xml
<resources>
<string name="app_name">Kecalek</string>
</resources>
```
### res/xml/network_security_config.xml
```xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Debug only: allow cleartext for local dev -->
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
```
## Package Directory Structure
Create these empty directories under `app/src/main/java/com/kecalek/chat/`:
```
di/
crypto/
network/
core/
data/
data/local/
data/local/dao/
data/local/entity/
data/model/
data/repository/
ui/
ui/theme/
ui/navigation/
ui/auth/
ui/conversations/
ui/chat/
ui/groups/
ui/profile/
ui/verification/
ui/devices/
ui/components/
util/
```
## Placeholder Files
Create these minimal placeholder files:
### KecalekApp.kt
```kotlin
package com.kecalek.chat
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class KecalekApp : Application()
```
### MainActivity.kt
```kotlin
package com.kecalek.chat
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// TODO: NavGraph entry point
}
}
}
```
## Constraints
- Min SDK 26 (Android 8.0)
- Target SDK 34 (Android 14)
- Kotlin 1.9.22
- Compose BOM 2024.02.00
- Java 17 compatibility
- Do NOT add any business logic
- Do NOT create any UI components beyond the placeholder MainActivity
- All `TODO` comments should be brief and descriptive
## DO NOT
- Implement any cryptographic operations
- Add any UI screens or composables
- Add business logic or ViewModels
- Modify any files outside the android/ directory

View File

@@ -0,0 +1,347 @@
# Agent B: Theme + Navigation
## Phase: 0 (Scaffolding)
## Depends on: Agent A (project structure must exist)
## Context
You are building the theme system and navigation graph for "Kecalek" — an encrypted chat Android app.
The app uses a Catppuccin Mocha dark theme (Signal-like appearance).
## Task
1. Implement Catppuccin Mocha dark theme in Jetpack Compose Material 3
2. Set up Compose Navigation with all app routes
3. Update MainActivity to use the theme and navigation
## Files to Create
### 1. ui/theme/Color.kt
```kotlin
package com.kecalek.chat.ui.theme
import androidx.compose.ui.graphics.Color
// Catppuccin Mocha palette
object CatppuccinMocha {
val Rosewater = Color(0xFFF5E0DC)
val Flamingo = Color(0xFFF2CDCD)
val Pink = Color(0xFFF5C2E7)
val Mauve = Color(0xFFCBA6F7)
val Red = Color(0xFFF38BA8)
val Maroon = Color(0xFFEBA0AC)
val Peach = Color(0xFFFAB387)
val Yellow = Color(0xFFF9E2AF)
val Green = Color(0xFFA6E3A1)
val Teal = Color(0xFF94E2D5)
val Sky = Color(0xFF89DCEB)
val Sapphire = Color(0xFF74C7EC)
val Blue = Color(0xFF89B4FA)
val Lavender = Color(0xFFB4BEFE)
val Text = Color(0xFFCDD6F4)
val Subtext1 = Color(0xFFBAC2DE)
val Subtext0 = Color(0xFFA6ADC8)
val Overlay2 = Color(0xFF9399B2)
val Overlay1 = Color(0xFF7F849C)
val Overlay0 = Color(0xFF6C7086)
val Surface2 = Color(0xFF585B70)
val Surface1 = Color(0xFF45475A)
val Surface0 = Color(0xFF313244)
val Base = Color(0xFF1E1E2E)
val Mantle = Color(0xFF181825)
val Crust = Color(0xFF11111B)
}
```
### 2. ui/theme/Type.kt
```kotlin
package com.kecalek.chat.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val KecalekTypography = Typography(
headlineLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
),
headlineMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
),
titleLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 24.sp,
),
titleMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 22.sp,
),
bodyLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
),
bodyMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
),
bodySmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
),
labelLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
),
labelSmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
),
)
```
### 3. ui/theme/Theme.kt
```kotlin
package com.kecalek.chat.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
private val DarkColorScheme = darkColorScheme(
primary = CatppuccinMocha.Lavender,
onPrimary = CatppuccinMocha.Base,
primaryContainer = CatppuccinMocha.Lavender.copy(alpha = 0.3f),
onPrimaryContainer = CatppuccinMocha.Text,
secondary = CatppuccinMocha.Mauve,
onSecondary = CatppuccinMocha.Base,
secondaryContainer = CatppuccinMocha.Mauve.copy(alpha = 0.3f),
onSecondaryContainer = CatppuccinMocha.Text,
tertiary = CatppuccinMocha.Peach,
onTertiary = CatppuccinMocha.Base,
error = CatppuccinMocha.Red,
onError = CatppuccinMocha.Base,
errorContainer = CatppuccinMocha.Red.copy(alpha = 0.3f),
background = CatppuccinMocha.Base,
onBackground = CatppuccinMocha.Text,
surface = CatppuccinMocha.Surface0,
onSurface = CatppuccinMocha.Text,
surfaceVariant = CatppuccinMocha.Surface1,
onSurfaceVariant = CatppuccinMocha.Subtext1,
outline = CatppuccinMocha.Overlay0,
outlineVariant = CatppuccinMocha.Surface2,
inverseSurface = CatppuccinMocha.Text,
inverseOnSurface = CatppuccinMocha.Base,
)
@Composable
fun KecalekTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColorScheme,
typography = KecalekTypography,
content = content,
)
}
```
### 4. ui/navigation/NavGraph.kt
```kotlin
package com.kecalek.chat.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
/**
* Navigation routes for the app.
*/
object Routes {
const val LOGIN = "login"
const val REGISTER = "register"
const val PAIRING = "pairing"
const val CONVERSATION_LIST = "conversations"
const val CHAT = "chat/{conversationId}"
const val GROUP_INFO = "group_info/{conversationId}"
const val PROFILE = "profile/{userId}"
const val EDIT_PROFILE = "edit_profile"
const val VERIFICATION = "verification/{userId}"
const val DEVICE_LIST = "devices"
const val SETTINGS = "settings"
const val IMAGE_VIEWER = "image_viewer/{imageUrl}"
// Helper functions to build routes with arguments
fun chat(conversationId: String) = "chat/$conversationId"
fun groupInfo(conversationId: String) = "group_info/$conversationId"
fun profile(userId: String) = "profile/$userId"
fun verification(userId: String) = "verification/$userId"
fun imageViewer(imageUrl: String) = "image_viewer/$imageUrl"
}
@Composable
fun KecalekNavGraph(
navController: NavHostController = rememberNavController(),
startDestination: String = Routes.LOGIN,
) {
NavHost(
navController = navController,
startDestination = startDestination,
) {
composable(Routes.LOGIN) {
// TODO: LoginScreen(navController)
}
composable(Routes.REGISTER) {
// TODO: RegisterScreen(navController)
}
composable(Routes.PAIRING) {
// TODO: PairingScreen(navController)
}
composable(Routes.CONVERSATION_LIST) {
// TODO: ConversationListScreen(navController)
}
composable(
route = Routes.CHAT,
arguments = listOf(navArgument("conversationId") { type = NavType.StringType })
) { backStackEntry ->
val conversationId = backStackEntry.arguments?.getString("conversationId") ?: return@composable
// TODO: ChatScreen(conversationId, navController)
}
composable(
route = Routes.GROUP_INFO,
arguments = listOf(navArgument("conversationId") { type = NavType.StringType })
) { backStackEntry ->
val conversationId = backStackEntry.arguments?.getString("conversationId") ?: return@composable
// TODO: GroupInfoScreen(conversationId, navController)
}
composable(
route = Routes.PROFILE,
arguments = listOf(navArgument("userId") { type = NavType.StringType })
) { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId") ?: return@composable
// TODO: ProfileScreen(userId, navController)
}
composable(Routes.EDIT_PROFILE) {
// TODO: EditProfileScreen(navController)
}
composable(
route = Routes.VERIFICATION,
arguments = listOf(navArgument("userId") { type = NavType.StringType })
) { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId") ?: return@composable
// TODO: SafetyNumberScreen(userId, navController)
}
composable(Routes.DEVICE_LIST) {
// TODO: DeviceListScreen(navController)
}
composable(Routes.SETTINGS) {
// TODO: SettingsScreen(navController)
}
composable(
route = Routes.IMAGE_VIEWER,
arguments = listOf(navArgument("imageUrl") { type = NavType.StringType })
) { backStackEntry ->
val imageUrl = backStackEntry.arguments?.getString("imageUrl") ?: return@composable
// TODO: ImageViewer(imageUrl, navController)
}
}
}
```
### 5. Update MainActivity.kt
```kotlin
package com.kecalek.chat
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.kecalek.chat.ui.navigation.KecalekNavGraph
import com.kecalek.chat.ui.theme.KecalekTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
KecalekTheme {
Surface(modifier = Modifier.fillMaxSize()) {
KecalekNavGraph()
}
}
}
}
}
```
## Color Usage Guide
Use these mappings consistently across all screens:
| UI Element | Color |
|-----------|-------|
| Own message bubble background | `CatppuccinMocha.Lavender.copy(alpha = 0.15f)` |
| Other's message bubble background | `CatppuccinMocha.Surface0` |
| App background | `CatppuccinMocha.Base` |
| Card/surface background | `CatppuccinMocha.Surface0` |
| Primary text | `CatppuccinMocha.Text` |
| Secondary text | `CatppuccinMocha.Subtext1` |
| Muted text (timestamps) | `CatppuccinMocha.Overlay1` |
| Online indicator | `CatppuccinMocha.Green` |
| Error/delete | `CatppuccinMocha.Red` |
| Unread badge | `CatppuccinMocha.Lavender` |
| Links/@mentions | `CatppuccinMocha.Blue` |
| Verified checkmark | `CatppuccinMocha.Green` |
| Warning | `CatppuccinMocha.Peach` |
| Send button | `CatppuccinMocha.Lavender` |
| Input field background | `CatppuccinMocha.Surface1` |
| Dividers | `CatppuccinMocha.Surface2` |
## Constraints
- Dark theme only (no light theme for now)
- Use Material 3 components exclusively
- Edge-to-edge display (enableEdgeToEdge)
- All navigation routes defined with TODO placeholders for actual screens
## DO NOT
- Implement any actual screen UI (only TODO placeholders in NavGraph)
- Add business logic
- Implement any cryptographic operations
- Modify build.gradle files

View File

@@ -0,0 +1,657 @@
# 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

View File

@@ -0,0 +1,160 @@
# Agent D: Auth Screens
## Phase: 2 (UI Shells)
## Depends on: Agent A (project), Agent B (theme + navigation)
## Context
You are building authentication screens for "Kecalek" encrypted chat app.
The login uses RSA challenge-response (no password sent to server).
Registration requires email verification code.
Device pairing allows linking a new device to an existing account.
## Task
Create Login, Register, Pairing screens and AuthViewModel skeleton.
## Files to Create
### 1. ui/auth/LoginScreen.kt
Jetpack Compose screen with:
- **App title** "Kecalek" at the top (headlineLarge)
- **Subtitle** "Encrypted Messaging" (bodyMedium, Subtext1 color)
- **Email/Username** text field (OutlinedTextField, Surface1 background)
- **Password** text field (password visibility toggle icon)
- **Login button** (filled, Lavender primary, full width)
- **"Create Account"** text button below (navigates to Register)
- **"Link Device"** text button below (navigates to Pairing)
- **Server config section** (expandable/collapsible):
- Host text field (default: "chat.ai-tech.news")
- Port text field (default: "9999")
- TLS toggle switch (default: off)
- **Loading state**: CircularProgressIndicator replacing login button
- **Error state**: Red error text below password field
- **Biometric login button** (fingerprint icon, shown only if biometric available)
**Layout**: Centered vertically, max width 400dp, padding 24dp.
### 2. ui/auth/RegisterScreen.kt
Jetpack Compose screen with:
- **Back arrow** in top bar (navigates back)
- **Title** "Create Account"
- **Username** text field
- **Email** text field
- **Password** text field (with visibility toggle)
- **Confirm Password** text field
- **Register button** (filled, Lavender, full width)
- **Confirmation code section** (shown after successful registration):
- Info text "Check your email for a verification code"
- 6-digit code input field
- Confirm button
- **Loading state** + **Error state** (same pattern as Login)
### 3. ui/auth/PairingScreen.kt
Jetpack Compose screen with:
- **Back arrow** in top bar
- **Title** "Link New Device"
- **Info text** explaining pairing process
- **8-digit pairing code** displayed prominently (headlineLarge, monospace, letter-spacing)
- **Countdown timer** or progress indicator showing poll status
- **"Waiting for authorization..."** text with animated dots
- **Cancel button** (outlined)
- **Status messages**: "Device authorized", "Pairing failed", etc.
### 4. ui/auth/AuthViewModel.kt
```kotlin
package com.kecalek.chat.ui.auth
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
data class AuthUiState(
val isLoading: Boolean = false,
val error: String? = null,
val isLoggedIn: Boolean = false,
val isRegistered: Boolean = false,
val needsConfirmation: Boolean = false,
val pairingCode: String? = null,
val isPairingWaiting: Boolean = false,
val serverHost: String = "chat.ai-tech.news",
val serverPort: Int = 9999,
val useTls: Boolean = false,
val biometricAvailable: Boolean = false,
)
@HiltViewModel
class AuthViewModel @Inject constructor(
// TODO: Inject ChatClient, SessionManager
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
fun login(emailOrUsername: String, password: String) {
// TODO: Implement RSA challenge-response login
// 1. ChatClient.login(email, password)
// 2. On success: navigate to ConversationList
// 3. On failure: show error
}
fun register(username: String, email: String, password: String) {
// TODO: Implement registration
// 1. ChatClient.register(username, email, password)
// 2. On success: show confirmation code input
// 3. On failure: show error
}
fun confirmRegistration(email: String, code: String) {
// TODO: Confirm with 6-digit code
// 1. ChatClient.confirm_registration(email, code)
// 2. On success: auto-login
}
fun startPairing() {
// TODO: Start device pairing
// 1. ChatClient.pairing_start() -> get 8-digit code
// 2. Show code to user
// 3. Start polling for authorization
}
fun loginWithBiometric() {
// TODO: Biometric authentication
}
fun updateServerConfig(host: String, port: Int, useTls: Boolean) {
_uiState.value = _uiState.value.copy(
serverHost = host,
serverPort = port,
useTls = useTls,
)
}
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
}
```
## Reference: iOS LoginView behavior
- Login/Register are modes of the same view (iOS uses toggle tabs)
- Server configuration is expandable (collapsed by default)
- Biometric login uses Face ID/Touch ID
- Error messages appear below the form
- Loading spinner replaces the action button
## Constraints
- Use Material 3 components (OutlinedTextField, FilledTonalButton, etc.)
- Use `CatppuccinMocha` colors from theme
- Use `hiltViewModel()` for ViewModel injection
- All screens receive `navController: NavHostController` parameter
- Password fields must have visibility toggle
- Support keyboard "Done" action to submit form
- Use `rememberSaveable` for form state to survive config changes
## DO NOT
- Implement actual login/register/pairing logic (just skeleton functions)
- Handle cryptographic operations
- Store credentials or keys
- Modify navigation graph (screen placeholders already exist)

View File

@@ -0,0 +1,274 @@
# Agent E: Conversation List Screen
## Phase: 2 (UI Shells)
## Depends on: Agent A (project), Agent B (theme + navigation), Agent C (models)
## Context
The conversation list is the main screen after login. It shows all DMs and groups
with online status, unread counts, last message preview, and group invitations.
## Task
Create ConversationListScreen, ConversationRow, NewConversationSheet, and ViewModel skeleton.
## Files to Create
### 1. ui/conversations/ConversationListScreen.kt
Jetpack Compose screen with:
- **Top bar**: "Chats" title + profile icon button (right) + settings gear (right)
- **Invitations section** (shown only when invitations exist):
- Amber/Peach colored card for each invitation
- Shows: "Group Name — invited by Username"
- Accept (Green) and Decline (Red) buttons
- **Conversation list** (LazyColumn):
- Each item is a ConversationRow
- Pull-to-refresh (SwipeRefresh)
- Empty state: "No conversations yet" with illustration
- **FAB** (bottom-right): "+" button to create new conversation
- On click: show NewConversationSheet
- **Long-press** on conversation: context menu with:
- "Mark as read"
- "Add to favorites" / "Remove from favorites"
- Sorting: favorites first, then by last message time descending
### 2. ui/conversations/ConversationRow.kt
Composable for a single conversation list item:
- **Left**: Circular avatar (40dp)
- DM: User avatar with online green dot overlay (bottom-right)
- Group: Group avatar or default letter circle (deterministic color from name)
- **Center** (weight 1f, vertical arrangement):
- **Top row**: Conversation name (bold if unread) + timestamp (right-aligned, Overlay1 color)
- **Bottom row**: Last message preview (1 line, Subtext1 color) + unread badge (right-aligned)
- **Unread badge**: Lavender circle with white count text (shown when count > 0)
- **Favorite star**: Small star icon if favorited (Yellow color)
- **Verified checkmark**: Small green checkmark for verified DM contacts
### 3. ui/conversations/NewConversationSheet.kt
Bottom sheet (ModalBottomSheet) with:
- **Title**: "New Conversation"
- **Tabs**: "Direct Message" | "Create Group"
- **DM tab**:
- Email text field
- "Start Chat" button
- **Group tab**:
- Group name text field
- Email text field with "Add" button (adds to member list)
- Member list (removable chips)
- "Create Group" button
- **Loading** + **Error** states
### 4. ui/conversations/ConversationListVM.kt
```kotlin
package com.kecalek.chat.ui.conversations
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.kecalek.chat.data.model.Conversation
import com.kecalek.chat.data.model.Invitation
import javax.inject.Inject
data class ConversationListState(
val conversations: List<Conversation> = emptyList(),
val invitations: List<Invitation> = emptyList(),
val onlineUsers: Set<String> = emptySet(),
val isLoading: Boolean = false,
val error: String? = null,
val currentUserId: String = "",
)
@HiltViewModel
class ConversationListVM @Inject constructor(
// TODO: Inject repositories
) : ViewModel() {
private val _uiState = MutableStateFlow(ConversationListState())
val uiState: StateFlow<ConversationListState> = _uiState.asStateFlow()
fun loadConversations() {
// TODO: Fetch from server + local cache
}
fun loadInvitations() {
// TODO: Fetch pending invitations
}
fun acceptInvitation(conversationId: String) {
// TODO: Accept group invitation
}
fun declineInvitation(conversationId: String) {
// TODO: Decline group invitation
}
fun createDmConversation(email: String) {
// TODO: Find or create DM conversation
}
fun createGroupConversation(name: String, memberEmails: List<String>) {
// TODO: Create group conversation
}
fun toggleFavorite(conversationId: String) {
// TODO: Toggle favorite status
}
fun markAsRead(conversationId: String) {
// TODO: Mark all messages in conversation as read
}
fun refresh() {
// TODO: Pull-to-refresh
}
}
```
### 5. ui/components/CircularAvatar.kt
```kotlin
package com.kecalek.chat.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import kotlin.math.absoluteValue
/**
* Circular avatar with fallback to colored letter circle.
* Color is deterministic based on the name string.
*/
@Composable
fun CircularAvatar(
imageUrl: String?,
name: String,
size: Dp = 40.dp,
modifier: Modifier = Modifier,
) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = name,
modifier = modifier
.size(size)
.clip(CircleShape),
contentScale = ContentScale.Crop,
)
} else {
val colors = listOf(
Color(0xFFF38BA8), Color(0xFFFAB387), Color(0xFFF9E2AF),
Color(0xFFA6E3A1), Color(0xFF89DCEB), Color(0xFF89B4FA),
Color(0xFFCBA6F7), Color(0xFFF5C2E7),
)
val color = colors[name.hashCode().absoluteValue % colors.size]
val initial = name.firstOrNull()?.uppercase() ?: "?"
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(color),
contentAlignment = Alignment.Center,
) {
Text(
text = initial,
color = Color(0xFF1E1E2E),
fontSize = (size.value * 0.4).sp,
)
}
}
}
```
### 6. ui/components/OnlineDot.kt
```kotlin
package com.kecalek.chat.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.kecalek.chat.ui.theme.CatppuccinMocha
/**
* Small green dot overlay for online status indication.
* Place this in a Box with the avatar, aligned to BottomEnd.
*/
@Composable
fun OnlineDot(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(12.dp)
.background(CatppuccinMocha.Green, CircleShape)
.border(2.dp, CatppuccinMocha.Base, CircleShape)
)
}
```
### 7. ui/components/UnreadBadge.kt
```kotlin
package com.kecalek.chat.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kecalek.chat.ui.theme.CatppuccinMocha
@Composable
fun UnreadBadge(count: Int, modifier: Modifier = Modifier) {
if (count > 0) {
Box(
modifier = modifier
.defaultMinSize(minWidth = 20.dp, minHeight = 20.dp)
.background(CatppuccinMocha.Lavender, CircleShape)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = if (count > 99) "99+" else count.toString(),
color = CatppuccinMocha.Base,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
}
}
}
```
## Constraints
- Use Material 3 components
- ConversationRow must be efficient (used in LazyColumn)
- Avatar colors must be deterministic (same name = same color)
- Unread badge shows "99+" for counts > 99
- Online dot: 12dp green circle with 2dp Base-colored border
- Favorites sorted first, then by lastMessageTime descending
## DO NOT
- Implement actual server communication
- Handle encryption/decryption
- Implement real avatar loading from server (use placeholder URLs for now)

View File

@@ -0,0 +1,230 @@
# Agent F: Chat Screen
## Phase: 2 (UI Shells)
## Depends on: Agent A, Agent B (theme + navigation), Agent C (models)
## Context
The chat screen is the core messaging interface. It shows encrypted messages in bubbles,
supports replies, file attachments, reactions, pins, and real-time updates.
## Task
Create ChatScreen, MessageBubble, MessageInput, ImageViewer, and ChatViewModel skeleton.
## Files to Create
### 1. ui/chat/ChatScreen.kt
Jetpack Compose screen with:
- **Top bar (custom)**:
- Back arrow (left)
- Circular avatar (32dp)
- Conversation name + "Encrypted" / "Verified" label below (small, Green or Overlay1)
- Action buttons (right): Search icon, Info/Group icon
- **Message list** (LazyColumn, reverseLayout = true):
- Messages grouped by date (date separator headers)
- Each message is a MessageBubble
- Scroll to bottom FAB (shown when not at bottom)
- **Reply preview bar** (shown when replying to a message):
- Vertical Lavender bar + replied message text preview + close button
- **Search overlay** (shown when search active):
- Search text field with prev/next buttons + match count
- Highlighted matches in messages
- **Message input** at bottom (MessageInput composable)
### 2. ui/chat/MessageBubble.kt
Composable for a single message:
- **Own messages**: Right-aligned, Lavender background (alpha 0.15)
- **Other's messages**: Left-aligned, Surface0 background
- **Content layout** (vertical):
- Sender name (only in groups, for other's messages, bold, Lavender color)
- Forwarded header (if forwarded): "Forwarded from Username" with blue left border
- Reply reference (if reply): small gray box with replied message text (1 line)
- Text content with:
- Link detection (Blue, underlined)
- @mention highlighting (Blue, bold)
- Image thumbnail (if image): clickable, shows full-size on tap
- File card (if file): icon + filename + size, clickable to download
- **Bottom row** (horizontal):
- Timestamp (Overlay1, small)
- Pin icon (if pinned)
- Read receipt indicators:
- 1 checkmark = sent
- 2 checkmarks = delivered
- 2 blue checkmarks = read
- Reaction badges (if reactions): row of emoji+count chips below message
- **Deleted message**: "This message was deleted" in italic, Overlay1 color
- **Long-press context menu**:
- Reply
- React (submenu with 6 emoji)
- Copy text
- Forward
- Pin / Unpin
- Delete (own messages only, Red color)
- **Message shape**: Rounded rectangle (12dp), with tail on sender side
### 3. ui/chat/MessageInput.kt
Composable for message input area:
- **Layout** (horizontal):
- Attachment button (left, paperclip icon)
- On click: shows bottom sheet with "Image" and "File" options
- Text field (weight 1f, pill-shaped, Surface1 background)
- Placeholder: "Message..."
- Auto-grow up to 4 lines
- @mention detection triggers autocomplete popup
- Send button (right, Lavender, circular, arrow icon)
- Shown only when text is non-empty or attachment selected
- **@mention autocomplete**: Popup above input showing matching member names
- **Attachment preview**: Shows selected image/file name before sending
### 4. ui/chat/ImageViewer.kt
Full-screen image viewer:
- **Black background** with translucent system bars
- **Zoomable image** (pinch-to-zoom, double-tap zoom, pan)
- **Top bar**: Back button + filename (transparent background)
- **Bottom bar**: Share button + Save button
- **Gesture**: Swipe down to dismiss
### 5. ui/chat/ChatViewModel.kt
```kotlin
package com.kecalek.chat.ui.chat
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.kecalek.chat.data.model.Conversation
import com.kecalek.chat.data.model.ConversationMember
import com.kecalek.chat.data.model.Message
import javax.inject.Inject
data class ChatUiState(
val messages: List<Message> = emptyList(),
val conversation: Conversation? = null,
val members: List<ConversationMember> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val replyingTo: Message? = null,
val isSearchActive: Boolean = false,
val searchQuery: String = "",
val searchResults: List<Int> = emptyList(), // indices into messages
val currentSearchIndex: Int = -1,
val currentUserId: String = "",
val verificationStatus: String = "encrypted", // "encrypted" or "verified"
)
@HiltViewModel
class ChatViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
// TODO: Inject repositories
) : ViewModel() {
val conversationId: String = savedStateHandle["conversationId"] ?: ""
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
fun loadMessages() {
// TODO: Load from cache + incremental sync from server
}
fun sendMessage(text: String) {
// TODO: Encrypt and send message
}
fun sendImage(uri: String) {
// TODO: Encrypt and upload image
}
fun sendFile(uri: String) {
// TODO: Encrypt and upload file
}
fun deleteMessage(messageId: String) {
// TODO: Soft-delete message
}
fun reactToMessage(messageId: String, reaction: String) {
// TODO: Add/remove reaction
}
fun pinMessage(messageId: String) {
// TODO: Pin/unpin message
}
fun forwardMessage(messageId: String, targetConversationId: String) {
// TODO: Forward message to another conversation
}
fun setReplyTo(message: Message?) {
_uiState.value = _uiState.value.copy(replyingTo = message)
}
fun toggleSearch() {
val current = _uiState.value
_uiState.value = current.copy(
isSearchActive = !current.isSearchActive,
searchQuery = "",
searchResults = emptyList(),
currentSearchIndex = -1,
)
}
fun search(query: String) {
// TODO: Search through local message cache
}
fun nextSearchResult() {
// TODO: Navigate to next search result
}
fun prevSearchResult() {
// TODO: Navigate to previous search result
}
fun markAsRead() {
// TODO: Mark visible messages as read
}
fun downloadFile(fileId: String) {
// TODO: Download and decrypt file
}
}
```
## Message Bubble Visual Spec
```
┌──────────────────────────────────────────┐
│ Forwarded from Alice (fwd) │
│ ┌────────────────────────────────────┐ │
│ │ Replying to: original message... │ │
│ └────────────────────────────────────┘ │
│ Bob (sender name, groups only) │
│ │
│ Message text content here with │
│ @mentions highlighted in blue │
│ │
│ ┌────────────────────────────────────┐ │
│ │ [Image Thumbnail] │ │
│ └────────────────────────────────────┘ │
│ │
│ 12:34 📌 ✓✓ │
│ 👍2 ❤1 │
└──────────────────────────────────────────┘
```
## Constraints
- Use Material 3 components
- LazyColumn with reverseLayout for chat (newest at bottom)
- Message bubbles must be efficient for long conversations
- Use `remember` for expensive computations in bubbles
- Maximum bubble width: 80% of screen width
- Image thumbnails: max 200dp width, maintain aspect ratio
- Context menu via `DropdownMenu` on long-press
## DO NOT
- Implement actual encryption/decryption
- Implement actual file upload/download
- Handle server communication
- Implement QR code scanning (that's Agent G)

View File

@@ -0,0 +1,268 @@
# Agent G: Profile + Group + Verification Screens
## Phase: 2 (UI Shells)
## Depends on: Agent A, Agent B (theme + navigation), Agent C (models)
## Context
These screens handle user profiles, group management, and contact verification.
Verification uses Signal-style safety numbers, fingerprints, and QR codes.
## Task
Create Profile, EditProfile, GroupInfo, SafetyNumber, QRScanner, DeviceList screens and ViewModels.
## Files to Create
### 1. ui/profile/ProfileScreen.kt
Jetpack Compose screen showing user profile:
- **Top bar**: Back arrow + "Profile" title
- **Own profile** (editable mode):
- Large circular avatar (80dp) with camera overlay icon
- Username (editable text field)
- Email (read-only, Subtext1 color)
- Phone text field (with visibility toggle switch)
- Location text field (with visibility toggle switch)
- "Save" button (Lavender, full width)
- Divider
- "Key Rotation" button (Peach/warning color)
- "Authorize Device" button
- "Logout" button (Red color)
- **Other user profile** (read-only mode):
- Large circular avatar (80dp)
- Username (headlineMedium)
- Email (bodyMedium, Subtext1)
- Phone (if visible)
- Location (if visible)
- Divider
- **Security section**:
- Verification status badge ("Verified" green or "Not Verified" muted)
- "View Safety Number" button (navigates to Verification screen)
- Fingerprint display (monospace, small)
### 2. ui/profile/EditProfileScreen.kt
(Can be merged into ProfileScreen as a mode, or separate — up to implementation)
### 3. ui/profile/ProfileViewModel.kt
```kotlin
package com.kecalek.chat.ui.profile
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.kecalek.chat.data.model.UserProfile
import javax.inject.Inject
data class ProfileUiState(
val profile: UserProfile? = null,
val isOwnProfile: Boolean = false,
val isEditing: Boolean = false,
val isLoading: Boolean = false,
val error: String? = null,
val verificationStatus: String = "unverified", // "verified", "trusted", "unverified"
val fingerprint: String = "",
)
@HiltViewModel
class ProfileViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
// TODO: Inject repositories
) : ViewModel() {
val userId: String = savedStateHandle["userId"] ?: ""
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun loadProfile() { /* TODO */ }
fun updateProfile(phone: String?, location: String?, phoneVisible: Boolean, locationVisible: Boolean) { /* TODO */ }
fun updateAvatar(imageBytes: ByteArray) { /* TODO */ }
fun rotateKeys() { /* TODO */ }
fun logout() { /* TODO */ }
}
```
### 4. ui/groups/GroupInfoScreen.kt
Jetpack Compose screen for group management:
- **Top bar**: Back arrow + "Group Info" title
- **Group avatar** (80dp, circular) with camera overlay (creator only)
- **Group name** (editable by creator, with edit icon)
- **Member count** ("X members")
- Divider
- **Members list**:
- Each member: avatar (32dp) + username + email
- Creator badge (small crown or "Admin" label)
- Verified checkmark for verified members (not self)
- Creator can tap member → "Remove" option
- **"Add Member"** button (creator or all members, depending on server):
- Opens dialog with email input
- Divider
- **"Leave Group"** button (Red, outlined)
- **"Delete Group"** button (Red, filled — creator only)
- Confirmation dialogs for destructive actions
### 5. ui/groups/CreateGroupSheet.kt
Bottom sheet for group creation (reuse from Agent E or create here):
- Group name field
- Email field + "Add" button
- Member chip list
- "Create" button
### 6. ui/groups/InvitationBanner.kt
Composable for invitation display in conversation list:
- Peach/Amber border card
- Group name + "invited by Username"
- Accept (Green icon button) + Decline (Red icon button)
### 7. ui/verification/SafetyNumberScreen.kt
Jetpack Compose screen for contact verification:
- **Top bar**: Back arrow + "Verify Contact" title
- **Verification status badge**:
- "Verified" (Green background)
- "Trusted" (Lavender background)
- "Not Verified" (Surface1 background)
- **Safety number display**:
- 60 digits displayed as 12 groups of 5
- 3 lines of 4 groups each
- Monospace font, large text
- Info text: "Compare this number with your contact's device"
- **QR code**:
- Generated QR code image (200dp)
- "Show my QR code" section
- **Fingerprints section**:
- "My fingerprint": 30 digits (6 groups of 5, 2 lines)
- "Their fingerprint": 30 digits
- Monospace font
- **Action buttons**:
- "Mark as Verified" (Green, filled) — shown when not verified
- "Remove Verification" (Red, outlined) — shown when verified
- "Scan QR Code" button (opens camera)
### 8. ui/verification/QRScannerScreen.kt
Camera-based QR code scanner:
- Full-screen camera preview
- QR code detection overlay (frame guide)
- On successful scan: verify and show result
- "Cancel" button overlay
- Uses CameraX + ZXing for detection
### 9. ui/verification/VerificationVM.kt
```kotlin
package com.kecalek.chat.ui.verification
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
data class VerificationUiState(
val peerUsername: String = "",
val verificationStatus: String = "unverified",
val safetyNumber: String = "", // 60 digits formatted
val myFingerprint: String = "", // 30 digits formatted
val peerFingerprint: String = "", // 30 digits formatted
val qrCodeData: ByteArray? = null, // QR payload for generation
val isLoading: Boolean = false,
val scanResult: String? = null, // Success/failure message
)
@HiltViewModel
class VerificationVM @Inject constructor(
savedStateHandle: SavedStateHandle,
// TODO: Inject ChatClient for verification operations
) : ViewModel() {
val userId: String = savedStateHandle["userId"] ?: ""
private val _uiState = MutableStateFlow(VerificationUiState())
val uiState: StateFlow<VerificationUiState> = _uiState.asStateFlow()
fun loadVerificationData() { /* TODO: get safety number, fingerprints, QR data */ }
fun markAsVerified() { /* TODO: verify_contact() */ }
fun removeVerification() { /* TODO: unverify_contact() */ }
fun processQrScanResult(data: String) { /* TODO: verify_qr_code() */ }
}
```
### 10. ui/devices/DeviceListScreen.kt
Jetpack Compose screen for device management:
- **Top bar**: Back arrow + "My Devices" title
- **Device list** (LazyColumn):
- Each device: device name/ID + last seen timestamp
- Current device highlighted with "(This device)" label
- "Remove" button for other devices (Red icon)
- **Info text**: "Removing a device will end its session"
### 11. ui/devices/DeviceViewModel.kt
```kotlin
package com.kecalek.chat.ui.devices
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
data class Device(
val id: String,
val name: String?,
val lastSeen: String,
val isCurrentDevice: Boolean,
)
data class DeviceListState(
val devices: List<Device> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class DeviceViewModel @Inject constructor(
// TODO: Inject ChatClient
) : ViewModel() {
private val _uiState = MutableStateFlow(DeviceListState())
val uiState: StateFlow<DeviceListState> = _uiState.asStateFlow()
fun loadDevices() { /* TODO */ }
fun removeDevice(deviceId: String) { /* TODO */ }
}
```
## Safety Number Display Format
```
12345 67890 12345 67890
12345 67890 12345 67890
12345 67890 12345 67890
```
- 12 groups of 5 digits
- 3 lines of 4 groups
- Monospace font
- Large text (20sp)
## Fingerprint Display Format
```
12345 67890 12345
67890 12345 67890
```
- 6 groups of 5 digits
- 2 lines of 3 groups
- Monospace font
## Constraints
- Use Material 3 components
- CameraX for QR scanner (not deprecated Camera1)
- QR generation via ZXing BarcodeEncoder
- Confirmation dialogs for destructive actions (leave group, delete group, remove device)
- Creator-only actions clearly gated in UI
## DO NOT
- Implement actual crypto verification logic
- Generate real safety numbers or fingerprints
- Handle actual server communication
- Implement actual QR code encoding/decoding logic

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

142
specs/agent-i-hilt-di.md Normal file
View File

@@ -0,0 +1,142 @@
# Agent I: Hilt DI Modules
## Phase: 3 (Core Logic)
## Depends on: Agent A (Gradle), Agent C (Room database)
## Context
Set up Hilt dependency injection for the entire app.
Provides singletons for database, network, crypto components.
## Task
Create all Hilt DI modules for the application.
## Files to Create
### 1. di/AppModule.kt
```kotlin
package com.kecalek.chat.di
import android.content.Context
import androidx.room.Room
import com.kecalek.chat.data.local.AppDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import net.sqlcipher.database.SupportFactory
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context,
): AppDatabase {
// TODO: Get database passphrase from secure storage
// For now, use a placeholder. In production, derive from identity key.
val passphrase = "TODO_REPLACE_WITH_DERIVED_KEY".toByteArray()
val factory = SupportFactory(passphrase)
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"kecalek_chat.db"
)
.openHelperFactory(factory)
.fallbackToDestructiveMigration()
.build()
}
}
```
### 2. di/DatabaseModule.kt
```kotlin
package com.kecalek.chat.di
import com.kecalek.chat.data.local.AppDatabase
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 dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
fun provideMessageDao(db: AppDatabase): MessageDao = db.messageDao()
@Provides
fun provideConversationDao(db: AppDatabase): ConversationDao = db.conversationDao()
@Provides
fun provideUserCacheDao(db: AppDatabase): UserCacheDao = db.userCacheDao()
}
```
### 3. di/NetworkModule.kt
```kotlin
package com.kecalek.chat.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
// TODO: Provide ConnectionManager singleton
// TODO: Provide ProtocolHandler singleton
// TODO: Provide ServerApi singleton
// Placeholder — will be wired when network layer is implemented by Claude Code
}
```
### 4. di/CryptoModule.kt
```kotlin
package com.kecalek.chat.di
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
object CryptoModule {
// TODO: Provide crypto components (implemented by Claude Code)
// - AesGcmCrypto
// - HkdfUtils
// - KeyEncryption (ECP1)
// - Ed25519Crypto
// - X25519Crypto
// - RSACrypto
// - MessagePadding
// - ContactVerification
// Placeholder — will be wired when crypto layer is implemented by Claude Code
}
```
## Constraints
- All modules use `@InstallIn(SingletonComponent::class)` unless scoped differently
- Database uses SQLCipher encryption via SupportFactory
- DAO providers are simple pass-through from AppDatabase
- Network and Crypto modules are placeholders (Claude Code implements the actual classes)
- Use `@Singleton` for expensive objects (database, network manager)
## DO NOT
- Implement actual crypto or network classes
- Add any UI-related bindings
- Create ViewModel bindings (Hilt handles those automatically via @HiltViewModel)

View File

@@ -0,0 +1,132 @@
# Agent J: File Sharing UI
## Phase: 4 (Feature Completion)
## Depends on: Agent F (Chat Screen), Agent C (models with FileInfo/ImageInfo)
## Context
File sharing uses chunked encrypted upload/download (AES-256-GCM).
The UI handles image/file picking, thumbnail display, download progress, and file type icons.
Encryption logic is handled by ChatClient (Claude Code) — this agent only handles UI.
## Task
Create file/image picker integration, download UI, file type icons, and thumbnail display.
## Files to Create
### 1. ui/chat/AttachmentSheet.kt
Bottom sheet shown when attachment button is tapped:
- **"Image"** option with gallery icon
- Opens system image picker (ActivityResultContracts.PickVisualMedia)
- Shows selected image preview before sending
- **"File"** option with document icon
- Opens system file picker (ActivityResultContracts.OpenDocument)
- Shows selected file name + size before sending
- **"Camera"** option with camera icon
- Opens camera to take photo (ActivityResultContracts.TakePicture)
### 2. ui/chat/ImageThumbnail.kt
Composable for image message display in chat:
- Shows base64 JPEG thumbnail from ImageInfo.thumbnail
- Max width 200dp, maintain aspect ratio
- Rounded corners (8dp)
- Loading shimmer while full image loads
- On tap: navigate to ImageViewer with full image URL
- Download progress overlay (if downloading full resolution)
### 3. ui/chat/FileCard.kt
Composable for file attachment display in chat:
- Horizontal layout:
- File type icon (40dp, left):
- PDF: Red document icon
- Image: Blue image icon
- Video: Purple play icon
- Audio: Green music icon
- Archive: Yellow archive icon
- Default: Gray document icon
- Center (weight 1f):
- Filename (bold, 1 line, ellipsize end)
- File size (formatted: KB, MB) + mime type
- Download button/progress (right):
- Download icon (if not downloaded)
- CircularProgressIndicator (if downloading)
- Checkmark (if downloaded)
- Background: Surface1 with 1dp Surface2 border, rounded 8dp
- On tap: download if not downloaded, open if downloaded
### 4. ui/chat/DownloadProgress.kt
Reusable download progress composable:
- Circular progress indicator with percentage text
- Cancel button
- File size downloaded / total
### 5. util/FileUtils.kt
```kotlin
package com.kecalek.chat.util
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
object FileUtils {
fun getFileName(context: Context, uri: Uri): String {
// Query ContentResolver for display name
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val nameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0) return it.getString(nameIndex)
}
}
return uri.lastPathSegment ?: "unknown"
}
fun getFileSize(context: Context, uri: Uri): Long {
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val sizeIndex = it.getColumnIndex(android.provider.OpenableColumns.SIZE)
if (sizeIndex >= 0) return it.getLong(sizeIndex)
}
}
return 0
}
fun getMimeType(context: Context, uri: Uri): String {
return context.contentResolver.getType(uri)
?: MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(uri.toString().substringAfterLast('.'))
?: "application/octet-stream"
}
fun formatFileSize(bytes: Long): String = when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
bytes < 1024 * 1024 * 1024 -> "${"%.1f".format(bytes / (1024.0 * 1024.0))} MB"
else -> "${"%.1f".format(bytes / (1024.0 * 1024.0 * 1024.0))} GB"
}
fun getFileTypeIcon(mimeType: String): FileTypeIcon = when {
mimeType.startsWith("image/") -> FileTypeIcon.IMAGE
mimeType.startsWith("video/") -> FileTypeIcon.VIDEO
mimeType.startsWith("audio/") -> FileTypeIcon.AUDIO
mimeType == "application/pdf" -> FileTypeIcon.PDF
mimeType.contains("zip") || mimeType.contains("tar") || mimeType.contains("rar") -> FileTypeIcon.ARCHIVE
else -> FileTypeIcon.DOCUMENT
}
enum class FileTypeIcon { PDF, IMAGE, VIDEO, AUDIO, ARCHIVE, DOCUMENT }
}
```
## Constraints
- Use ActivityResultContracts for file/image picking (no deprecated startActivityForResult)
- Thumbnail display from base64 data (not URL loading)
- Max image display width: 200dp in chat
- File cards: consistent height, ellipsize long filenames
- Download progress: 0-100% with cancel option
## DO NOT
- Implement actual file encryption/decryption
- Implement actual chunked upload/download
- Handle server communication
- Store files on disk (that's ChatClient's job)

View File

@@ -0,0 +1,101 @@
# Agent K: Reactions, Pins, Search, Forward
## Phase: 4 (Feature Completion)
## Depends on: Agent F (Chat Screen with MessageBubble)
## Context
These features enhance the chat experience. Reactions use 6 predefined emoji.
Pins allow highlighting important messages. Search is client-side through cached messages.
Forward sends messages to other conversations with attribution.
## Task
Create reaction picker, pin UI, search overlay, forward dialog, and @mention autocomplete.
## Files to Create
### 1. ui/chat/ReactionPicker.kt
Emoji reaction picker composable:
- **Layout**: Horizontal row of 6 emoji buttons in a rounded Surface1 card
- **Emoji**: thumbsup, heart, laugh, surprised, sad, thumbsdown
- **Display**: Use emoji characters (not images)
- **Behavior**:
- Shown as popup/overlay near the long-pressed message
- Tap emoji: add reaction (or remove if already reacted)
- Dismiss on outside tap
- **Animation**: Scale-in animation when appearing
### 2. ui/chat/ReactionBadge.kt
Reaction display below messages:
- **Layout**: FlowRow of reaction chips
- **Each chip**: emoji + count in a small pill shape
- Background: Surface1 (or Lavender alpha if user reacted)
- Border: Surface2 (or Lavender if user reacted)
- Text: emoji (14sp) + count (12sp)
- **Behavior**: Tap to toggle own reaction
### 3. ui/chat/PinnedMessagesSheet.kt
Bottom sheet showing pinned messages:
- **Header**: "Pinned Messages" title + close button
- **List**: LazyColumn of pinned messages
- Each item: sender name + message text preview + pin date
- Tap: scroll to message in chat (dismiss sheet)
- **Empty state**: "No pinned messages"
### 4. ui/chat/SearchOverlay.kt
Search bar overlay at top of chat screen:
- **Layout**: Row with:
- Search TextField (weight 1f, with search icon)
- Match count text ("3 of 12")
- Up arrow button (previous result)
- Down arrow button (next result)
- Close button (X)
- **Behavior**:
- Activated by search icon in top bar
- Results update as user types (debounced 300ms)
- Current match highlighted differently from other matches
- Up/Down cycle through results
- Escape or X closes search
- **Match highlighting in messages**: Yellow background on matching text
### 5. ui/chat/ForwardPickerDialog.kt
Dialog for selecting forwarding target:
- **Title**: "Forward to..."
- **Conversation list**: LazyColumn of all user's conversations
- Each item: avatar + conversation name
- Tap: forward to that conversation and dismiss
- **Search field**: Filter conversations by name
- **Cancel button**
### 6. ui/chat/MentionAutocomplete.kt
@mention autocomplete popup:
- **Trigger**: Typing "@" in message input
- **Layout**: Popup above the text input
- LazyColumn of matching member names
- Each item: avatar (24dp) + username
- **Behavior**:
- Filters as user types after "@"
- Tap: inserts "@username " into text field
- Dismiss on escape, backspace past "@", or clicking outside
- **Styling**: Surface1 background, elevated (shadow)
## Reaction Emoji Mapping
```
"thumbsup" -> 👍
"heart" -> ❤️
"laugh" -> 😂
"surprised" -> 😮
"sad" -> 😢
"thumbsdown" -> 👎
```
## Constraints
- Reaction picker uses emoji characters (Unicode), not custom images
- Search is debounced (300ms) to avoid excessive computation
- Forward dialog loads conversation list from local cache
- @mention autocomplete only shows members of current conversation
- All animations should be subtle and fast (200-300ms)
## DO NOT
- Implement actual server calls for reactions/pins
- Handle message encryption for forwarding
- Implement full-text search indexing (just LIKE query on cached text)

View File

@@ -0,0 +1,299 @@
# Agent L: Notifications + Background Service
## Phase: 4 (Feature Completion)
## Depends on: Agent A (Manifest), Agent I (DI)
## Context
The app needs a persistent TCP connection to receive real-time push notifications from the server.
On Android, this requires a Foreground Service to keep the connection alive when the app is backgrounded.
Additionally, local notifications are shown when messages arrive while the app is not in focus.
## Task
Create foreground service for TCP connection, notification channels, and app lifecycle handling.
## Files to Create
### 1. core/ChatService.kt
Android Foreground Service:
```kotlin
package com.kecalek.chat.core
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.kecalek.chat.MainActivity
import com.kecalek.chat.R
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class ChatService : Service() {
// TODO: @Inject ChatClient or ConnectionManager
companion object {
const val CHANNEL_ID_SERVICE = "kecalek_service"
const val CHANNEL_ID_MESSAGES = "kecalek_messages"
const val CHANNEL_ID_GROUPS = "kecalek_groups"
const val CHANNEL_ID_SYSTEM = "kecalek_system"
const val NOTIFICATION_ID_SERVICE = 1
}
override fun onCreate() {
super.onCreate()
createNotificationChannels()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NOTIFICATION_ID_SERVICE, buildServiceNotification())
// TODO: Start TCP connection listener
// TODO: Handle incoming notifications and show local notifications
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
// TODO: Disconnect from server
super.onDestroy()
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
// Service channel (silent, low priority)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID_SERVICE,
"Connection Service",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Keeps the encrypted connection alive"
setShowBadge(false)
}
)
// Message notifications (high priority, sound)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID_MESSAGES,
"Messages",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "New message notifications"
enableVibration(true)
}
)
// Group notifications
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID_GROUPS,
"Groups",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Group activity notifications"
}
)
// System notifications (key changes, connection)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID_SYSTEM,
"System",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Connection and security notifications"
}
)
}
}
private fun buildServiceNotification(): Notification {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(this, CHANNEL_ID_SERVICE)
.setContentTitle("Kecalek")
.setContentText("Connected securely")
.setSmallIcon(android.R.drawable.ic_lock_lock) // TODO: Custom icon
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSilent(true)
.build()
}
}
```
### 2. core/NotificationHelper.kt
Helper for showing local notifications:
```kotlin
package com.kecalek.chat.core
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.kecalek.chat.MainActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NotificationHelper @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private var nextNotificationId = 100
fun showMessageNotification(
senderName: String,
conversationId: String,
messagePreview: String?,
) {
// E2EE: Never show plaintext in notification if app is locked
val displayText = messagePreview ?: "New encrypted message"
val intent = Intent(context, MainActivity::class.java).apply {
putExtra("conversationId", conversationId)
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
context, conversationId.hashCode(), intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_MESSAGES)
.setContentTitle(senderName)
.setContentText(displayText)
.setSmallIcon(android.R.drawable.ic_dialog_email) // TODO: Custom icon
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setGroup("messages")
.build()
notificationManager.notify(nextNotificationId++, notification)
}
fun showGroupNotification(
groupName: String,
action: String, // "invited you", "member joined", etc.
) {
val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_GROUPS)
.setContentTitle(groupName)
.setContentText(action)
.setSmallIcon(android.R.drawable.ic_dialog_info) // TODO: Custom icon
.setAutoCancel(true)
.build()
notificationManager.notify(nextNotificationId++, notification)
}
fun showSystemNotification(title: String, text: String) {
val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_SYSTEM)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_lock_lock) // TODO: Custom icon
.setAutoCancel(true)
.build()
notificationManager.notify(nextNotificationId++, notification)
}
fun cancelAll() {
notificationManager.cancelAll()
}
}
```
### 3. core/AppLifecycleObserver.kt
```kotlin
package com.kecalek.chat.core
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import javax.inject.Inject
import javax.inject.Singleton
/**
* Observes app lifecycle to manage TCP connection state.
* - Foreground: ensure connection is active
* - Background: keep connection via foreground service
*/
@Singleton
class AppLifecycleObserver @Inject constructor(
// TODO: Inject ChatClient or ConnectionManager
) : DefaultLifecycleObserver {
var isInForeground: Boolean = false
private set
override fun onStart(owner: LifecycleOwner) {
isInForeground = true
// TODO: Reconnect if disconnected, health check
}
override fun onStop(owner: LifecycleOwner) {
isInForeground = false
// TODO: Connection stays alive via foreground service
}
}
```
### 4. Update AndroidManifest.xml
Add service declaration:
```xml
<service
android:name=".core.ChatService"
android:foregroundServiceType="dataSync"
android:exported="false" />
```
## Notification Types to Handle
These 17 server push notification types must trigger appropriate local notifications:
| Server Type | Notification Channel | Display |
|-------------|---------------------|---------|
| `new_message` | Messages | "Sender: message preview" |
| `messages_read` | (none) | Silent — update UI only |
| `message_deleted` | (none) | Silent — update UI only |
| `message_reacted` | (none) | Silent — update UI only |
| `message_pinned` | (none) | Silent — update UI only |
| `message_unpinned` | (none) | Silent — update UI only |
| `conversation_created` | Groups | "New conversation created" |
| `conversation_renamed` | Groups | "Group renamed to X" |
| `member_added` | Groups | "X joined the group" |
| `member_removed` | Groups | "X was removed" |
| `group_invitation` | Groups | "X invited you to Y" |
| `user_online` | (none) | Silent — update UI only |
| `user_offline` | (none) | Silent — update UI only |
| `online_users` | (none) | Silent — update UI only |
| `session_reset` | System | "Session reset by X" |
| `keys_updated` | System | "Security keys updated" |
## Constraints
- Foreground service with `FOREGROUND_SERVICE_DATA_SYNC` type
- `START_STICKY` to restart if killed
- Notification channels required for Android O+
- E2EE consideration: don't show plaintext in notifications when app is locked
- Group notifications by conversation for notification stacking
- Use PendingIntent.FLAG_IMMUTABLE (Android 12+ requirement)
## DO NOT
- Implement actual TCP connection management
- Handle message decryption
- Implement Firebase Cloud Messaging (future enhancement)
- Store messages or update database directly (delegate to ChatClient)

View File

@@ -0,0 +1,261 @@
# Agent M: Settings + Polish
## Phase: 4 (Feature Completion)
## Depends on: Agent B (Theme + Navigation), Agent D (Auth for server config)
## Context
Final polish: settings screen, connection indicator, privacy lock screen, error handling UI.
## Task
Create settings screen, reusable UI components for connection status, error handling, and privacy lock.
## Files to Create
### 1. ui/settings/SettingsScreen.kt
Jetpack Compose settings screen:
- **Top bar**: Back arrow + "Settings" title
- **Server Configuration section**:
- Host text field (current value displayed)
- Port text field
- TLS toggle switch
- "Save" button
- **Account section**:
- "Change Username" button
- "Change Password" button
- "Key Rotation" button (Peach/warning color with info text)
- "My Devices" button (navigates to DeviceList)
- **Privacy section**:
- "Privacy Lock" toggle (enables biometric/PIN lock)
- "Lock Timeout" selector (30s, 1min, 5min)
- **About section**:
- App version ("Kecalek v0.8.5")
- "End-to-end encrypted" info text
- "Signal Protocol (X3DH + Double Ratchet)"
- **Danger zone**:
- "Delete Account" button (Red, with confirmation dialog)
- "Logout" button (Red outlined)
### 2. ui/components/ConnectionIndicator.kt
```kotlin
package com.kecalek.chat.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kecalek.chat.ui.theme.CatppuccinMocha
enum class ConnectionStatus {
CONNECTED,
DISCONNECTED,
RECONNECTING,
}
/**
* Small dot indicator showing connection status.
* Green = connected, Red = disconnected, Orange = reconnecting.
*/
@Composable
fun ConnectionIndicator(
status: ConnectionStatus,
showLabel: Boolean = false,
modifier: Modifier = Modifier,
) {
val color = when (status) {
ConnectionStatus.CONNECTED -> CatppuccinMocha.Green
ConnectionStatus.DISCONNECTED -> CatppuccinMocha.Red
ConnectionStatus.RECONNECTING -> CatppuccinMocha.Peach
}
val label = when (status) {
ConnectionStatus.CONNECTED -> "Connected"
ConnectionStatus.DISCONNECTED -> "Disconnected"
ConnectionStatus.RECONNECTING -> "Reconnecting..."
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Box(
modifier = Modifier
.size(8.dp)
.background(color, CircleShape)
)
if (showLabel) {
Spacer(Modifier.width(4.dp))
Text(
text = label,
color = color,
fontSize = 11.sp,
)
}
}
}
```
### 3. ui/components/SearchBar.kt
```kotlin
package com.kecalek.chat.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.kecalek.chat.ui.theme.CatppuccinMocha
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
onClose: () -> Unit,
placeholder: String = "Search...",
modifier: Modifier = Modifier,
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text(placeholder, color = CatppuccinMocha.Overlay1) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Default.Close, contentDescription = "Clear")
}
}
},
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = CatppuccinMocha.Lavender,
unfocusedBorderColor = CatppuccinMocha.Surface2,
focusedContainerColor = CatppuccinMocha.Surface1,
unfocusedContainerColor = CatppuccinMocha.Surface1,
),
)
}
```
### 4. ui/auth/PrivacyLockScreen.kt
Privacy lock overlay (anti-forensic feature):
- **Full-screen overlay** covering all content
- **Dark background** (Base color, 98% opacity)
- **Lock icon** centered (large, 64dp)
- **Password field** (or biometric prompt)
- **"Unlock" button** (Lavender)
- **Behavior**:
- Shown when app loses focus for > 30 seconds (configurable)
- Immediate dark overlay on app background (hides content)
- After timeout: requires password/biometric to unlock
- Password verified by decrypting identity key (ECP1 format)
### 5. ui/components/ErrorSnackbar.kt
```kotlin
package com.kecalek.chat.ui.components
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.kecalek.chat.ui.theme.CatppuccinMocha
@Composable
fun ErrorSnackbar(
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier,
) {
SnackbarHost(
hostState = snackbarHostState,
modifier = modifier,
) { data ->
Snackbar(
containerColor = CatppuccinMocha.Red.copy(alpha = 0.9f),
contentColor = CatppuccinMocha.Text,
actionColor = CatppuccinMocha.Rosewater,
snackbarData = data,
)
}
}
```
### 6. ui/components/ConfirmationDialog.kt
```kotlin
package com.kecalek.chat.ui.components
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import com.kecalek.chat.ui.theme.CatppuccinMocha
@Composable
fun ConfirmationDialog(
title: String,
message: String,
confirmText: String = "Confirm",
dismissText: String = "Cancel",
isDestructive: Boolean = false,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
containerColor = CatppuccinMocha.Surface0,
titleContentColor = CatppuccinMocha.Text,
textContentColor = CatppuccinMocha.Subtext1,
title = { Text(title) },
text = { Text(message) },
confirmButton = {
FilledTonalButton(
onClick = onConfirm,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = if (isDestructive) CatppuccinMocha.Red else CatppuccinMocha.Lavender,
contentColor = if (isDestructive) CatppuccinMocha.Text else CatppuccinMocha.Base,
),
) {
Text(confirmText)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(dismissText, color = CatppuccinMocha.Subtext1)
}
},
)
}
```
## Constraints
- Settings persist via DataStore or SharedPreferences
- Server config changes require reconnection
- Privacy lock uses BiometricPrompt API
- Connection indicator should be lightweight (placed in top bars)
- Confirmation dialogs for all destructive actions
- Error snackbar theming matches Catppuccin Mocha
## DO NOT
- Implement actual server reconnection logic
- Handle biometric authentication implementation (just the UI shell)
- Implement password verification against ECP1 keys
- Implement actual account deletion (server endpoint doesn't exist yet)