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:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# SSH keys (never commit!)
|
||||
android_studio
|
||||
android_studio.pub
|
||||
*.pem
|
||||
*.key
|
||||
id_rsa
|
||||
id_ed25519
|
||||
|
||||
# Local configuration
|
||||
local.properties
|
||||
|
||||
# Gradle build cache & outputs
|
||||
.gradle/
|
||||
build/
|
||||
**/build/
|
||||
|
||||
# Android Studio / IntelliJ IDE generated files
|
||||
.idea/caches/
|
||||
.idea/libraries/
|
||||
.idea/modules.xml
|
||||
.idea/workspace.xml
|
||||
.idea/navEditor.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
|
||||
# Generated files
|
||||
*.class
|
||||
*.ap_
|
||||
|
||||
# Kotlin incremental compilation
|
||||
**/.kotlin/
|
||||
|
||||
# OS junk
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
3
.idea/.gitignore
generated
vendored
Normal file
3
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
1
.idea/.name
generated
Normal file
1
.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
||||
Kecalek
|
||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
123
.idea/codeStyles/Project.xml
generated
Normal file
123
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,123 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
6
.idea/compiler.xml
generated
Normal file
6
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
18
.idea/gradle.xml
generated
Normal file
18
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/migrations.xml
generated
Normal file
10
.idea/migrations.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/misc.xml
generated
Normal file
10
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
17
.idea/runConfigurations.xml
generated
Normal file
17
.idea/runConfigurations.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
91
app/build.gradle.kts
Normal file
91
app/build.gradle.kts
Normal file
@@ -0,0 +1,91 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("com.google.devtools.ksp")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.kecalek.chat"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.kecalek.chat"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
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
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2025.01.01")
|
||||
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.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.navigation:navigation-compose:2.8.5")
|
||||
|
||||
implementation("com.google.dagger:hilt-android:2.59.2")
|
||||
ksp("com.google.dagger:hilt-compiler:2.59.2")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
|
||||
|
||||
implementation("androidx.room:room-runtime:2.8.4")
|
||||
implementation("androidx.room:room-ktx:2.8.4")
|
||||
ksp("androidx.room:room-compiler:2.8.4")
|
||||
implementation("net.zetetic:sqlcipher-android:4.13.0")
|
||||
implementation("androidx.sqlite:sqlite-ktx:2.4.0")
|
||||
|
||||
implementation("com.google.crypto.tink:tink-android:1.12.0")
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.77")
|
||||
implementation("org.bouncycastle:bcpkix-jdk18on:1.77")
|
||||
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
|
||||
|
||||
implementation("androidx.camera:camera-camera2:1.5.0")
|
||||
implementation("androidx.camera:camera-lifecycle:1.5.0")
|
||||
implementation("androidx.camera:camera-view:1.5.0")
|
||||
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
androidTestImplementation(composeBom)
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
10
app/proguard-rules.pro
vendored
Normal file
10
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Bouncy Castle
|
||||
-keep class org.bouncycastle.** { *; }
|
||||
-dontwarn org.bouncycastle.**
|
||||
|
||||
# Tink
|
||||
-keep class com.google.crypto.tink.** { *; }
|
||||
|
||||
# SQLCipher
|
||||
-keep class net.sqlcipher.** { *; }
|
||||
-keep class net.sqlcipher.database.** { *; }
|
||||
39
app/src/main/AndroidManifest.xml
Normal file
39
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?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>
|
||||
|
||||
<service
|
||||
android:name=".core.ChatService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
22
app/src/main/java/com/kecalek/chat/KecalekApp.kt
Normal file
22
app/src/main/java/com/kecalek/chat/KecalekApp.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.kecalek.chat
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||
import java.security.Security
|
||||
|
||||
@HiltAndroidApp
|
||||
class KecalekApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Replace Android's stripped Bouncy Castle with full version.
|
||||
// Required for RSASSA-PSS, Ed25519, X25519, etc.
|
||||
Security.removeProvider("BC")
|
||||
Security.insertProviderAt(BouncyCastleProvider(), 1)
|
||||
|
||||
// Load SQLCipher native library early, on app startup.
|
||||
// Must happen before Room/SQLCipher is used.
|
||||
System.loadLibrary("sqlcipher")
|
||||
}
|
||||
}
|
||||
22
app/src/main/java/com/kecalek/chat/MainActivity.kt
Normal file
22
app/src/main/java/com/kecalek/chat/MainActivity.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.kecalek.chat
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
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 {
|
||||
KecalekNavGraph()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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() : 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
|
||||
}
|
||||
}
|
||||
563
app/src/main/java/com/kecalek/chat/core/ChatClient.kt
Normal file
563
app/src/main/java/com/kecalek/chat/core/ChatClient.kt
Normal file
@@ -0,0 +1,563 @@
|
||||
package com.kecalek.chat.core
|
||||
|
||||
import com.kecalek.chat.crypto.*
|
||||
import com.kecalek.chat.network.ConnectionManager
|
||||
import com.kecalek.chat.network.ServerApi
|
||||
import com.kecalek.chat.network.decodeBinary
|
||||
import com.kecalek.chat.network.encodeBinary
|
||||
import com.kecalek.chat.util.Constants
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.params.X25519PublicKeyParameters
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Main chat client orchestrator.
|
||||
* Handles all encrypted message sending/receiving, session management,
|
||||
* prekey rotation, and push notification processing.
|
||||
*
|
||||
* This is the Android equivalent of Python chat_core.py / iOS ChatClient.swift.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Per-device Double Ratchet session management
|
||||
* - X3DH key exchange for new sessions
|
||||
* - Sender key distribution for group messaging
|
||||
* - Self-encryption for multi-device access
|
||||
* - Prekey count monitoring and rotation
|
||||
* - TOFU (Trust On First Use) identity key tracking
|
||||
*/
|
||||
@Singleton
|
||||
class ChatClient @Inject constructor(
|
||||
private val api: ServerApi,
|
||||
private val connection: ConnectionManager,
|
||||
private val keyStorage: KeyStorage,
|
||||
private val sessionManager: SessionManager,
|
||||
private val notificationRouter: NotificationRouter,
|
||||
) {
|
||||
|
||||
private var identityPrivate: Ed25519PrivateKeyParameters? = null
|
||||
private var identityPublic: Ed25519PublicKeyParameters? = null
|
||||
private var selfEncryptionKey: ByteArray? = null
|
||||
|
||||
// Session cache: (userId, deviceId) -> DoubleRatchet
|
||||
private val sessions = mutableMapOf<String, DoubleRatchet>()
|
||||
private val sessionMutex = Mutex()
|
||||
|
||||
// Device bundle cache: userId -> (bundles, timestamp)
|
||||
private val bundleCache = mutableMapOf<String, Pair<List<DeviceBundleInfo>, Long>>()
|
||||
|
||||
// Sender key cache: (conversationId, userId) -> SenderKeyState
|
||||
private val senderKeys = mutableMapOf<String, SenderKeyState>()
|
||||
|
||||
// TOFU registry: userId -> identityKeyBytes
|
||||
private val tofuRegistry = mutableMapOf<String, ByteArray>()
|
||||
|
||||
// Self-encrypt queue for multi-device
|
||||
private val selfEncryptQueue = mutableListOf<SelfEncryptEntry>()
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Initialize after login. Loads keys and sets up notification handlers.
|
||||
*/
|
||||
suspend fun initialize(password: String) {
|
||||
identityPrivate = keyStorage.loadIdentityPrivate(password)
|
||||
identityPublic = keyStorage.loadIdentityPublic()
|
||||
|
||||
val privRaw = Ed25519Crypto.serializePrivate(identityPrivate!!)
|
||||
keyStorage.initLocalKey(privRaw)
|
||||
selfEncryptionKey = HkdfUtils.deriveSelfEncryptionKey(privRaw)
|
||||
|
||||
// Load TOFU registry
|
||||
tofuRegistry.putAll(keyStorage.loadTofuRegistry())
|
||||
|
||||
// Ensure prekeys are sufficient
|
||||
ensurePrekeys()
|
||||
|
||||
// Register notification handlers
|
||||
setupNotificationHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an encrypted DM (direct message).
|
||||
* Encrypts per-device with Double Ratchet + self-encryption for multi-device.
|
||||
*/
|
||||
suspend fun sendDm(
|
||||
conversationId: String,
|
||||
plaintext: ByteArray,
|
||||
replyTo: String? = null,
|
||||
imageFileId: String? = null,
|
||||
): String {
|
||||
val session = sessionManager.currentSession
|
||||
?: throw IllegalStateException("Not logged in")
|
||||
|
||||
// Pad plaintext
|
||||
val padded = MessagePadding.pad(plaintext)
|
||||
|
||||
// Get device bundles for all recipients
|
||||
val conversations = api.listConversations()
|
||||
// For simplicity, we'll encrypt directly with known sessions
|
||||
|
||||
// Get recipient user IDs from the conversation
|
||||
val recipientEntries = mutableListOf<Map<String, Any?>>()
|
||||
|
||||
// Get device bundles for all members
|
||||
val convResp = api.getMessages(conversationId, limit = 0)
|
||||
|
||||
// Encrypt for each recipient device
|
||||
val deviceBundles = getDeviceBundles(session.userId) // self bundles
|
||||
// TODO: Get bundles for all conversation members and encrypt per-device
|
||||
|
||||
// Self-encryption for multi-device access
|
||||
val selfResult = encryptSelf(padded)
|
||||
recipientEntries.add(mapOf(
|
||||
"user_id" to session.userId,
|
||||
"device_id" to Constants.SELF_DEVICE_ID,
|
||||
"encrypted_content" to encodeBinary(selfResult.ciphertext),
|
||||
"nonce" to encodeBinary(selfResult.nonce),
|
||||
))
|
||||
|
||||
// Build ratchet header from first recipient encryption
|
||||
// TODO: Use actual ratchet header from per-device encryption
|
||||
val ratchetHeader = mapOf(
|
||||
"dh_pub" to "TODO",
|
||||
"n" to 0,
|
||||
"pn" to 0,
|
||||
)
|
||||
|
||||
val resp = api.sendMessage(
|
||||
conversationId = conversationId,
|
||||
ratchetHeader = ratchetHeader,
|
||||
recipients = recipientEntries,
|
||||
imageFileId = imageFileId,
|
||||
)
|
||||
|
||||
if (!resp.isOk) throw Exception("Send failed: ${resp.errorMessage}")
|
||||
return resp.data.getString("message_id")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an encrypted group message using sender keys.
|
||||
*/
|
||||
suspend fun sendGroupMessage(
|
||||
conversationId: String,
|
||||
plaintext: ByteArray,
|
||||
memberIds: List<String>,
|
||||
): String {
|
||||
val session = sessionManager.currentSession
|
||||
?: throw IllegalStateException("Not logged in")
|
||||
|
||||
val padded = MessagePadding.pad(plaintext)
|
||||
|
||||
// Get or create sender key for this conversation
|
||||
val senderKeyState = getOrCreateSenderKey(conversationId, session.userId)
|
||||
|
||||
// Encrypt with sender key (symmetric)
|
||||
val skMessage = senderKeyState.encrypt(padded)
|
||||
|
||||
// Save updated state
|
||||
keyStorage.saveSenderKey(conversationId, session.userId, senderKeyState.exportState())
|
||||
|
||||
// Distribute sender key to members who don't have it yet
|
||||
// TODO: Track which members have received the sender key
|
||||
|
||||
// Build recipients list with per-device encrypted sender key distribution
|
||||
val recipientEntries = mutableListOf<Map<String, Any?>>()
|
||||
|
||||
// Self-encryption
|
||||
val selfResult = encryptSelf(padded)
|
||||
recipientEntries.add(mapOf(
|
||||
"user_id" to session.userId,
|
||||
"device_id" to Constants.SELF_DEVICE_ID,
|
||||
"encrypted_content" to encodeBinary(selfResult.ciphertext),
|
||||
"nonce" to encodeBinary(selfResult.nonce),
|
||||
))
|
||||
|
||||
val resp = api.sendMessage(
|
||||
conversationId = conversationId,
|
||||
ratchetHeader = mapOf("dh_pub" to "group", "n" to 0, "pn" to 0),
|
||||
recipients = recipientEntries,
|
||||
senderChainId = encodeBinary(skMessage.chainIdHex.hexToBytes()),
|
||||
senderChainN = skMessage.n,
|
||||
)
|
||||
|
||||
if (!resp.isOk) throw Exception("Send failed: ${resp.errorMessage}")
|
||||
return resp.data.getString("message_id")
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a received DM.
|
||||
*/
|
||||
suspend fun decryptDm(
|
||||
senderId: String,
|
||||
senderDeviceId: String,
|
||||
encryptedContent: ByteArray,
|
||||
nonce: ByteArray,
|
||||
ratchetHeaderMap: Map<String, Any>,
|
||||
x3dhHeaderMap: Map<String, Any>? = null,
|
||||
): ByteArray = sessionMutex.withLock {
|
||||
val sessionKey = "${senderId}_${senderDeviceId}"
|
||||
|
||||
// If X3DH header present, establish new session
|
||||
if (x3dhHeaderMap != null) {
|
||||
val ratchet = establishSessionFromX3DH(senderId, senderDeviceId, x3dhHeaderMap)
|
||||
sessions[sessionKey] = ratchet
|
||||
}
|
||||
|
||||
val ratchet = sessions[sessionKey]
|
||||
?: loadOrCreateSession(senderId, senderDeviceId)
|
||||
|
||||
val header = RatchetHeader.fromMap(ratchetHeaderMap)
|
||||
val padded = ratchet.decrypt(header, encryptedContent, nonce)
|
||||
|
||||
// Save updated session state
|
||||
keyStorage.saveSession(senderId, senderDeviceId, ratchet.exportState())
|
||||
sessions[sessionKey] = ratchet
|
||||
|
||||
return MessagePadding.unpad(padded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a self-encrypted message (for multi-device access).
|
||||
*/
|
||||
fun decryptSelf(encryptedContent: ByteArray, nonce: ByteArray): ByteArray {
|
||||
val key = selfEncryptionKey
|
||||
?: throw IllegalStateException("Self-encryption key not initialized")
|
||||
|
||||
val padded = AesGcmCrypto.decryptCombined(key, nonce, encryptedContent)
|
||||
return MessagePadding.unpad(padded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a group message using sender keys.
|
||||
*/
|
||||
suspend fun decryptGroup(
|
||||
conversationId: String,
|
||||
senderId: String,
|
||||
encryptedContent: ByteArray,
|
||||
nonce: ByteArray,
|
||||
chainIdBase64: String,
|
||||
chainN: Int,
|
||||
): ByteArray {
|
||||
val senderKey = getOrLoadSenderKey(conversationId, senderId)
|
||||
?: throw CryptoException.DecryptionFailed("No sender key for $senderId in $conversationId")
|
||||
|
||||
val chainIdHex = decodeBinary(chainIdBase64).toHex()
|
||||
val padded = senderKey.decrypt(chainIdHex, chainN, encryptedContent, nonce)
|
||||
|
||||
// Save updated state
|
||||
keyStorage.saveSenderKey(conversationId, senderId, senderKey.exportState())
|
||||
|
||||
return MessagePadding.unpad(padded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure sufficient prekeys on the server.
|
||||
*/
|
||||
suspend fun ensurePrekeys() {
|
||||
val resp = api.getPrekeyCount()
|
||||
if (!resp.isOk) return
|
||||
|
||||
val count = resp.data.getInt("count")
|
||||
val spkCreatedAt = resp.data.optString("spk_created_at", "")
|
||||
|
||||
val needSpkRotation = shouldRotateSpk(spkCreatedAt)
|
||||
val needOpkRefill = count < Constants.OPK_REPLENISH_THRESHOLD
|
||||
|
||||
if (!needSpkRotation && !needOpkRefill) return
|
||||
|
||||
val fields = mutableMapOf<String, Any?>()
|
||||
|
||||
if (needSpkRotation) {
|
||||
val idPriv = identityPrivate ?: return
|
||||
// Rotate: save current as previous
|
||||
val currentSpk = keyStorage.loadSignedPreKey(isCurrent = true)
|
||||
if (currentSpk != null) {
|
||||
keyStorage.saveSignedPreKey(currentSpk, isCurrent = false)
|
||||
}
|
||||
|
||||
val newSpk = X3DH.generateSignedPreKey(idPriv)
|
||||
keyStorage.saveSignedPreKey(newSpk, isCurrent = true)
|
||||
|
||||
fields["signed_prekey"] = mapOf(
|
||||
"id" to newSpk.id,
|
||||
"public_key" to encodeBinary(X25519Crypto.serializePublic(newSpk.publicKey)),
|
||||
"signature" to encodeBinary(newSpk.signature),
|
||||
)
|
||||
}
|
||||
|
||||
if (needOpkRefill) {
|
||||
val newOpks = X3DH.generateOneTimePreKeys(Constants.OPK_BATCH_SIZE)
|
||||
|
||||
// Save private parts
|
||||
val opkPrivates = keyStorage.loadOpkPrivates().toMutableMap()
|
||||
for (opk in newOpks) {
|
||||
opkPrivates[opk.id] = X25519Crypto.serializePrivate(opk.privateKey)
|
||||
}
|
||||
keyStorage.saveOpkPrivates(opkPrivates)
|
||||
|
||||
fields["one_time_prekeys"] = newOpks.map { opk ->
|
||||
mapOf(
|
||||
"id" to opk.id,
|
||||
"public_key" to encodeBinary(X25519Crypto.serializePublic(opk.publicKey)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
api.ensurePrekeys(
|
||||
signedPrekey = fields["signed_prekey"] as? Map<String, String>,
|
||||
oneTimePrekeys = fields["one_time_prekeys"] as? List<Map<String, String>>,
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Private Helpers =====
|
||||
|
||||
private fun encryptSelf(plaintext: ByteArray): SelfEncryptResult {
|
||||
val key = selfEncryptionKey
|
||||
?: throw IllegalStateException("Self-encryption key not initialized")
|
||||
|
||||
val (nonce, ctWithTag) = AesGcmCrypto.encryptCombined(plaintext, key)
|
||||
return SelfEncryptResult(ctWithTag, nonce)
|
||||
}
|
||||
|
||||
private suspend fun getDeviceBundles(userId: String): List<DeviceBundleInfo> {
|
||||
// Check cache
|
||||
val cached = bundleCache[userId]
|
||||
if (cached != null && System.currentTimeMillis() - cached.second < Constants.DEVICE_BUNDLE_CACHE_TTL_MS) {
|
||||
return cached.first
|
||||
}
|
||||
|
||||
val resp = api.getKeyBundle(userId)
|
||||
if (!resp.isOk) return emptyList()
|
||||
|
||||
val data = resp.data
|
||||
val identityKeyBase64 = data.getString("identity_key")
|
||||
val identityKeyBytes = decodeBinary(identityKeyBase64)
|
||||
|
||||
// Track TOFU
|
||||
trackTofu(userId, identityKeyBytes)
|
||||
|
||||
val bundles = mutableListOf<DeviceBundleInfo>()
|
||||
val deviceBundlesArray = data.optJSONArray("device_bundles")
|
||||
|
||||
if (deviceBundlesArray != null) {
|
||||
for (i in 0 until deviceBundlesArray.length()) {
|
||||
val bundle = deviceBundlesArray.getJSONObject(i)
|
||||
bundles.add(DeviceBundleInfo(
|
||||
deviceId = bundle.getString("device_id"),
|
||||
identityKeyBytes = identityKeyBytes,
|
||||
spkPublicBytes = decodeBinary(bundle.getString("signed_prekey")),
|
||||
spkSignatureBytes = decodeBinary(bundle.getString("spk_signature")),
|
||||
opkPublicBytes = bundle.optString("one_time_prekey", "").takeIf { it.isNotEmpty() }
|
||||
?.let { decodeBinary(it) },
|
||||
opkId = bundle.optString("one_time_prekey_id", null),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
bundleCache[userId] = Pair(bundles, System.currentTimeMillis())
|
||||
return bundles
|
||||
}
|
||||
|
||||
private suspend fun loadOrCreateSession(
|
||||
userId: String,
|
||||
deviceId: String,
|
||||
): DoubleRatchet {
|
||||
val sessionKey = "${userId}_${deviceId}"
|
||||
|
||||
// Try loading from storage
|
||||
val stored = keyStorage.loadSession(userId, deviceId)
|
||||
if (stored != null) {
|
||||
val ratchet = DoubleRatchet.importState(stored)
|
||||
sessions[sessionKey] = ratchet
|
||||
return ratchet
|
||||
}
|
||||
|
||||
// Need to create via X3DH
|
||||
val bundles = getDeviceBundles(userId)
|
||||
val bundle = bundles.find { it.deviceId == deviceId }
|
||||
?: throw CryptoException.X3DHFailed("No bundle for device $deviceId of user $userId")
|
||||
|
||||
val idPriv = identityPrivate ?: throw IllegalStateException("Identity key not loaded")
|
||||
val remoteIdPub = Ed25519Crypto.loadPublic(bundle.identityKeyBytes)
|
||||
val spkPub = X25519Crypto.loadPublic(bundle.spkPublicBytes)
|
||||
val opkPub = bundle.opkPublicBytes?.let { X25519Crypto.loadPublic(it) }
|
||||
|
||||
val x3dhResult = X3DH.initiate(
|
||||
ikPrivateEd = idPriv,
|
||||
ikPublicRemoteEd = remoteIdPub,
|
||||
spkRemote = spkPub,
|
||||
spkSignature = bundle.spkSignatureBytes,
|
||||
opkRemote = opkPub,
|
||||
)
|
||||
|
||||
val ratchet = DoubleRatchet.initAlice(x3dhResult.sharedSecret, spkPub)
|
||||
sessions[sessionKey] = ratchet
|
||||
keyStorage.saveSession(userId, deviceId, ratchet.exportState())
|
||||
|
||||
return ratchet
|
||||
}
|
||||
|
||||
private fun establishSessionFromX3DH(
|
||||
senderId: String,
|
||||
senderDeviceId: String,
|
||||
x3dhHeader: Map<String, Any>,
|
||||
): DoubleRatchet {
|
||||
val idPriv = identityPrivate ?: throw IllegalStateException("Identity key not loaded")
|
||||
val spk = keyStorage.loadSignedPreKey(isCurrent = true)
|
||||
?: keyStorage.loadSignedPreKey(isCurrent = false)
|
||||
?: throw CryptoException.X3DHFailed("No SPK available")
|
||||
|
||||
val ekPubBytes = decodeBinary(x3dhHeader["ek_pub"] as String)
|
||||
val ikPubBytes = decodeBinary(x3dhHeader["ik_pub"] as String)
|
||||
val opkId = x3dhHeader["opk_id"] as? String
|
||||
|
||||
val remoteIdPub = Ed25519Crypto.loadPublic(ikPubBytes)
|
||||
val ekPub = X25519Crypto.loadPublic(ekPubBytes)
|
||||
|
||||
var opkPrivate: org.bouncycastle.crypto.params.X25519PrivateKeyParameters? = null
|
||||
if (opkId != null) {
|
||||
val opkPrivates = keyStorage.loadOpkPrivates()
|
||||
val opkBytes = opkPrivates[opkId]
|
||||
if (opkBytes != null) {
|
||||
opkPrivate = X25519Crypto.loadPrivate(opkBytes)
|
||||
// Remove used OPK
|
||||
val remaining = opkPrivates.toMutableMap()
|
||||
remaining.remove(opkId)
|
||||
keyStorage.saveOpkPrivates(remaining)
|
||||
}
|
||||
}
|
||||
|
||||
val sharedSecret = X3DH.respond(
|
||||
ikPrivateEd = idPriv,
|
||||
spkPrivate = spk.privateKey,
|
||||
ikRemoteEd = remoteIdPub,
|
||||
ekRemote = ekPub,
|
||||
opkPrivate = opkPrivate,
|
||||
)
|
||||
|
||||
// Track TOFU
|
||||
trackTofu(senderId, ikPubBytes)
|
||||
|
||||
val ratchet = DoubleRatchet.initBob(
|
||||
sharedSecret = sharedSecret,
|
||||
spkPair = Pair(spk.privateKey, spk.publicKey),
|
||||
)
|
||||
|
||||
keyStorage.saveSession(senderId, senderDeviceId, ratchet.exportState())
|
||||
return ratchet
|
||||
}
|
||||
|
||||
private fun getOrCreateSenderKey(conversationId: String, userId: String): SenderKeyState {
|
||||
val key = "${conversationId}_${userId}"
|
||||
senderKeys[key]?.let { return it }
|
||||
|
||||
val stored = keyStorage.loadSenderKey(conversationId, userId)
|
||||
if (stored != null) {
|
||||
val state = SenderKeyState.importState(stored)
|
||||
senderKeys[key] = state
|
||||
return state
|
||||
}
|
||||
|
||||
val state = SenderKeyState.create()
|
||||
senderKeys[key] = state
|
||||
keyStorage.saveSenderKey(conversationId, userId, state.exportState())
|
||||
return state
|
||||
}
|
||||
|
||||
private fun getOrLoadSenderKey(conversationId: String, userId: String): SenderKeyState? {
|
||||
val key = "${conversationId}_${userId}"
|
||||
senderKeys[key]?.let { return it }
|
||||
|
||||
val stored = keyStorage.loadSenderKey(conversationId, userId) ?: return null
|
||||
val state = SenderKeyState.importState(stored)
|
||||
senderKeys[key] = state
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a received sender key from a group member.
|
||||
*/
|
||||
fun importSenderKey(conversationId: String, userId: String, exportedKey: ByteArray) {
|
||||
val state = SenderKeyState.fromKey(exportedKey)
|
||||
val key = "${conversationId}_${userId}"
|
||||
senderKeys[key] = state
|
||||
keyStorage.saveSenderKey(conversationId, userId, state.exportState())
|
||||
}
|
||||
|
||||
private fun trackTofu(userId: String, identityKeyBytes: ByteArray) {
|
||||
val existing = tofuRegistry[userId]
|
||||
if (existing != null && !existing.contentEquals(identityKeyBytes)) {
|
||||
// Identity key changed! This is a potential MITM attack.
|
||||
// TODO: Emit warning event for UI to display
|
||||
android.util.Log.w("ChatClient", "Identity key changed for user $userId!")
|
||||
}
|
||||
tofuRegistry[userId] = identityKeyBytes
|
||||
keyStorage.saveTofuRegistry(tofuRegistry)
|
||||
}
|
||||
|
||||
private fun shouldRotateSpk(spkCreatedAt: String): Boolean {
|
||||
if (spkCreatedAt.isEmpty()) return true
|
||||
return try {
|
||||
val created = java.time.Instant.parse(spkCreatedAt)
|
||||
val age = java.time.Duration.between(created, java.time.Instant.now())
|
||||
age.toDays() >= Constants.SPK_ROTATION_DAYS
|
||||
} catch (_: Exception) {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupNotificationHandlers() {
|
||||
connection.onMessage = { json ->
|
||||
notificationRouter.route(json)
|
||||
}
|
||||
|
||||
// Handle new_message push
|
||||
notificationRouter.on(NotificationRouter.NEW_MESSAGE) { data ->
|
||||
// TODO: Decrypt message and update UI/DB
|
||||
// This requires async handling - will be wired in Phase 3/4
|
||||
}
|
||||
|
||||
// Handle session_reset push
|
||||
notificationRouter.on(NotificationRouter.SESSION_RESET) { data ->
|
||||
val fromUserId = data.getString("from_user_id")
|
||||
val fromDeviceId = data.getString("from_device_id")
|
||||
// Remove session to force re-establishment
|
||||
val sessionKey = "${fromUserId}_${fromDeviceId}"
|
||||
sessions.remove(sessionKey)
|
||||
keyStorage.deleteSession(fromUserId, fromDeviceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DeviceBundleInfo(
|
||||
val deviceId: String,
|
||||
val identityKeyBytes: ByteArray,
|
||||
val spkPublicBytes: ByteArray,
|
||||
val spkSignatureBytes: ByteArray,
|
||||
val opkPublicBytes: ByteArray?,
|
||||
val opkId: String?,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is DeviceBundleInfo) return false
|
||||
return deviceId == other.deviceId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = deviceId.hashCode()
|
||||
}
|
||||
|
||||
private data class SelfEncryptResult(
|
||||
val ciphertext: ByteArray,
|
||||
val nonce: ByteArray,
|
||||
)
|
||||
|
||||
private data class SelfEncryptEntry(
|
||||
val messageId: String,
|
||||
val ciphertext: ByteArray,
|
||||
val nonce: ByteArray,
|
||||
)
|
||||
111
app/src/main/java/com/kecalek/chat/core/ChatService.kt
Normal file
111
app/src/main/java/com/kecalek/chat/core/ChatService.kt
Normal file
@@ -0,0 +1,111 @@
|
||||
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 dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChatService : Service() {
|
||||
|
||||
@Inject lateinit var notificationHelper: NotificationHelper
|
||||
|
||||
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 via ConnectionManager
|
||||
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)
|
||||
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID_SERVICE,
|
||||
"Connection Service",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Keeps the encrypted connection alive"
|
||||
setShowBadge(false)
|
||||
}
|
||||
)
|
||||
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID_MESSAGES,
|
||||
"Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "New message notifications"
|
||||
enableVibration(true)
|
||||
}
|
||||
)
|
||||
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID_GROUPS,
|
||||
"Groups",
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Group activity notifications"
|
||||
}
|
||||
)
|
||||
|
||||
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)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
260
app/src/main/java/com/kecalek/chat/core/KeyStorage.kt
Normal file
260
app/src/main/java/com/kecalek/chat/core/KeyStorage.kt
Normal file
@@ -0,0 +1,260 @@
|
||||
package com.kecalek.chat.core
|
||||
|
||||
import android.content.Context
|
||||
import com.kecalek.chat.crypto.*
|
||||
import com.kecalek.chat.network.decodeBinary
|
||||
import com.kecalek.chat.network.encodeBinary
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.params.X25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.X25519PublicKeyParameters
|
||||
import java.io.File
|
||||
import java.security.interfaces.RSAPrivateKey
|
||||
import java.security.interfaces.RSAPublicKey
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Encrypted local key persistence.
|
||||
* All sensitive keys are encrypted at rest using AES-256-GCM with a key derived
|
||||
* from the identity key (via HKDF).
|
||||
*
|
||||
* RSA and identity keys use ECP1 format (password-based encryption).
|
||||
* Other keys use local storage key derived from identity private key.
|
||||
*
|
||||
* Storage layout in app-private files dir:
|
||||
* keys/
|
||||
* rsa_private.ecp1 - RSA private key (ECP1 encrypted with password)
|
||||
* rsa_public.der - RSA public key (DER unencrypted)
|
||||
* identity_private.ecp1 - Ed25519 private key (ECP1 encrypted with password)
|
||||
* identity_public.raw - Ed25519 public key (32 bytes unencrypted)
|
||||
* spk_current.enc - Current signed pre-key (AES-GCM via local key)
|
||||
* spk_previous.enc - Previous SPK for grace period
|
||||
* opk_privates.enc - OPK private keys map (AES-GCM via local key)
|
||||
* sessions/ - Double Ratchet session states
|
||||
* {userId}_{deviceId}.enc
|
||||
* sender_keys/ - Group sender key states
|
||||
* {conversationId}_{userId}.enc
|
||||
* tofu.enc - TOFU identity key registry
|
||||
* verified.enc - Verified contacts set
|
||||
*/
|
||||
@Singleton
|
||||
class KeyStorage @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
private val keysDir: File get() = File(context.filesDir, "keys").also { it.mkdirs() }
|
||||
private val sessionsDir: File get() = File(keysDir, "sessions").also { it.mkdirs() }
|
||||
private val senderKeysDir: File get() = File(keysDir, "sender_keys").also { it.mkdirs() }
|
||||
|
||||
private var localKey: ByteArray? = null
|
||||
|
||||
/**
|
||||
* Initialize local storage key from identity private key.
|
||||
* Must be called after loading identity key.
|
||||
*/
|
||||
fun initLocalKey(identityPrivateRaw: ByteArray) {
|
||||
localKey = HkdfUtils.deriveLocalStorageKey(identityPrivateRaw)
|
||||
}
|
||||
|
||||
// ===== RSA Keys (ECP1) =====
|
||||
|
||||
fun saveRsaKeys(privateKey: RSAPrivateKey, publicKey: RSAPublicKey, password: String) {
|
||||
File(keysDir, "rsa_private.ecp1").writeBytes(RSACrypto.serializePrivate(privateKey, password))
|
||||
File(keysDir, "rsa_public.der").writeBytes(RSACrypto.serializePublic(publicKey))
|
||||
}
|
||||
|
||||
fun loadRsaPrivate(password: String): RSAPrivateKey {
|
||||
val data = File(keysDir, "rsa_private.ecp1").readBytes()
|
||||
return RSACrypto.loadPrivate(data, password)
|
||||
}
|
||||
|
||||
fun loadRsaPublic(): RSAPublicKey {
|
||||
val data = File(keysDir, "rsa_public.der").readBytes()
|
||||
return RSACrypto.loadPublic(data)
|
||||
}
|
||||
|
||||
fun hasRsaKeys(): Boolean = File(keysDir, "rsa_private.ecp1").exists()
|
||||
|
||||
// ===== Identity Keys (ECP1) =====
|
||||
|
||||
fun saveIdentityKeys(
|
||||
privateKey: Ed25519PrivateKeyParameters,
|
||||
publicKey: Ed25519PublicKeyParameters,
|
||||
password: String,
|
||||
) {
|
||||
val privRaw = Ed25519Crypto.serializePrivate(privateKey)
|
||||
File(keysDir, "identity_private.ecp1").writeBytes(KeyEncryption.encrypt(privRaw, password))
|
||||
File(keysDir, "identity_public.raw").writeBytes(Ed25519Crypto.serializePublic(publicKey))
|
||||
}
|
||||
|
||||
fun loadIdentityPrivate(password: String): Ed25519PrivateKeyParameters {
|
||||
val data = File(keysDir, "identity_private.ecp1").readBytes()
|
||||
val raw = KeyEncryption.decrypt(data, password)
|
||||
return Ed25519Crypto.loadPrivate(raw)
|
||||
}
|
||||
|
||||
fun loadIdentityPublic(): Ed25519PublicKeyParameters {
|
||||
val data = File(keysDir, "identity_public.raw").readBytes()
|
||||
return Ed25519Crypto.loadPublic(data)
|
||||
}
|
||||
|
||||
fun hasIdentityKeys(): Boolean = File(keysDir, "identity_private.ecp1").exists()
|
||||
|
||||
// ===== Signed Pre-Key (AES-GCM via local key) =====
|
||||
|
||||
fun saveSignedPreKey(spk: SignedPreKey, isCurrent: Boolean = true) {
|
||||
val filename = if (isCurrent) "spk_current.enc" else "spk_previous.enc"
|
||||
val data = serializeSpk(spk)
|
||||
encryptAndSave(filename, data)
|
||||
}
|
||||
|
||||
fun loadSignedPreKey(isCurrent: Boolean = true): SignedPreKey? {
|
||||
val filename = if (isCurrent) "spk_current.enc" else "spk_previous.enc"
|
||||
val data = loadAndDecrypt(filename) ?: return null
|
||||
return deserializeSpk(data)
|
||||
}
|
||||
|
||||
// ===== One-Time Pre-Keys (AES-GCM via local key) =====
|
||||
|
||||
fun saveOpkPrivates(opks: Map<String, ByteArray>) {
|
||||
val combined = org.json.JSONObject()
|
||||
for ((id, privBytes) in opks) {
|
||||
combined.put(id, encodeBinary(privBytes))
|
||||
}
|
||||
encryptAndSave("opk_privates.enc", combined.toString().toByteArray())
|
||||
}
|
||||
|
||||
fun loadOpkPrivates(): Map<String, ByteArray> {
|
||||
val data = loadAndDecrypt("opk_privates.enc") ?: return emptyMap()
|
||||
val json = org.json.JSONObject(String(data))
|
||||
return json.keys().asSequence().associateWith { decodeBinary(json.getString(it)) }
|
||||
}
|
||||
|
||||
// ===== Double Ratchet Sessions =====
|
||||
|
||||
fun saveSession(userId: String, deviceId: String, state: ByteArray) {
|
||||
val filename = "${userId}_${deviceId}.enc"
|
||||
val file = File(sessionsDir, filename)
|
||||
val key = requireLocalKey()
|
||||
val (nonce, ct) = AesGcmCrypto.encryptCombined(state, key)
|
||||
file.writeBytes(nonce + ct)
|
||||
}
|
||||
|
||||
fun loadSession(userId: String, deviceId: String): ByteArray? {
|
||||
val filename = "${userId}_${deviceId}.enc"
|
||||
val file = File(sessionsDir, filename)
|
||||
if (!file.exists()) return null
|
||||
val key = requireLocalKey()
|
||||
val raw = file.readBytes()
|
||||
if (raw.size < 12) return null
|
||||
val nonce = raw.copyOfRange(0, 12)
|
||||
val ct = raw.copyOfRange(12, raw.size)
|
||||
return AesGcmCrypto.decryptCombined(key, nonce, ct)
|
||||
}
|
||||
|
||||
fun deleteSession(userId: String, deviceId: String) {
|
||||
File(sessionsDir, "${userId}_${deviceId}.enc").delete()
|
||||
}
|
||||
|
||||
// ===== Sender Key States =====
|
||||
|
||||
fun saveSenderKey(conversationId: String, userId: String, state: ByteArray) {
|
||||
val filename = "${conversationId}_${userId}.enc"
|
||||
val file = File(senderKeysDir, filename)
|
||||
val key = requireLocalKey()
|
||||
val (nonce, ct) = AesGcmCrypto.encryptCombined(state, key)
|
||||
file.writeBytes(nonce + ct)
|
||||
}
|
||||
|
||||
fun loadSenderKey(conversationId: String, userId: String): ByteArray? {
|
||||
val filename = "${conversationId}_${userId}.enc"
|
||||
val file = File(senderKeysDir, filename)
|
||||
if (!file.exists()) return null
|
||||
val key = requireLocalKey()
|
||||
val raw = file.readBytes()
|
||||
if (raw.size < 12) return null
|
||||
val nonce = raw.copyOfRange(0, 12)
|
||||
val ct = raw.copyOfRange(12, raw.size)
|
||||
return AesGcmCrypto.decryptCombined(key, nonce, ct)
|
||||
}
|
||||
|
||||
// ===== TOFU Registry =====
|
||||
|
||||
fun saveTofuRegistry(registry: Map<String, ByteArray>) {
|
||||
val json = org.json.JSONObject()
|
||||
for ((userId, identityKey) in registry) {
|
||||
json.put(userId, encodeBinary(identityKey))
|
||||
}
|
||||
encryptAndSave("tofu.enc", json.toString().toByteArray())
|
||||
}
|
||||
|
||||
fun loadTofuRegistry(): Map<String, ByteArray> {
|
||||
val data = loadAndDecrypt("tofu.enc") ?: return emptyMap()
|
||||
val json = org.json.JSONObject(String(data))
|
||||
return json.keys().asSequence().associateWith { decodeBinary(json.getString(it)) }
|
||||
}
|
||||
|
||||
// ===== Verified Contacts =====
|
||||
|
||||
fun saveVerifiedContacts(contacts: Set<String>) {
|
||||
val json = org.json.JSONArray(contacts.toList())
|
||||
encryptAndSave("verified.enc", json.toString().toByteArray())
|
||||
}
|
||||
|
||||
fun loadVerifiedContacts(): Set<String> {
|
||||
val data = loadAndDecrypt("verified.enc") ?: return emptySet()
|
||||
val json = org.json.JSONArray(String(data))
|
||||
return (0 until json.length()).map { json.getString(it) }.toSet()
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private fun encryptAndSave(filename: String, data: ByteArray) {
|
||||
val key = requireLocalKey()
|
||||
val (nonce, ct) = AesGcmCrypto.encryptCombined(data, key)
|
||||
File(keysDir, filename).writeBytes(nonce + ct)
|
||||
}
|
||||
|
||||
private fun loadAndDecrypt(filename: String): ByteArray? {
|
||||
val file = File(keysDir, filename)
|
||||
if (!file.exists()) return null
|
||||
val key = requireLocalKey()
|
||||
val raw = file.readBytes()
|
||||
if (raw.size < 12) return null
|
||||
val nonce = raw.copyOfRange(0, 12)
|
||||
val ct = raw.copyOfRange(12, raw.size)
|
||||
return AesGcmCrypto.decryptCombined(key, nonce, ct)
|
||||
}
|
||||
|
||||
private fun requireLocalKey(): ByteArray =
|
||||
localKey ?: throw IllegalStateException("Local key not initialized. Call initLocalKey() first.")
|
||||
|
||||
private fun serializeSpk(spk: SignedPreKey): ByteArray {
|
||||
val json = org.json.JSONObject()
|
||||
json.put("id", spk.id)
|
||||
json.put("private", encodeBinary(X25519Crypto.serializePrivate(spk.privateKey)))
|
||||
json.put("public", encodeBinary(X25519Crypto.serializePublic(spk.publicKey)))
|
||||
json.put("signature", encodeBinary(spk.signature))
|
||||
return json.toString().toByteArray()
|
||||
}
|
||||
|
||||
private fun deserializeSpk(data: ByteArray): SignedPreKey {
|
||||
val json = org.json.JSONObject(String(data))
|
||||
return SignedPreKey(
|
||||
id = json.getString("id"),
|
||||
privateKey = X25519Crypto.loadPrivate(decodeBinary(json.getString("private"))),
|
||||
publicKey = X25519Crypto.loadPublic(decodeBinary(json.getString("public"))),
|
||||
signature = decodeBinary(json.getString("signature")),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all stored keys. Used for account deletion/reset.
|
||||
*/
|
||||
fun deleteAll() {
|
||||
keysDir.deleteRecursively()
|
||||
localKey = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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?,
|
||||
) {
|
||||
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)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setGroup("messages")
|
||||
.build()
|
||||
|
||||
notificationManager.notify(nextNotificationId++, notification)
|
||||
}
|
||||
|
||||
fun showGroupNotification(groupName: String, action: String) {
|
||||
val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_GROUPS)
|
||||
.setContentTitle(groupName)
|
||||
.setContentText(action)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.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)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(nextNotificationId++, notification)
|
||||
}
|
||||
|
||||
fun cancelAll() {
|
||||
notificationManager.cancelAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.kecalek.chat.core
|
||||
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Routes incoming push notifications (18 types) to appropriate handlers.
|
||||
* Each notification type triggers UI updates and/or local notifications.
|
||||
*/
|
||||
@Singleton
|
||||
class NotificationRouter @Inject constructor() {
|
||||
|
||||
// Registered handlers for each notification type
|
||||
private val handlers = mutableMapOf<String, MutableList<(JSONObject) -> Unit>>()
|
||||
|
||||
/**
|
||||
* Register a handler for a specific notification type.
|
||||
*/
|
||||
fun on(type: String, handler: (JSONObject) -> Unit) {
|
||||
handlers.getOrPut(type) { mutableListOf() }.add(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all handlers for a type.
|
||||
*/
|
||||
fun off(type: String) {
|
||||
handlers.remove(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a push notification to registered handlers.
|
||||
* @param json the raw push notification JSON
|
||||
*/
|
||||
fun route(json: JSONObject) {
|
||||
val type = json.optString("type", "")
|
||||
val data = json.optJSONObject("data") ?: JSONObject()
|
||||
|
||||
handlers[type]?.forEach { handler ->
|
||||
try {
|
||||
handler(data)
|
||||
} catch (e: Exception) {
|
||||
// Log but don't crash on handler errors
|
||||
android.util.Log.e("NotificationRouter", "Handler error for $type", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// All 18 push notification types
|
||||
const val NEW_MESSAGE = "new_message"
|
||||
const val MESSAGES_READ = "messages_read"
|
||||
const val MESSAGE_DELETED = "message_deleted"
|
||||
const val MESSAGE_DELIVERED = "message_delivered"
|
||||
const val CONVERSATION_CREATED = "conversation_created"
|
||||
const val MEMBER_ADDED = "member_added"
|
||||
const val MEMBER_REMOVED = "member_removed"
|
||||
const val GROUP_INVITATION = "group_invitation"
|
||||
const val CONVERSATION_RENAMED = "conversation_renamed"
|
||||
const val SESSION_RESET = "session_reset"
|
||||
const val MESSAGE_REACTED = "message_reacted"
|
||||
const val MESSAGE_PINNED = "message_pinned"
|
||||
const val MESSAGE_UNPINNED = "message_unpinned"
|
||||
const val USER_ONLINE = "user_online"
|
||||
const val USER_OFFLINE = "user_offline"
|
||||
const val ONLINE_USERS = "online_users"
|
||||
const val USERNAME_CHANGED = "username_changed"
|
||||
const val PROTOCOL_ERROR = "protocol_error"
|
||||
}
|
||||
}
|
||||
257
app/src/main/java/com/kecalek/chat/core/SessionManager.kt
Normal file
257
app/src/main/java/com/kecalek/chat/core/SessionManager.kt
Normal file
@@ -0,0 +1,257 @@
|
||||
package com.kecalek.chat.core
|
||||
|
||||
import com.kecalek.chat.crypto.RSACrypto
|
||||
import com.kecalek.chat.network.ConnectionManager
|
||||
import com.kecalek.chat.network.ProtocolHandler
|
||||
import com.kecalek.chat.network.ServerApi
|
||||
import com.kecalek.chat.network.decodeBinary
|
||||
import com.kecalek.chat.network.encodeBinary
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.interfaces.RSAPrivateKey
|
||||
import java.security.interfaces.RSAPublicKey
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Authentication and session state management.
|
||||
* Handles RSA challenge-response login, session persistence, and reconnection.
|
||||
*
|
||||
* On reconnect, automatically re-authenticates using in-memory credentials
|
||||
* (RSA private key + email) stored after the last successful login.
|
||||
*/
|
||||
@Singleton
|
||||
class SessionManager @Inject constructor(
|
||||
private val connection: ConnectionManager,
|
||||
private val api: ServerApi,
|
||||
) {
|
||||
|
||||
data class Session(
|
||||
val userId: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val deviceId: String,
|
||||
val serverVersion: String,
|
||||
)
|
||||
|
||||
sealed class AuthState {
|
||||
data object NotAuthenticated : AuthState()
|
||||
data object Authenticating : AuthState()
|
||||
data class Authenticated(val session: Session) : AuthState()
|
||||
data class Error(val message: String) : AuthState()
|
||||
}
|
||||
|
||||
private val _authState = MutableStateFlow<AuthState>(AuthState.NotAuthenticated)
|
||||
val authState: StateFlow<AuthState> = _authState
|
||||
|
||||
var currentSession: Session? = null
|
||||
private set
|
||||
|
||||
// Scope for launching re-auth coroutines from the onConnected callback
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// In-memory credentials for automatic re-authentication after reconnect.
|
||||
// Never persisted to disk.
|
||||
private var lastEmail: String? = null
|
||||
private var lastRsaPrivateKey: RSAPrivateKey? = null
|
||||
private var lastDeviceId: String? = null
|
||||
|
||||
init {
|
||||
// Re-authenticate automatically whenever the connection is (re)established.
|
||||
// During the initial login() call, lastEmail is null (cleared before connect),
|
||||
// so this handler is a no-op for the first connection.
|
||||
connection.onConnected = {
|
||||
val email = lastEmail ?: return@onConnected
|
||||
val key = lastRsaPrivateKey ?: return@onConnected
|
||||
scope.launch {
|
||||
try {
|
||||
val session = performAuthHandshake(email, key, lastDeviceId, "Android")
|
||||
currentSession = session
|
||||
_authState.value = AuthState.Authenticated(session)
|
||||
} catch (e: Exception) {
|
||||
_authState.value = AuthState.Error("Auto-reconnect auth failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full login flow: connect -> login_start -> sign challenge -> login_finish.
|
||||
* Stores credentials in memory for automatic re-authentication on reconnect.
|
||||
*/
|
||||
suspend fun login(
|
||||
email: String,
|
||||
rsaPrivateKey: RSAPrivateKey,
|
||||
host: String,
|
||||
port: Int,
|
||||
useTls: Boolean = false,
|
||||
deviceId: String? = null,
|
||||
deviceName: String = "Android",
|
||||
): Session {
|
||||
_authState.value = AuthState.Authenticating
|
||||
|
||||
// Clear previous credentials so the onConnected handler does NOT trigger
|
||||
// a re-auth attempt during the new connect() call below.
|
||||
lastEmail = null
|
||||
lastRsaPrivateKey = null
|
||||
|
||||
try {
|
||||
if (connection.state.value != ConnectionManager.State.CONNECTED) {
|
||||
connection.connect(host, port, useTls)
|
||||
}
|
||||
|
||||
val session = performAuthHandshake(email, rsaPrivateKey, deviceId, deviceName)
|
||||
|
||||
// Persist credentials for future reconnects
|
||||
lastEmail = email
|
||||
lastRsaPrivateKey = rsaPrivateKey
|
||||
lastDeviceId = session.deviceId
|
||||
|
||||
currentSession = session
|
||||
_authState.value = AuthState.Authenticated(session)
|
||||
return session
|
||||
} catch (e: AuthException) {
|
||||
_authState.value = AuthState.Error(e.message ?: "Login failed")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_authState.value = AuthState.Error(e.message ?: "Connection failed")
|
||||
throw AuthException("Login failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core RSA challenge-response handshake.
|
||||
* Shared by login() and the automatic reconnect handler.
|
||||
*/
|
||||
private suspend fun performAuthHandshake(
|
||||
email: String,
|
||||
rsaPrivateKey: RSAPrivateKey,
|
||||
deviceId: String?,
|
||||
deviceName: String,
|
||||
): Session {
|
||||
// Step 1: Request challenge
|
||||
val startResp = api.loginStart(email)
|
||||
if (!startResp.isOk) throw AuthException(startResp.errorMessage)
|
||||
val challengeBytes = decodeBinary(startResp.data.getString("challenge"))
|
||||
|
||||
// Step 2: Sign challenge with RSA-PSS
|
||||
val signature = RSACrypto.sign(rsaPrivateKey, challengeBytes)
|
||||
|
||||
// Step 3: Complete login
|
||||
val finishResp = api.loginFinish(
|
||||
email = email,
|
||||
signatureBase64 = encodeBinary(signature),
|
||||
clientVersion = ProtocolHandler.VERSION,
|
||||
deviceId = deviceId,
|
||||
deviceName = deviceName,
|
||||
)
|
||||
if (!finishResp.isOk) throw AuthException(finishResp.errorMessage)
|
||||
|
||||
val data = finishResp.data
|
||||
return Session(
|
||||
userId = data.getString("user_id"),
|
||||
username = data.getString("username"),
|
||||
email = data.getString("email"),
|
||||
deviceId = data.getString("device_id"),
|
||||
serverVersion = data.getString("server_version"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register new account.
|
||||
*/
|
||||
suspend fun register(
|
||||
username: String,
|
||||
email: String,
|
||||
rsaPublicKeyPem: String,
|
||||
identityKeyBase64: String,
|
||||
host: String,
|
||||
port: Int,
|
||||
useTls: Boolean = false,
|
||||
): String? {
|
||||
if (connection.state.value != ConnectionManager.State.CONNECTED) {
|
||||
connection.connect(host, port, useTls)
|
||||
}
|
||||
|
||||
val resp = api.register(username, email, rsaPublicKeyPem, identityKeyBase64)
|
||||
if (!resp.isOk) {
|
||||
throw AuthException(resp.errorMessage)
|
||||
}
|
||||
|
||||
// Returns verification code in dev mode, null in production
|
||||
return resp.data.optString("code", null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm registration with email code.
|
||||
*/
|
||||
suspend fun confirmRegistration(email: String, code: String): String {
|
||||
val resp = api.registerConfirm(email, code)
|
||||
if (!resp.isOk) {
|
||||
throw AuthException(resp.errorMessage)
|
||||
}
|
||||
return resp.data.getString("user_id")
|
||||
}
|
||||
|
||||
/**
|
||||
* Start device pairing.
|
||||
*/
|
||||
suspend fun startPairing(
|
||||
email: String,
|
||||
tempPublicKey: String,
|
||||
host: String,
|
||||
port: Int,
|
||||
useTls: Boolean = false,
|
||||
): Pair<String, String> {
|
||||
if (connection.state.value != ConnectionManager.State.CONNECTED) {
|
||||
connection.connect(host, port, useTls)
|
||||
}
|
||||
|
||||
val resp = api.pairingStart(email, tempPublicKey)
|
||||
if (!resp.isOk) {
|
||||
throw AuthException(resp.errorMessage)
|
||||
}
|
||||
|
||||
val code = resp.data.getString("code")
|
||||
val pollToken = resp.data.getString("poll_token")
|
||||
return Pair(code, pollToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for pairing completion.
|
||||
*/
|
||||
suspend fun pollPairing(code: String, pollToken: String): PairingResult {
|
||||
val resp = api.pairingPoll(code, pollToken)
|
||||
if (!resp.isOk) {
|
||||
throw AuthException(resp.errorMessage)
|
||||
}
|
||||
|
||||
val ready = resp.data.getBoolean("ready")
|
||||
if (!ready) return PairingResult.Waiting
|
||||
|
||||
val payload = resp.data.optJSONObject("payload")
|
||||
return PairingResult.Complete(payload?.toString() ?: "{}")
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
// Clear in-memory credentials so reconnect handler doesn't re-authenticate
|
||||
lastEmail = null
|
||||
lastRsaPrivateKey = null
|
||||
lastDeviceId = null
|
||||
|
||||
currentSession = null
|
||||
_authState.value = AuthState.NotAuthenticated
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
sealed class PairingResult {
|
||||
data object Waiting : PairingResult()
|
||||
data class Complete(val payloadJson: String) : PairingResult()
|
||||
}
|
||||
|
||||
class AuthException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
156
app/src/main/java/com/kecalek/chat/crypto/AesGcmCrypto.kt
Normal file
156
app/src/main/java/com/kecalek/chat/crypto/AesGcmCrypto.kt
Normal file
@@ -0,0 +1,156 @@
|
||||
package com.kecalek.chat.crypto
|
||||
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* AES-256-GCM encryption/decryption.
|
||||
* Nonce: 12 bytes (96 bits), Tag: 128 bits.
|
||||
* Compatible with Python's AESGCM from cryptography library.
|
||||
*/
|
||||
object AesGcmCrypto {
|
||||
|
||||
private const val KEY_SIZE = 32
|
||||
private const val NONCE_SIZE = 12
|
||||
private const val TAG_BITS = 128
|
||||
private const val ALGORITHM = "AES/GCM/NoPadding"
|
||||
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
/**
|
||||
* Encrypt plaintext with AES-256-GCM.
|
||||
* @param plaintext data to encrypt
|
||||
* @param key 32-byte AES key (generated if null)
|
||||
* @param aad optional additional authenticated data
|
||||
* @return AesGcmResult with key, nonce, ciphertext (without tag), tag (16 bytes)
|
||||
*/
|
||||
fun encrypt(
|
||||
plaintext: ByteArray,
|
||||
key: ByteArray? = null,
|
||||
aad: ByteArray? = null,
|
||||
): AesGcmResult {
|
||||
val aesKey = key ?: generateKey()
|
||||
require(aesKey.size == KEY_SIZE) { "AES key must be $KEY_SIZE bytes" }
|
||||
|
||||
val nonce = ByteArray(NONCE_SIZE).also { secureRandom.nextBytes(it) }
|
||||
val cipher = Cipher.getInstance(ALGORITHM)
|
||||
cipher.init(
|
||||
Cipher.ENCRYPT_MODE,
|
||||
SecretKeySpec(aesKey, "AES"),
|
||||
GCMParameterSpec(TAG_BITS, nonce),
|
||||
)
|
||||
if (aad != null) cipher.updateAAD(aad)
|
||||
|
||||
// Java GCM appends tag to ciphertext
|
||||
val ctWithTag = cipher.doFinal(plaintext)
|
||||
val ciphertext = ctWithTag.copyOfRange(0, ctWithTag.size - 16)
|
||||
val tag = ctWithTag.copyOfRange(ctWithTag.size - 16, ctWithTag.size)
|
||||
|
||||
return AesGcmResult(aesKey, nonce, ciphertext, tag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt AES-256-GCM ciphertext.
|
||||
* @param key 32-byte AES key
|
||||
* @param nonce 12-byte nonce
|
||||
* @param ciphertext encrypted data (without tag)
|
||||
* @param tag 16-byte authentication tag
|
||||
* @param aad optional additional authenticated data
|
||||
* @return decrypted plaintext
|
||||
*/
|
||||
fun decrypt(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
ciphertext: ByteArray,
|
||||
tag: ByteArray,
|
||||
aad: ByteArray? = null,
|
||||
): ByteArray {
|
||||
require(key.size == KEY_SIZE) { "AES key must be $KEY_SIZE bytes" }
|
||||
require(nonce.size == NONCE_SIZE) { "Nonce must be $NONCE_SIZE bytes" }
|
||||
require(tag.size == 16) { "Tag must be 16 bytes" }
|
||||
|
||||
val cipher = Cipher.getInstance(ALGORITHM)
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
SecretKeySpec(key, "AES"),
|
||||
GCMParameterSpec(TAG_BITS, nonce),
|
||||
)
|
||||
if (aad != null) cipher.updateAAD(aad)
|
||||
|
||||
// Java expects ciphertext + tag concatenated
|
||||
val ctWithTag = ciphertext + tag
|
||||
return cipher.doFinal(ctWithTag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt returning ciphertext+tag combined (for internal use by ECP1, Double Ratchet).
|
||||
*/
|
||||
fun encryptCombined(
|
||||
plaintext: ByteArray,
|
||||
key: ByteArray,
|
||||
aad: ByteArray? = null,
|
||||
): Pair<ByteArray, ByteArray> {
|
||||
require(key.size == KEY_SIZE) { "AES key must be $KEY_SIZE bytes" }
|
||||
|
||||
val nonce = ByteArray(NONCE_SIZE).also { secureRandom.nextBytes(it) }
|
||||
val cipher = Cipher.getInstance(ALGORITHM)
|
||||
cipher.init(
|
||||
Cipher.ENCRYPT_MODE,
|
||||
SecretKeySpec(key, "AES"),
|
||||
GCMParameterSpec(TAG_BITS, nonce),
|
||||
)
|
||||
if (aad != null) cipher.updateAAD(aad)
|
||||
|
||||
val ctWithTag = cipher.doFinal(plaintext)
|
||||
return Pair(nonce, ctWithTag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt ciphertext+tag combined.
|
||||
*/
|
||||
fun decryptCombined(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
ctWithTag: ByteArray,
|
||||
aad: ByteArray? = null,
|
||||
): ByteArray {
|
||||
require(key.size == KEY_SIZE) { "AES key must be $KEY_SIZE bytes" }
|
||||
require(nonce.size == NONCE_SIZE) { "Nonce must be $NONCE_SIZE bytes" }
|
||||
|
||||
val cipher = Cipher.getInstance(ALGORITHM)
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
SecretKeySpec(key, "AES"),
|
||||
GCMParameterSpec(TAG_BITS, nonce),
|
||||
)
|
||||
if (aad != null) cipher.updateAAD(aad)
|
||||
|
||||
return cipher.doFinal(ctWithTag)
|
||||
}
|
||||
|
||||
fun generateKey(): ByteArray = ByteArray(KEY_SIZE).also { secureRandom.nextBytes(it) }
|
||||
}
|
||||
|
||||
data class AesGcmResult(
|
||||
val key: ByteArray,
|
||||
val nonce: ByteArray,
|
||||
val ciphertext: ByteArray,
|
||||
val tag: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AesGcmResult) return false
|
||||
return key.contentEquals(other.key) && nonce.contentEquals(other.nonce) &&
|
||||
ciphertext.contentEquals(other.ciphertext) && tag.contentEquals(other.tag)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = key.contentHashCode()
|
||||
result = 31 * result + nonce.contentHashCode()
|
||||
result = 31 * result + ciphertext.contentHashCode()
|
||||
result = 31 * result + tag.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
130
app/src/main/java/com/kecalek/chat/crypto/ContactVerification.kt
Normal file
130
app/src/main/java/com/kecalek/chat/crypto/ContactVerification.kt
Normal file
@@ -0,0 +1,130 @@
|
||||
package com.kecalek.chat.crypto
|
||||
|
||||
import java.math.BigInteger
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Contact key verification: fingerprints, safety numbers, QR code payloads.
|
||||
* Compatible with Python compute_fingerprint, compute_safety_number,
|
||||
* encode_verification_qr, decode_verification_qr.
|
||||
*
|
||||
* Fingerprint: SHA-512 iterated 5200x over (version + identity_key + user_id).
|
||||
* Safety number: 60 digits (12 groups of 5), derived from both users' fingerprints.
|
||||
*/
|
||||
object ContactVerification {
|
||||
|
||||
private const val FINGERPRINT_VERSION = 0
|
||||
private const val FINGERPRINT_ITERATIONS = 5200
|
||||
private const val QR_VERSION: Byte = 0x01
|
||||
|
||||
/**
|
||||
* Compute fingerprint for a user's identity key.
|
||||
* @param userId user ID string
|
||||
* @param identityKeyBytes 32-byte Ed25519 public key
|
||||
* @param iterations number of SHA-512 iterations (default 5200)
|
||||
* @return 32 bytes (first 32 of final SHA-512 hash)
|
||||
*/
|
||||
fun computeFingerprint(
|
||||
userId: String,
|
||||
identityKeyBytes: ByteArray,
|
||||
iterations: Int = FINGERPRINT_ITERATIONS,
|
||||
): ByteArray {
|
||||
// Seed: version(2B big-endian) + identity_key(32B) + user_id(UTF-8)
|
||||
val versionBytes = ByteBuffer.allocate(2).putShort(FINGERPRINT_VERSION.toShort()).array()
|
||||
val userIdBytes = userId.toByteArray(Charsets.UTF_8)
|
||||
var data = versionBytes + identityKeyBytes + userIdBytes
|
||||
|
||||
val digest = MessageDigest.getInstance("SHA-512")
|
||||
for (i in 0 until iterations) {
|
||||
digest.reset()
|
||||
digest.update(data)
|
||||
digest.update(identityKeyBytes)
|
||||
data = digest.digest()
|
||||
}
|
||||
|
||||
return data.copyOfRange(0, 32)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format fingerprint bytes as 6 groups of 5 digits.
|
||||
* Each group: int.from_bytes(5 bytes, "big") % 100_000, zero-padded.
|
||||
* @return "XXXXX XXXXX XXXXX\nXXXXX XXXXX XXXXX"
|
||||
*/
|
||||
fun formatFingerprint(fpBytes: ByteArray): String {
|
||||
val groups = (0 until 6).map { i ->
|
||||
val chunk = fpBytes.copyOfRange(i * 5, (i + 1) * 5)
|
||||
val num = BigInteger(1, chunk).mod(BigInteger.valueOf(100_000)).toInt()
|
||||
"%05d".format(num)
|
||||
}
|
||||
return "${groups[0]} ${groups[1]} ${groups[2]}\n${groups[3]} ${groups[4]} ${groups[5]}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute safety number between two users.
|
||||
* Deterministic ordering: lower user_id fingerprint comes first.
|
||||
* @return 60 digits as "XXXXX XXXXX XXXXX XXXXX\n..." (3 lines of 4 groups)
|
||||
*/
|
||||
fun computeSafetyNumber(
|
||||
myUserId: String,
|
||||
myIdentityKey: ByteArray,
|
||||
theirUserId: String,
|
||||
theirIdentityKey: ByteArray,
|
||||
): String {
|
||||
val myFp = computeFingerprint(myUserId, myIdentityKey)
|
||||
val theirFp = computeFingerprint(theirUserId, theirIdentityKey)
|
||||
|
||||
// Deterministic ordering: lower user_id first
|
||||
val combined = if (myUserId < theirUserId) {
|
||||
myFp + theirFp
|
||||
} else {
|
||||
theirFp + myFp
|
||||
}
|
||||
|
||||
// 12 groups of 5 digits from 64 bytes
|
||||
val groups = (0 until 12).map { i ->
|
||||
val chunk = combined.copyOfRange(i * 5, (i + 1) * 5)
|
||||
val num = BigInteger(1, chunk).mod(BigInteger.valueOf(100_000)).toInt()
|
||||
"%05d".format(num)
|
||||
}
|
||||
|
||||
return "${groups[0]} ${groups[1]} ${groups[2]} ${groups[3]}\n" +
|
||||
"${groups[4]} ${groups[5]} ${groups[6]} ${groups[7]}\n" +
|
||||
"${groups[8]} ${groups[9]} ${groups[10]} ${groups[11]}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode verification QR code payload.
|
||||
* Format: 0x01 + uid_len(1B) + uid(UTF-8) + identity_key(32B)
|
||||
*/
|
||||
fun encodeVerificationQR(userId: String, identityKeyBytes: ByteArray): ByteArray {
|
||||
val uidBytes = userId.toByteArray(Charsets.UTF_8)
|
||||
require(uidBytes.size <= 255) { "User ID too long for QR encoding" }
|
||||
|
||||
val result = ByteArray(1 + 1 + uidBytes.size + identityKeyBytes.size)
|
||||
result[0] = QR_VERSION
|
||||
result[1] = uidBytes.size.toByte()
|
||||
System.arraycopy(uidBytes, 0, result, 2, uidBytes.size)
|
||||
System.arraycopy(identityKeyBytes, 0, result, 2 + uidBytes.size, identityKeyBytes.size)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode verification QR code payload.
|
||||
* @return Pair(userId, identityKeyBytes)
|
||||
* @throws CryptoException.InvalidQRCode on invalid format
|
||||
*/
|
||||
fun decodeVerificationQR(data: ByteArray): Pair<String, ByteArray> {
|
||||
if (data.size < 3) throw CryptoException.InvalidQRCode("QR data too short")
|
||||
if (data[0] != QR_VERSION) throw CryptoException.InvalidQRCode("Unknown QR version: ${data[0]}")
|
||||
|
||||
val uidLen = data[1].toInt() and 0xFF
|
||||
if (data.size < 2 + uidLen + 32) {
|
||||
throw CryptoException.InvalidQRCode("QR data incomplete")
|
||||
}
|
||||
|
||||
val userId = String(data, 2, uidLen, Charsets.UTF_8)
|
||||
val identityKey = data.copyOfRange(2 + uidLen, 2 + uidLen + 32)
|
||||
return Pair(userId, identityKey)
|
||||
}
|
||||
}
|
||||
33
app/src/main/java/com/kecalek/chat/crypto/CryptoErrors.kt
Normal file
33
app/src/main/java/com/kecalek/chat/crypto/CryptoErrors.kt
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.kecalek.chat.crypto
|
||||
|
||||
/**
|
||||
* Error types for cryptographic operations.
|
||||
*/
|
||||
sealed class CryptoException(message: String, cause: Throwable? = null) : Exception(message, cause) {
|
||||
class DecryptionFailed(message: String = "Decryption failed", cause: Throwable? = null) :
|
||||
CryptoException(message, cause)
|
||||
|
||||
class InvalidSignature(message: String = "Signature verification failed") :
|
||||
CryptoException(message)
|
||||
|
||||
class InvalidKey(message: String = "Invalid key format", cause: Throwable? = null) :
|
||||
CryptoException(message, cause)
|
||||
|
||||
class InvalidPassword(message: String = "Invalid password", cause: Throwable? = null) :
|
||||
CryptoException(message, cause)
|
||||
|
||||
class MaxSkipExceeded(message: String = "Maximum message skip exceeded") :
|
||||
CryptoException(message)
|
||||
|
||||
class InvalidHeader(message: String = "Invalid ratchet header") :
|
||||
CryptoException(message)
|
||||
|
||||
class ChainIdMismatch(message: String = "Sender key chain ID mismatch") :
|
||||
CryptoException(message)
|
||||
|
||||
class InvalidQRCode(message: String = "Invalid verification QR code") :
|
||||
CryptoException(message)
|
||||
|
||||
class X3DHFailed(message: String = "X3DH key agreement failed", cause: Throwable? = null) :
|
||||
CryptoException(message, cause)
|
||||
}
|
||||
396
app/src/main/java/com/kecalek/chat/crypto/DoubleRatchet.kt
Normal file
396
app/src/main/java/com/kecalek/chat/crypto/DoubleRatchet.kt
Normal file
@@ -0,0 +1,396 @@
|
||||
package com.kecalek.chat.crypto
|
||||
|
||||
import org.bouncycastle.crypto.params.X25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.X25519PublicKeyParameters
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Double Ratchet algorithm for end-to-end encrypted messaging.
|
||||
* Provides forward secrecy and break-in recovery.
|
||||
*
|
||||
* Compatible with Python DoubleRatchet class from crypto_utils.py.
|
||||
*
|
||||
* State:
|
||||
* - dh_pair: current ratchet X25519 keypair
|
||||
* - dh_remote: remote's current ratchet public key
|
||||
* - root_key: 32-byte root key
|
||||
* - send_chain_key / recv_chain_key: current chain keys
|
||||
* - send_n / recv_n: message counters
|
||||
* - prev_send_n: previous sending chain length
|
||||
* - skipped: map of (dh_hex, n) -> message_key for out-of-order delivery
|
||||
*/
|
||||
class DoubleRatchet private constructor() {
|
||||
|
||||
private lateinit var dhPrivate: X25519PrivateKeyParameters
|
||||
private lateinit var dhPublic: X25519PublicKeyParameters
|
||||
private var dhRemote: X25519PublicKeyParameters? = null
|
||||
private lateinit var rootKey: ByteArray
|
||||
private var sendChainKey: ByteArray? = null
|
||||
private var recvChainKey: ByteArray? = null
|
||||
private var sendN: Int = 0
|
||||
private var recvN: Int = 0
|
||||
private var prevSendN: Int = 0
|
||||
|
||||
// skipped[(remotePublicHex, messageNumber)] = messageKey
|
||||
private val skipped = mutableMapOf<String, ByteArray>()
|
||||
|
||||
companion object {
|
||||
private const val MAX_SKIP = 256
|
||||
|
||||
/**
|
||||
* Initialize as Alice (initiator).
|
||||
* Called after X3DH produces a shared secret.
|
||||
*
|
||||
* @param sharedSecret X3DH shared secret
|
||||
* @param bobSpkPub Bob's signed pre-key public (used as initial remote ratchet key)
|
||||
*/
|
||||
fun initAlice(sharedSecret: ByteArray, bobSpkPub: X25519PublicKeyParameters): DoubleRatchet {
|
||||
val ratchet = DoubleRatchet()
|
||||
|
||||
// Generate initial ratchet keypair
|
||||
val (dhPriv, dhPub) = X25519Crypto.generateKeypair()
|
||||
ratchet.dhPrivate = dhPriv
|
||||
ratchet.dhPublic = dhPub
|
||||
ratchet.dhRemote = bobSpkPub
|
||||
|
||||
// Initial DH ratchet step
|
||||
val dhOutput = X25519Crypto.dh(dhPriv, bobSpkPub)
|
||||
val (newRootKey, sendChainKey) = HkdfUtils.kdfRk(sharedSecret, dhOutput)
|
||||
ratchet.rootKey = newRootKey
|
||||
ratchet.sendChainKey = sendChainKey
|
||||
ratchet.recvChainKey = null
|
||||
ratchet.sendN = 0
|
||||
ratchet.recvN = 0
|
||||
ratchet.prevSendN = 0
|
||||
|
||||
return ratchet
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize as Bob (responder).
|
||||
* Uses SPK pair as initial ratchet key.
|
||||
*
|
||||
* @param sharedSecret X3DH shared secret
|
||||
* @param spkPair Bob's signed pre-key pair (private, public)
|
||||
*/
|
||||
fun initBob(
|
||||
sharedSecret: ByteArray,
|
||||
spkPair: Pair<X25519PrivateKeyParameters, X25519PublicKeyParameters>,
|
||||
): DoubleRatchet {
|
||||
val ratchet = DoubleRatchet()
|
||||
ratchet.dhPrivate = spkPair.first
|
||||
ratchet.dhPublic = spkPair.second
|
||||
ratchet.rootKey = sharedSecret
|
||||
ratchet.sendChainKey = null
|
||||
ratchet.recvChainKey = null
|
||||
ratchet.sendN = 0
|
||||
ratchet.recvN = 0
|
||||
ratchet.prevSendN = 0
|
||||
return ratchet
|
||||
}
|
||||
|
||||
/**
|
||||
* Import ratchet state from JSON bytes.
|
||||
*/
|
||||
fun importState(data: ByteArray): DoubleRatchet {
|
||||
val json = JSONObject(String(data))
|
||||
val ratchet = DoubleRatchet()
|
||||
|
||||
ratchet.dhPrivate = X25519Crypto.loadPrivate(json.getString("dh_priv").hexToBytes())
|
||||
ratchet.dhPublic = X25519Crypto.loadPublic(json.getString("dh_pub").hexToBytes())
|
||||
|
||||
if (json.has("dh_remote") && !json.isNull("dh_remote")) {
|
||||
ratchet.dhRemote = X25519Crypto.loadPublic(json.getString("dh_remote").hexToBytes())
|
||||
}
|
||||
|
||||
ratchet.rootKey = json.getString("root_key").hexToBytes()
|
||||
ratchet.sendChainKey = json.optString("send_ck", "").takeIf { it.isNotEmpty() }?.hexToBytes()
|
||||
ratchet.recvChainKey = json.optString("recv_ck", "").takeIf { it.isNotEmpty() }?.hexToBytes()
|
||||
ratchet.sendN = json.getInt("send_n")
|
||||
ratchet.recvN = json.getInt("recv_n")
|
||||
ratchet.prevSendN = json.getInt("prev_send_n")
|
||||
|
||||
// Import skipped keys
|
||||
if (json.has("skipped")) {
|
||||
val skippedJson = json.getJSONObject("skipped")
|
||||
for (key in skippedJson.keys()) {
|
||||
ratchet.skipped[key] = skippedJson.getString(key).hexToBytes()
|
||||
}
|
||||
}
|
||||
|
||||
return ratchet
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext message.
|
||||
* @return RatchetMessage with header dict, ciphertext+tag, nonce
|
||||
*/
|
||||
fun encrypt(plaintext: ByteArray): RatchetMessage {
|
||||
val ck = sendChainKey ?: throw CryptoException.DecryptionFailed("Send chain not initialized")
|
||||
|
||||
val (newChainKey, messageKey) = HkdfUtils.kdfCk(ck)
|
||||
sendChainKey = newChainKey
|
||||
|
||||
val header = RatchetHeader(
|
||||
dhPub = X25519Crypto.serializePublic(dhPublic),
|
||||
n = sendN,
|
||||
pn = prevSendN,
|
||||
)
|
||||
|
||||
val aad = header.serialize()
|
||||
val (nonce, ctWithTag) = AesGcmCrypto.encryptCombined(
|
||||
plaintext = plaintext,
|
||||
key = messageKey,
|
||||
aad = aad,
|
||||
)
|
||||
|
||||
sendN++
|
||||
|
||||
return RatchetMessage(
|
||||
header = header,
|
||||
ciphertext = ctWithTag,
|
||||
nonce = nonce,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt received message.
|
||||
* Handles out-of-order delivery via skipped message keys.
|
||||
* Full state rollback on failure.
|
||||
*/
|
||||
fun decrypt(header: RatchetHeader, ciphertext: ByteArray, nonce: ByteArray): ByteArray {
|
||||
val aad = header.serialize()
|
||||
|
||||
// Check skipped message keys first (no state change)
|
||||
val skippedKey = makeSkippedKey(header.dhPub.toHex(), header.n)
|
||||
skipped.remove(skippedKey)?.let { messageKey ->
|
||||
return AesGcmCrypto.decryptCombined(
|
||||
key = messageKey,
|
||||
nonce = nonce,
|
||||
ctWithTag = ciphertext,
|
||||
aad = aad,
|
||||
)
|
||||
}
|
||||
|
||||
// Take snapshot for rollback
|
||||
val snapshot = snapshot()
|
||||
|
||||
try {
|
||||
// New DH ratchet step if remote key changed
|
||||
val remoteHex = header.dhPub.toHex()
|
||||
val currentRemoteHex = dhRemote?.let { X25519Crypto.serializePublic(it).toHex() }
|
||||
|
||||
if (remoteHex != currentRemoteHex) {
|
||||
skipMessages(header.pn)
|
||||
dhRatchet(X25519Crypto.loadPublic(header.dhPub))
|
||||
}
|
||||
|
||||
skipMessages(header.n)
|
||||
|
||||
val ck = recvChainKey ?: throw CryptoException.DecryptionFailed("Receive chain not initialized")
|
||||
val (newChainKey, messageKey) = HkdfUtils.kdfCk(ck)
|
||||
recvChainKey = newChainKey
|
||||
recvN++
|
||||
|
||||
return AesGcmCrypto.decryptCombined(
|
||||
key = messageKey,
|
||||
nonce = nonce,
|
||||
ctWithTag = ciphertext,
|
||||
aad = aad,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Rollback on any failure
|
||||
restore(snapshot)
|
||||
throw if (e is CryptoException) e
|
||||
else CryptoException.DecryptionFailed("Decryption failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export full ratchet state as JSON bytes.
|
||||
*/
|
||||
fun exportState(): ByteArray {
|
||||
val json = JSONObject()
|
||||
json.put("dh_priv", X25519Crypto.serializePrivate(dhPrivate).toHex())
|
||||
json.put("dh_pub", X25519Crypto.serializePublic(dhPublic).toHex())
|
||||
json.put("dh_remote", dhRemote?.let { X25519Crypto.serializePublic(it).toHex() })
|
||||
json.put("root_key", rootKey.toHex())
|
||||
json.put("send_ck", sendChainKey?.toHex())
|
||||
json.put("recv_ck", recvChainKey?.toHex())
|
||||
json.put("send_n", sendN)
|
||||
json.put("recv_n", recvN)
|
||||
json.put("prev_send_n", prevSendN)
|
||||
|
||||
val skippedJson = JSONObject()
|
||||
for ((key, value) in skipped) {
|
||||
skippedJson.put(key, value.toHex())
|
||||
}
|
||||
json.put("skipped", skippedJson)
|
||||
|
||||
return json.toString().toByteArray()
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private fun skipMessages(until: Int) {
|
||||
if (recvChainKey == null) return
|
||||
if (until - recvN > MAX_SKIP) {
|
||||
throw CryptoException.MaxSkipExceeded("Cannot skip more than $MAX_SKIP messages")
|
||||
}
|
||||
|
||||
var ck = recvChainKey!!
|
||||
while (recvN < until) {
|
||||
val (newCk, messageKey) = HkdfUtils.kdfCk(ck)
|
||||
ck = newCk
|
||||
val remoteHex = dhRemote?.let { X25519Crypto.serializePublic(it).toHex() } ?: ""
|
||||
skipped[makeSkippedKey(remoteHex, recvN)] = messageKey
|
||||
recvN++
|
||||
}
|
||||
recvChainKey = ck
|
||||
}
|
||||
|
||||
private fun dhRatchet(remotePublic: X25519PublicKeyParameters) {
|
||||
prevSendN = sendN
|
||||
sendN = 0
|
||||
recvN = 0
|
||||
dhRemote = remotePublic
|
||||
|
||||
// Derive receive chain
|
||||
val dhOutput1 = X25519Crypto.dh(dhPrivate, remotePublic)
|
||||
val (rk1, recvCk) = HkdfUtils.kdfRk(rootKey, dhOutput1)
|
||||
rootKey = rk1
|
||||
recvChainKey = recvCk
|
||||
|
||||
// Generate new DH keypair and derive send chain
|
||||
val (newPriv, newPub) = X25519Crypto.generateKeypair()
|
||||
dhPrivate = newPriv
|
||||
dhPublic = newPub
|
||||
|
||||
val dhOutput2 = X25519Crypto.dh(newPriv, remotePublic)
|
||||
val (rk2, sendCk) = HkdfUtils.kdfRk(rootKey, dhOutput2)
|
||||
rootKey = rk2
|
||||
sendChainKey = sendCk
|
||||
}
|
||||
|
||||
private data class Snapshot(
|
||||
val dhPriv: ByteArray,
|
||||
val dhPub: ByteArray,
|
||||
val dhRemote: ByteArray?,
|
||||
val rootKey: ByteArray,
|
||||
val sendCk: ByteArray?,
|
||||
val recvCk: ByteArray?,
|
||||
val sendN: Int,
|
||||
val recvN: Int,
|
||||
val prevSendN: Int,
|
||||
val skipped: Map<String, ByteArray>,
|
||||
)
|
||||
|
||||
private fun snapshot(): Snapshot {
|
||||
return Snapshot(
|
||||
dhPriv = X25519Crypto.serializePrivate(dhPrivate),
|
||||
dhPub = X25519Crypto.serializePublic(dhPublic),
|
||||
dhRemote = dhRemote?.let { X25519Crypto.serializePublic(it) },
|
||||
rootKey = rootKey.copyOf(),
|
||||
sendCk = sendChainKey?.copyOf(),
|
||||
recvCk = recvChainKey?.copyOf(),
|
||||
sendN = sendN,
|
||||
recvN = recvN,
|
||||
prevSendN = prevSendN,
|
||||
skipped = skipped.toMap(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun restore(s: Snapshot) {
|
||||
dhPrivate = X25519Crypto.loadPrivate(s.dhPriv)
|
||||
dhPublic = X25519Crypto.loadPublic(s.dhPub)
|
||||
dhRemote = s.dhRemote?.let { X25519Crypto.loadPublic(it) }
|
||||
rootKey = s.rootKey
|
||||
sendChainKey = s.sendCk
|
||||
recvChainKey = s.recvCk
|
||||
sendN = s.sendN
|
||||
recvN = s.recvN
|
||||
prevSendN = s.prevSendN
|
||||
skipped.clear()
|
||||
skipped.putAll(s.skipped)
|
||||
}
|
||||
|
||||
private fun makeSkippedKey(dhHex: String, n: Int): String = "$dhHex:$n"
|
||||
}
|
||||
|
||||
/**
|
||||
* Ratchet message header.
|
||||
* Serialized as JSON: {"dh_pub": hex, "n": int, "pn": int}
|
||||
*/
|
||||
data class RatchetHeader(
|
||||
val dhPub: ByteArray,
|
||||
val n: Int,
|
||||
val pn: Int,
|
||||
) {
|
||||
fun serialize(): ByteArray {
|
||||
val json = JSONObject()
|
||||
json.put("dh_pub", dhPub.toHex())
|
||||
json.put("n", n)
|
||||
json.put("pn", pn)
|
||||
return json.toString().toByteArray()
|
||||
}
|
||||
|
||||
fun toMap(): Map<String, Any> = mapOf(
|
||||
"dh_pub" to dhPub.toHex(),
|
||||
"n" to n,
|
||||
"pn" to pn,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun fromMap(map: Map<String, Any>): RatchetHeader {
|
||||
return RatchetHeader(
|
||||
dhPub = (map["dh_pub"] as String).hexToBytes(),
|
||||
n = (map["n"] as Number).toInt(),
|
||||
pn = (map["pn"] as Number).toInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is RatchetHeader) return false
|
||||
return dhPub.contentEquals(other.dhPub) && n == other.n && pn == other.pn
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = dhPub.contentHashCode()
|
||||
result = 31 * result + n
|
||||
result = 31 * result + pn
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
data class RatchetMessage(
|
||||
val header: RatchetHeader,
|
||||
val ciphertext: ByteArray,
|
||||
val nonce: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is RatchetMessage) return false
|
||||
return header == other.header && ciphertext.contentEquals(other.ciphertext) &&
|
||||
nonce.contentEquals(other.nonce)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = header.hashCode()
|
||||
result = 31 * result + ciphertext.contentHashCode()
|
||||
result = 31 * result + nonce.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hex extension functions ---
|
||||
|
||||
internal fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
internal fun String.hexToBytes(): ByteArray {
|
||||
require(length % 2 == 0) { "Hex string must have even length" }
|
||||
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
130
app/src/main/java/com/kecalek/chat/crypto/Ed25519Crypto.kt
Normal file
130
app/src/main/java/com/kecalek/chat/crypto/Ed25519Crypto.kt
Normal file
@@ -0,0 +1,130 @@
|
||||
package com.kecalek.chat.crypto
|
||||
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import org.bouncycastle.math.ec.rfc7748.X25519Field
|
||||
import java.math.BigInteger
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Ed25519 signing and key operations using Bouncy Castle.
|
||||
* Includes Ed25519 -> X25519 conversion for X3DH.
|
||||
* Compatible with Python's Ed25519PrivateKey/PublicKey from cryptography library.
|
||||
*/
|
||||
object Ed25519Crypto {
|
||||
|
||||
/**
|
||||
* Generate Ed25519 keypair.
|
||||
* @return (privateKey, publicKey) as Bouncy Castle parameters
|
||||
*/
|
||||
fun generateKeypair(): Pair<Ed25519PrivateKeyParameters, Ed25519PublicKeyParameters> {
|
||||
val privateKey = Ed25519PrivateKeyParameters(java.security.SecureRandom())
|
||||
return Pair(privateKey, privateKey.generatePublicKey())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get 32-byte raw private key (seed).
|
||||
*/
|
||||
fun serializePrivate(key: Ed25519PrivateKeyParameters): ByteArray {
|
||||
return key.encoded // 32-byte seed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get 32-byte raw public key.
|
||||
*/
|
||||
fun serializePublic(key: Ed25519PublicKeyParameters): ByteArray {
|
||||
return key.encoded // 32 bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Ed25519 private key from 32-byte seed.
|
||||
*/
|
||||
fun loadPrivate(data: ByteArray): Ed25519PrivateKeyParameters {
|
||||
require(data.size == 32) { "Ed25519 private key must be 32 bytes" }
|
||||
return Ed25519PrivateKeyParameters(data, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Ed25519 public key from 32 bytes.
|
||||
*/
|
||||
fun loadPublic(data: ByteArray): Ed25519PublicKeyParameters {
|
||||
require(data.size == 32) { "Ed25519 public key must be 32 bytes" }
|
||||
return Ed25519PublicKeyParameters(data, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign data with Ed25519.
|
||||
* @return 64-byte signature
|
||||
*/
|
||||
fun sign(privateKey: Ed25519PrivateKeyParameters, data: ByteArray): ByteArray {
|
||||
val signer = Ed25519Signer()
|
||||
signer.init(true, privateKey)
|
||||
signer.update(data, 0, data.size)
|
||||
return signer.generateSignature()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Ed25519 signature.
|
||||
*/
|
||||
fun verify(publicKey: Ed25519PublicKeyParameters, signature: ByteArray, data: ByteArray): Boolean {
|
||||
return try {
|
||||
val verifier = Ed25519Signer()
|
||||
verifier.init(false, publicKey)
|
||||
verifier.update(data, 0, data.size)
|
||||
verifier.verifySignature(signature)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Ed25519 private key to X25519 private key.
|
||||
* Process: SHA-512(seed), take first 32 bytes, clamp per RFC 7748.
|
||||
* Compatible with Python ed25519_private_to_x25519.
|
||||
*/
|
||||
fun privateToX25519(edPrivate: Ed25519PrivateKeyParameters): ByteArray {
|
||||
val seed = edPrivate.encoded // 32-byte seed
|
||||
val hash = MessageDigest.getInstance("SHA-512").digest(seed)
|
||||
val clamped = hash.copyOfRange(0, 32)
|
||||
|
||||
// RFC 7748 clamping
|
||||
clamped[0] = (clamped[0].toInt() and 248).toByte()
|
||||
clamped[31] = (clamped[31].toInt() and 127).toByte()
|
||||
clamped[31] = (clamped[31].toInt() or 64).toByte()
|
||||
|
||||
return clamped
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Ed25519 public key to X25519 public key.
|
||||
* Uses Montgomery form conversion: u = (1 + y) / (1 - y) mod p
|
||||
* where p = 2^255 - 19, y is the Ed25519 y-coordinate.
|
||||
* Compatible with Python ed25519_public_to_x25519.
|
||||
*/
|
||||
fun publicToX25519(edPublic: Ed25519PublicKeyParameters): ByteArray {
|
||||
val edPubBytes = edPublic.encoded // 32 bytes, little-endian
|
||||
|
||||
// Interpret as little-endian integer
|
||||
var y = BigInteger(1, edPubBytes.reversedArray())
|
||||
// Clear sign bit (bit 255)
|
||||
y = y.and(BigInteger.ONE.shiftLeft(255).subtract(BigInteger.ONE))
|
||||
|
||||
val p = BigInteger.ONE.shiftLeft(255).subtract(BigInteger.valueOf(19))
|
||||
|
||||
val one = BigInteger.ONE
|
||||
val onePlusY = one.add(y).mod(p)
|
||||
val oneMinusY = one.subtract(y).mod(p)
|
||||
|
||||
// Modular inverse via Fermat's little theorem
|
||||
val oneMinusYInv = oneMinusY.modPow(p.subtract(BigInteger.TWO), p)
|
||||
val u = onePlusY.multiply(oneMinusYInv).mod(p)
|
||||
|
||||
// Convert to 32-byte little-endian
|
||||
val uBytes = u.toByteArray().reversedArray()
|
||||
val result = ByteArray(32)
|
||||
System.arraycopy(uBytes, 0, result, 0, minOf(uBytes.size, 32))
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
122
app/src/main/java/com/kecalek/chat/crypto/HkdfUtils.kt
Normal file
122
app/src/main/java/com/kecalek/chat/crypto/HkdfUtils.kt
Normal file
@@ -0,0 +1,122 @@
|
||||
package com.kecalek.chat.crypto
|
||||
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* HKDF-SHA256 (RFC 5869) and related KDF functions.
|
||||
* Compatible with Python's HKDF from cryptography.hazmat.primitives.kdf.hkdf.
|
||||
*
|
||||
* Chain key derivation uses HMAC-SHA256 directly (Signal Protocol spec).
|
||||
*/
|
||||
object HkdfUtils {
|
||||
|
||||
// Info strings matching Python/iOS constants
|
||||
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"
|
||||
|
||||
// Chain key KDF constants (Signal spec)
|
||||
private val CK_MSG_INFO = byteArrayOf(0x01) // chain key -> message key
|
||||
private val CK_NEXT_INFO = byteArrayOf(0x02) // chain key -> next chain key
|
||||
|
||||
/**
|
||||
* HKDF-SHA256: Extract + Expand (RFC 5869).
|
||||
* @param inputKey input keying material
|
||||
* @param salt optional salt (if null, uses zeros of hash length)
|
||||
* @param info context/application-specific info
|
||||
* @param length output key length in bytes
|
||||
*/
|
||||
fun derive(
|
||||
inputKey: ByteArray,
|
||||
salt: ByteArray? = null,
|
||||
info: ByteArray,
|
||||
length: Int = 32,
|
||||
): ByteArray {
|
||||
// Extract
|
||||
val prk = hmacSha256(salt ?: ByteArray(32), inputKey)
|
||||
// Expand
|
||||
return expand(prk, info, length)
|
||||
}
|
||||
|
||||
/**
|
||||
* HKDF-Expand (used when PRK is already extracted).
|
||||
*/
|
||||
private fun expand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
|
||||
val hashLen = 32
|
||||
val n = (length + hashLen - 1) / hashLen
|
||||
require(n <= 255) { "HKDF output too long" }
|
||||
|
||||
val output = ByteArray(n * hashLen)
|
||||
var t = ByteArray(0)
|
||||
for (i in 1..n) {
|
||||
val input = t + info + byteArrayOf(i.toByte())
|
||||
t = hmacSha256(prk, input)
|
||||
System.arraycopy(t, 0, output, (i - 1) * hashLen, hashLen)
|
||||
}
|
||||
return output.copyOfRange(0, length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Root key KDF: derives new root key + chain key from DH output.
|
||||
* kdf_rk(root_key, dh_output) -> (new_root_key, chain_key)
|
||||
*/
|
||||
fun kdfRk(rootKey: ByteArray, dhOutput: ByteArray): Pair<ByteArray, ByteArray> {
|
||||
val derived = derive(
|
||||
inputKey = dhOutput,
|
||||
salt = rootKey,
|
||||
info = ROOT_KEY_INFO.toByteArray(),
|
||||
length = 64,
|
||||
)
|
||||
val newRootKey = derived.copyOfRange(0, 32)
|
||||
val chainKey = derived.copyOfRange(32, 64)
|
||||
return Pair(newRootKey, chainKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain key KDF: derives message key + next chain key.
|
||||
* kdf_ck(chain_key) -> (new_chain_key, message_key)
|
||||
*/
|
||||
fun kdfCk(chainKey: ByteArray): Pair<ByteArray, ByteArray> {
|
||||
val messageKey = hmacSha256(chainKey, CK_MSG_INFO)
|
||||
val newChainKey = hmacSha256(chainKey, CK_NEXT_INFO)
|
||||
return Pair(newChainKey, messageKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive self-encryption key from identity private key.
|
||||
*/
|
||||
fun deriveSelfEncryptionKey(identityPrivateRaw: ByteArray): ByteArray {
|
||||
return derive(
|
||||
inputKey = identityPrivateRaw,
|
||||
salt = SELF_ENCRYPTION_SALT.toByteArray(),
|
||||
info = SELF_ENCRYPTION_INFO.toByteArray(),
|
||||
length = 32,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive local storage encryption key from identity private key.
|
||||
*/
|
||||
fun deriveLocalStorageKey(identityPrivateRaw: ByteArray): ByteArray {
|
||||
return derive(
|
||||
inputKey = identityPrivateRaw,
|
||||
salt = LOCAL_STORAGE_SALT.toByteArray(),
|
||||
info = LOCAL_STORAGE_INFO.toByteArray(),
|
||||
length = 32,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-SHA256.
|
||||
*/
|
||||
fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray {
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(key, "HmacSHA256"))
|
||||
return mac.doFinal(data)
|
||||
}
|
||||
}
|
||||
94
app/src/main/java/com/kecalek/chat/crypto/KeyEncryption.kt
Normal file
94
app/src/main/java/com/kecalek/chat/crypto/KeyEncryption.kt
Normal file
@@ -0,0 +1,94 @@
|
||||
package com.kecalek.chat.crypto
|
||||
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
|
||||
/**
|
||||
* ECP1 format: Password-based key encryption using PBKDF2-HMAC-SHA256 + AES-256-GCM.
|
||||
* Format: ECP1(4B magic) + salt(16B) + nonce(12B) + ciphertext+tag(N+16B)
|
||||
*
|
||||
* Compatible with Python _encrypt_private_key / _decrypt_private_key.
|
||||
* AAD for AES-GCM = ECP1_MAGIC bytes.
|
||||
*/
|
||||
object KeyEncryption {
|
||||
|
||||
private val ECP1_MAGIC = byteArrayOf(0x45, 0x43, 0x50, 0x31) // "ECP1"
|
||||
private const val PBKDF2_ITERATIONS = 600_000
|
||||
private const val SALT_SIZE = 16
|
||||
private const val NONCE_SIZE = 12
|
||||
private const val KEY_SIZE = 32
|
||||
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
/**
|
||||
* Encrypt raw key bytes with password using ECP1 format.
|
||||
* @param rawBytes the private key bytes to encrypt
|
||||
* @param password the password to derive encryption key from
|
||||
* @return ECP1 formatted encrypted data
|
||||
*/
|
||||
fun encrypt(rawBytes: ByteArray, password: String): ByteArray {
|
||||
val salt = ByteArray(SALT_SIZE).also { secureRandom.nextBytes(it) }
|
||||
val aesKey = deriveKey(password, salt)
|
||||
|
||||
val (nonce, ctWithTag) = AesGcmCrypto.encryptCombined(
|
||||
plaintext = rawBytes,
|
||||
key = aesKey,
|
||||
aad = ECP1_MAGIC,
|
||||
)
|
||||
|
||||
// ECP1 format: magic(4) + salt(16) + nonce(12) + ct+tag
|
||||
return ECP1_MAGIC + salt + nonce + ctWithTag
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt ECP1-encrypted key bytes with password.
|
||||
* @param data ECP1 formatted encrypted data
|
||||
* @param password the password
|
||||
* @return decrypted raw key bytes
|
||||
* @throws CryptoException.InvalidPassword if password is wrong or data is corrupted
|
||||
*/
|
||||
fun decrypt(data: ByteArray, password: String): ByteArray {
|
||||
if (data.size < 4 + SALT_SIZE + NONCE_SIZE + 16) {
|
||||
throw CryptoException.InvalidKey("Data too short for ECP1 format")
|
||||
}
|
||||
|
||||
// Verify magic
|
||||
if (!data.copyOfRange(0, 4).contentEquals(ECP1_MAGIC)) {
|
||||
throw CryptoException.InvalidKey("Invalid ECP1 magic bytes")
|
||||
}
|
||||
|
||||
val salt = data.copyOfRange(4, 4 + SALT_SIZE)
|
||||
val nonce = data.copyOfRange(4 + SALT_SIZE, 4 + SALT_SIZE + NONCE_SIZE)
|
||||
val ctWithTag = data.copyOfRange(4 + SALT_SIZE + NONCE_SIZE, data.size)
|
||||
|
||||
val aesKey = deriveKey(password, salt)
|
||||
|
||||
return try {
|
||||
AesGcmCrypto.decryptCombined(
|
||||
key = aesKey,
|
||||
nonce = nonce,
|
||||
ctWithTag = ctWithTag,
|
||||
aad = ECP1_MAGIC,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw CryptoException.InvalidPassword("Failed to decrypt: wrong password or corrupted data", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data starts with ECP1 magic bytes.
|
||||
*/
|
||||
fun isEcp1Format(data: ByteArray): Boolean {
|
||||
return data.size >= 4 && data.copyOfRange(0, 4).contentEquals(ECP1_MAGIC)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive 32-byte AES key from password using PBKDF2-HMAC-SHA256.
|
||||
*/
|
||||
private fun deriveKey(password: String, salt: ByteArray): ByteArray {
|
||||
val spec = PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_SIZE * 8)
|
||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||
return factory.generateSecret(spec).encoded
|
||||
}
|
||||
}
|
||||
82
app/src/main/java/com/kecalek/chat/crypto/MessagePadding.kt
Normal file
82
app/src/main/java/com/kecalek/chat/crypto/MessagePadding.kt
Normal file
@@ -0,0 +1,82 @@
|
||||
package com.kecalek.chat.crypto
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Bucket-based message padding for metadata privacy.
|
||||
* Pads messages to fixed bucket sizes to prevent message-length analysis.
|
||||
*
|
||||
* Format: 0x01 + plaintext + random_padding + pad_length(4 bytes big-endian)
|
||||
* Compatible with Python pad_plaintext/unpad_plaintext.
|
||||
*/
|
||||
object MessagePadding {
|
||||
|
||||
private const val PAD_MAGIC: Byte = 0x01
|
||||
private val PAD_BUCKETS = intArrayOf(64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536)
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
/**
|
||||
* Pad plaintext to nearest bucket size.
|
||||
* @param plaintext raw plaintext bytes
|
||||
* @return padded bytes: 0x01 + plaintext + random_padding + pad_length(4B)
|
||||
*/
|
||||
fun pad(plaintext: ByteArray): ByteArray {
|
||||
// content = magic + plaintext
|
||||
val content = ByteArray(1 + plaintext.size)
|
||||
content[0] = PAD_MAGIC
|
||||
System.arraycopy(plaintext, 0, content, 1, plaintext.size)
|
||||
|
||||
// minimum total size = content + 4 bytes for pad_length
|
||||
val minSize = content.size + 4
|
||||
|
||||
// find nearest bucket
|
||||
var targetSize = minSize
|
||||
for (bucket in PAD_BUCKETS) {
|
||||
if (bucket >= minSize) {
|
||||
targetSize = bucket
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// pad_length includes itself (4 bytes) + random padding bytes
|
||||
val padLength = targetSize - content.size
|
||||
val randomPadSize = padLength - 4
|
||||
|
||||
val result = ByteArray(targetSize)
|
||||
System.arraycopy(content, 0, result, 0, content.size)
|
||||
|
||||
// fill random padding
|
||||
if (randomPadSize > 0) {
|
||||
val randomBytes = ByteArray(randomPadSize)
|
||||
secureRandom.nextBytes(randomBytes)
|
||||
System.arraycopy(randomBytes, 0, result, content.size, randomPadSize)
|
||||
}
|
||||
|
||||
// write pad_length as big-endian uint32 at the end
|
||||
val lenBytes = ByteBuffer.allocate(4).putInt(padLength).array()
|
||||
System.arraycopy(lenBytes, 0, result, targetSize - 4, 4)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove padding from padded message.
|
||||
* @param data padded message bytes
|
||||
* @return original plaintext
|
||||
*/
|
||||
fun unpad(data: ByteArray): ByteArray {
|
||||
// Legacy unpadded messages (JSON starting with '{')
|
||||
if (data.isEmpty() || data[0] != PAD_MAGIC) return data
|
||||
if (data.size < 5) return data
|
||||
|
||||
// Read pad_length from last 4 bytes
|
||||
val padLength = ByteBuffer.wrap(data, data.size - 4, 4).int
|
||||
|
||||
// Validate
|
||||
if (padLength < 4 || padLength > data.size - 1) return data
|
||||
|
||||
// Strip magic prefix (1 byte) and padding (padLength bytes from end)
|
||||
return data.copyOfRange(1, data.size - padLength)
|
||||
}
|
||||
}
|
||||
136
app/src/main/java/com/kecalek/chat/crypto/RSACrypto.kt
Normal file
136
app/src/main/java/com/kecalek/chat/crypto/RSACrypto.kt
Normal file
@@ -0,0 +1,136 @@
|
||||
package com.kecalek.chat.crypto
|
||||
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.Signature
|
||||
import java.security.interfaces.RSAPrivateKey
|
||||
import java.security.interfaces.RSAPublicKey
|
||||
import java.security.spec.MGF1ParameterSpec
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.security.spec.PSSParameterSpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
|
||||
/**
|
||||
* RSA-4096 for login challenge-response only.
|
||||
* Uses RSA-PSS with SHA-256, MGF1-SHA256.
|
||||
*
|
||||
* Private key storage: DER PKCS8 raw bytes encrypted via ECP1.
|
||||
* Public key: DER SubjectPublicKeyInfo (X.509).
|
||||
*
|
||||
* Compatible with Python generate_rsa_keypair, rsa_sign, rsa_verify.
|
||||
* Sign uses PSS with salt_length=MAX. Verify accepts MAX or hash-length salt.
|
||||
*/
|
||||
object RSACrypto {
|
||||
|
||||
private const val KEY_SIZE = 4096
|
||||
|
||||
/**
|
||||
* Generate RSA-4096 keypair.
|
||||
*/
|
||||
fun generateKeypair(): Pair<RSAPrivateKey, RSAPublicKey> {
|
||||
val kpg = KeyPairGenerator.getInstance("RSA")
|
||||
kpg.initialize(KEY_SIZE)
|
||||
val kp = kpg.generateKeyPair()
|
||||
return Pair(kp.private as RSAPrivateKey, kp.public as RSAPublicKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize private key to DER PKCS8 format.
|
||||
* Optionally encrypt with password using ECP1.
|
||||
*/
|
||||
fun serializePrivate(key: RSAPrivateKey, password: String? = null): ByteArray {
|
||||
val der = key.encoded // PKCS8 DER
|
||||
return if (password != null) {
|
||||
KeyEncryption.encrypt(der, password)
|
||||
} else {
|
||||
der
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize public key to DER X.509 format.
|
||||
*/
|
||||
fun serializePublic(key: RSAPublicKey): ByteArray {
|
||||
return key.encoded // X.509 DER
|
||||
}
|
||||
|
||||
/**
|
||||
* Load private key from DER bytes (optionally ECP1-encrypted).
|
||||
*/
|
||||
fun loadPrivate(data: ByteArray, password: String? = null): RSAPrivateKey {
|
||||
val der = if (KeyEncryption.isEcp1Format(data) && password != null) {
|
||||
KeyEncryption.decrypt(data, password)
|
||||
} else {
|
||||
data
|
||||
}
|
||||
val keyFactory = KeyFactory.getInstance("RSA")
|
||||
return keyFactory.generatePrivate(PKCS8EncodedKeySpec(der)) as RSAPrivateKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Load public key from DER X.509 bytes.
|
||||
*/
|
||||
fun loadPublic(data: ByteArray): RSAPublicKey {
|
||||
val keyFactory = KeyFactory.getInstance("RSA")
|
||||
return keyFactory.generatePublic(X509EncodedKeySpec(data)) as RSAPublicKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign data with RSA-PSS (SHA-256, MGF1-SHA256, max salt length).
|
||||
* Compatible with Python rsa_sign.
|
||||
*/
|
||||
fun sign(privateKey: RSAPrivateKey, data: ByteArray): ByteArray {
|
||||
// Max salt length = key size in bytes - hash size - 2
|
||||
val maxSaltLen = privateKey.modulus.bitLength() / 8 - 32 - 2
|
||||
val pssSpec = PSSParameterSpec(
|
||||
"SHA-256",
|
||||
"MGF1",
|
||||
MGF1ParameterSpec.SHA256,
|
||||
maxSaltLen,
|
||||
1, // trailer field
|
||||
)
|
||||
val sig = Signature.getInstance("RSASSA-PSS")
|
||||
sig.setParameter(pssSpec)
|
||||
sig.initSign(privateKey)
|
||||
sig.update(data)
|
||||
return sig.sign()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify RSA-PSS signature.
|
||||
* Uses salt_length = max for verification (Java PSS handles this internally).
|
||||
* For cross-platform compat, we try max salt first, then hash-length salt.
|
||||
*/
|
||||
fun verify(publicKey: RSAPublicKey, signature: ByteArray, data: ByteArray): Boolean {
|
||||
// Try with max salt length first (Python's default for signing)
|
||||
val maxSaltLen = publicKey.modulus.bitLength() / 8 - 32 - 2
|
||||
if (verifyWithSaltLen(publicKey, signature, data, maxSaltLen)) return true
|
||||
// Try with hash-length salt (iOS compatibility)
|
||||
if (verifyWithSaltLen(publicKey, signature, data, 32)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun verifyWithSaltLen(
|
||||
publicKey: RSAPublicKey,
|
||||
signature: ByteArray,
|
||||
data: ByteArray,
|
||||
saltLen: Int,
|
||||
): Boolean {
|
||||
return try {
|
||||
val pssSpec = PSSParameterSpec(
|
||||
"SHA-256",
|
||||
"MGF1",
|
||||
MGF1ParameterSpec.SHA256,
|
||||
saltLen,
|
||||
1,
|
||||
)
|
||||
val sig = Signature.getInstance("RSASSA-PSS")
|
||||
sig.setParameter(pssSpec)
|
||||
sig.initVerify(publicKey)
|
||||
sig.update(data)
|
||||
sig.verify(signature)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
214
app/src/main/java/com/kecalek/chat/crypto/SenderKeyState.kt
Normal file
214
app/src/main/java/com/kecalek/chat/crypto/SenderKeyState.kt
Normal file
@@ -0,0 +1,214 @@
|
||||
package com.kecalek.chat.crypto
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Sender Key state for group messaging.
|
||||
* Each sender has their own chain that group members can decrypt.
|
||||
*
|
||||
* Compatible with Python SenderKeyState from crypto_utils.py.
|
||||
*
|
||||
* Chain: HKDF(sender_key, salt=0x00*32, info="SenderKeyChain") -> chain_key
|
||||
* Chain ID: SHA-256(sender_key) -> 32 bytes
|
||||
* Message key: kdf_ck(chain_key) -> (new_chain_key, message_key)
|
||||
* AAD: chain_id(32B) + message_number(4B big-endian)
|
||||
*/
|
||||
class SenderKeyState private constructor(
|
||||
private val senderKey: ByteArray,
|
||||
private var chainId: ByteArray,
|
||||
private var chainKey: ByteArray,
|
||||
private var n: Int,
|
||||
private val knownKeys: MutableMap<Int, ByteArray> = mutableMapOf(),
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val MAX_SENDER_KEY_SKIP = 256
|
||||
private val ZERO_SALT = ByteArray(32)
|
||||
|
||||
/**
|
||||
* Create new sender key state (for sending).
|
||||
* @param senderKey optional 32-byte sender key (generated if null)
|
||||
*/
|
||||
fun create(senderKey: ByteArray? = null): SenderKeyState {
|
||||
val key = senderKey ?: ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val chainId = MessageDigest.getInstance("SHA-256").digest(key)
|
||||
val chainKey = HkdfUtils.derive(
|
||||
inputKey = key,
|
||||
salt = ZERO_SALT,
|
||||
info = HkdfUtils.SENDER_KEY_CHAIN_INFO.toByteArray(),
|
||||
length = 32,
|
||||
)
|
||||
return SenderKeyState(key, chainId, chainKey, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize from received sender key (for receiving/decrypting).
|
||||
* @param exportedKey JSON bytes from exportKey()
|
||||
*/
|
||||
fun fromKey(exportedKey: ByteArray): SenderKeyState {
|
||||
val json = JSONObject(String(exportedKey))
|
||||
val key = json.getString("sender_key").hexToBytes()
|
||||
return create(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Import full state from JSON bytes.
|
||||
*/
|
||||
fun importState(data: ByteArray): SenderKeyState {
|
||||
val json = JSONObject(String(data))
|
||||
val senderKey = json.getString("sender_key").hexToBytes()
|
||||
val chainId = json.getString("chain_id").hexToBytes()
|
||||
val chainKey = json.getString("chain_key").hexToBytes()
|
||||
val n = json.getInt("n")
|
||||
|
||||
val knownKeys = mutableMapOf<Int, ByteArray>()
|
||||
if (json.has("known_keys")) {
|
||||
val knownJson = json.getJSONObject("known_keys")
|
||||
for (key in knownJson.keys()) {
|
||||
knownKeys[key.toInt()] = knownJson.getString(key).hexToBytes()
|
||||
}
|
||||
}
|
||||
|
||||
return SenderKeyState(senderKey, chainId, chainKey, n, knownKeys)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext for group message.
|
||||
* @return SenderKeyMessage with chain_id, n, ciphertext+tag, nonce
|
||||
*/
|
||||
fun encrypt(plaintext: ByteArray): SenderKeyMessage {
|
||||
val (newChainKey, messageKey) = HkdfUtils.kdfCk(chainKey)
|
||||
chainKey = newChainKey
|
||||
|
||||
val aad = buildAAD(chainId, n)
|
||||
val (nonce, ctWithTag) = AesGcmCrypto.encryptCombined(
|
||||
plaintext = plaintext,
|
||||
key = messageKey,
|
||||
aad = aad,
|
||||
)
|
||||
|
||||
val msg = SenderKeyMessage(
|
||||
chainIdHex = chainId.toHex(),
|
||||
n = n,
|
||||
ciphertext = ctWithTag,
|
||||
nonce = nonce,
|
||||
)
|
||||
n++
|
||||
return msg
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt received group message.
|
||||
* @param chainIdHex hex string of chain ID
|
||||
* @param messageN message number
|
||||
* @param ciphertext ciphertext+tag bytes
|
||||
* @param nonce 12-byte nonce
|
||||
* @return decrypted plaintext
|
||||
*/
|
||||
fun decrypt(chainIdHex: String, messageN: Int, ciphertext: ByteArray, nonce: ByteArray): ByteArray {
|
||||
// Verify chain ID
|
||||
if (chainIdHex != chainId.toHex()) {
|
||||
throw CryptoException.ChainIdMismatch("Expected ${chainId.toHex()}, got $chainIdHex")
|
||||
}
|
||||
|
||||
if (messageN - n > MAX_SENDER_KEY_SKIP) {
|
||||
throw CryptoException.MaxSkipExceeded("Cannot skip more than $MAX_SENDER_KEY_SKIP sender key messages")
|
||||
}
|
||||
|
||||
// Snapshot for rollback
|
||||
val snapChainKey = chainKey.copyOf()
|
||||
val snapN = n
|
||||
val snapKnownKeys = knownKeys.toMutableMap()
|
||||
|
||||
try {
|
||||
// Fast-forward: derive keys up to target
|
||||
while (n <= messageN) {
|
||||
val (newCk, mk) = HkdfUtils.kdfCk(chainKey)
|
||||
knownKeys[n] = mk
|
||||
chainKey = newCk
|
||||
n++
|
||||
}
|
||||
|
||||
val messageKey = knownKeys.remove(messageN)
|
||||
?: throw CryptoException.DecryptionFailed("Message key not found for n=$messageN")
|
||||
|
||||
val aad = buildAAD(chainId, messageN)
|
||||
return AesGcmCrypto.decryptCombined(
|
||||
key = messageKey,
|
||||
nonce = nonce,
|
||||
ctWithTag = ciphertext,
|
||||
aad = aad,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Rollback
|
||||
chainKey = snapChainKey
|
||||
n = snapN
|
||||
knownKeys.clear()
|
||||
knownKeys.putAll(snapKnownKeys)
|
||||
throw if (e is CryptoException) e
|
||||
else CryptoException.DecryptionFailed("Sender key decryption failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export sender key for distribution to group members.
|
||||
* @return JSON bytes containing just the sender_key
|
||||
*/
|
||||
fun exportKey(): ByteArray {
|
||||
val json = JSONObject()
|
||||
json.put("sender_key", senderKey.toHex())
|
||||
return json.toString().toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Export full state for persistence.
|
||||
*/
|
||||
fun exportState(): ByteArray {
|
||||
val json = JSONObject()
|
||||
json.put("sender_key", senderKey.toHex())
|
||||
json.put("chain_id", chainId.toHex())
|
||||
json.put("chain_key", chainKey.toHex())
|
||||
json.put("n", n)
|
||||
|
||||
val knownJson = JSONObject()
|
||||
for ((k, v) in knownKeys) {
|
||||
knownJson.put(k.toString(), v.toHex())
|
||||
}
|
||||
json.put("known_keys", knownJson)
|
||||
|
||||
return json.toString().toByteArray()
|
||||
}
|
||||
|
||||
fun getChainIdHex(): String = chainId.toHex()
|
||||
|
||||
private fun buildAAD(chainId: ByteArray, messageN: Int): ByteArray {
|
||||
val nBytes = ByteBuffer.allocate(4).putInt(messageN).array()
|
||||
return chainId + nBytes
|
||||
}
|
||||
}
|
||||
|
||||
data class SenderKeyMessage(
|
||||
val chainIdHex: String,
|
||||
val n: Int,
|
||||
val ciphertext: ByteArray,
|
||||
val nonce: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is SenderKeyMessage) return false
|
||||
return chainIdHex == other.chainIdHex && n == other.n &&
|
||||
ciphertext.contentEquals(other.ciphertext) && nonce.contentEquals(other.nonce)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = chainIdHex.hashCode()
|
||||
result = 31 * result + n
|
||||
result = 31 * result + ciphertext.contentHashCode()
|
||||
result = 31 * result + nonce.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
63
app/src/main/java/com/kecalek/chat/crypto/X25519Crypto.kt
Normal file
63
app/src/main/java/com/kecalek/chat/crypto/X25519Crypto.kt
Normal file
@@ -0,0 +1,63 @@
|
||||
package com.kecalek.chat.crypto
|
||||
|
||||
import org.bouncycastle.crypto.agreement.X25519Agreement
|
||||
import org.bouncycastle.crypto.params.X25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.X25519PublicKeyParameters
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* X25519 Diffie-Hellman key agreement using Bouncy Castle.
|
||||
* Compatible with Python's X25519PrivateKey/PublicKey from cryptography library.
|
||||
*/
|
||||
object X25519Crypto {
|
||||
|
||||
/**
|
||||
* Generate X25519 keypair.
|
||||
*/
|
||||
fun generateKeypair(): Pair<X25519PrivateKeyParameters, X25519PublicKeyParameters> {
|
||||
val privateKey = X25519PrivateKeyParameters(SecureRandom())
|
||||
return Pair(privateKey, privateKey.generatePublicKey())
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize X25519 private key to 32 bytes.
|
||||
*/
|
||||
fun serializePrivate(key: X25519PrivateKeyParameters): ByteArray {
|
||||
return key.encoded // 32 bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize X25519 public key to 32 bytes.
|
||||
*/
|
||||
fun serializePublic(key: X25519PublicKeyParameters): ByteArray {
|
||||
return key.encoded // 32 bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Load X25519 private key from 32 bytes.
|
||||
*/
|
||||
fun loadPrivate(data: ByteArray): X25519PrivateKeyParameters {
|
||||
require(data.size == 32) { "X25519 private key must be 32 bytes" }
|
||||
return X25519PrivateKeyParameters(data, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load X25519 public key from 32 bytes.
|
||||
*/
|
||||
fun loadPublic(data: ByteArray): X25519PublicKeyParameters {
|
||||
require(data.size == 32) { "X25519 public key must be 32 bytes" }
|
||||
return X25519PublicKeyParameters(data, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform X25519 Diffie-Hellman key agreement.
|
||||
* @return 32-byte shared secret
|
||||
*/
|
||||
fun dh(privateKey: X25519PrivateKeyParameters, publicKey: X25519PublicKeyParameters): ByteArray {
|
||||
val agreement = X25519Agreement()
|
||||
agreement.init(privateKey)
|
||||
val secret = ByteArray(agreement.agreementSize)
|
||||
agreement.calculateAgreement(publicKey, secret, 0)
|
||||
return secret
|
||||
}
|
||||
}
|
||||
206
app/src/main/java/com/kecalek/chat/crypto/X3DH.kt
Normal file
206
app/src/main/java/com/kecalek/chat/crypto/X3DH.kt
Normal file
@@ -0,0 +1,206 @@
|
||||
package com.kecalek.chat.crypto
|
||||
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.params.X25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.X25519PublicKeyParameters
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* X3DH (Extended Triple Diffie-Hellman) key agreement protocol.
|
||||
* Used to establish shared secrets for initial Double Ratchet sessions.
|
||||
*
|
||||
* Compatible with Python x3dh_initiate / x3dh_respond.
|
||||
*
|
||||
* DH operations:
|
||||
* dh1 = DH(IK_A_x25519, SPK_B)
|
||||
* dh2 = DH(EK_A, IK_B_x25519)
|
||||
* dh3 = DH(EK_A, SPK_B)
|
||||
* dh4 = DH(EK_A, OPK_B) [optional]
|
||||
*
|
||||
* Shared secret = HKDF(dh1||dh2||dh3[||dh4], salt=0x00*32, info="EncryptedChat_X3DH")
|
||||
*/
|
||||
object X3DH {
|
||||
|
||||
private val ZERO_SALT = ByteArray(32)
|
||||
|
||||
/**
|
||||
* Generate a signed pre-key.
|
||||
* @param identityPrivate Ed25519 identity private key (for signing)
|
||||
* @return SignedPreKey with X25519 keypair, signature, and UUID
|
||||
*/
|
||||
fun generateSignedPreKey(identityPrivate: Ed25519PrivateKeyParameters): SignedPreKey {
|
||||
val (spkPrivate, spkPublic) = X25519Crypto.generateKeypair()
|
||||
val spkPubBytes = X25519Crypto.serializePublic(spkPublic)
|
||||
val signature = Ed25519Crypto.sign(identityPrivate, spkPubBytes)
|
||||
val id = UUID.randomUUID().toString()
|
||||
|
||||
return SignedPreKey(
|
||||
id = id,
|
||||
privateKey = spkPrivate,
|
||||
publicKey = spkPublic,
|
||||
signature = signature,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate one-time pre-keys.
|
||||
* @param count number of OPKs to generate
|
||||
* @return list of OneTimePreKey with X25519 keypair and UUID
|
||||
*/
|
||||
fun generateOneTimePreKeys(count: Int = 50): List<OneTimePreKey> {
|
||||
return (0 until count).map {
|
||||
val (priv, pub) = X25519Crypto.generateKeypair()
|
||||
OneTimePreKey(
|
||||
id = UUID.randomUUID().toString(),
|
||||
privateKey = priv,
|
||||
publicKey = pub,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiator (Alice) side of X3DH.
|
||||
*
|
||||
* @param ikPrivateEd Alice's Ed25519 identity private key
|
||||
* @param ikPublicRemoteEd Bob's Ed25519 identity public key
|
||||
* @param spkRemote Bob's signed pre-key (X25519 public)
|
||||
* @param spkSignature Bob's signature over his SPK public bytes
|
||||
* @param opkRemote Bob's one-time pre-key (X25519 public, optional)
|
||||
* @return X3DHResult with shared secret, ephemeral keypair
|
||||
* @throws CryptoException.InvalidSignature if SPK signature verification fails
|
||||
* @throws CryptoException.X3DHFailed if key agreement fails
|
||||
*/
|
||||
fun initiate(
|
||||
ikPrivateEd: Ed25519PrivateKeyParameters,
|
||||
ikPublicRemoteEd: Ed25519PublicKeyParameters,
|
||||
spkRemote: X25519PublicKeyParameters,
|
||||
spkSignature: ByteArray,
|
||||
opkRemote: X25519PublicKeyParameters? = null,
|
||||
): X3DHResult {
|
||||
// Verify SPK signature
|
||||
val spkRemoteBytes = X25519Crypto.serializePublic(spkRemote)
|
||||
if (!Ed25519Crypto.verify(ikPublicRemoteEd, spkSignature, spkRemoteBytes)) {
|
||||
throw CryptoException.InvalidSignature("SPK signature verification failed")
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert identity keys Ed25519 -> X25519
|
||||
val ikPrivateX = X25519Crypto.loadPrivate(Ed25519Crypto.privateToX25519(ikPrivateEd))
|
||||
val ikPublicRemoteX = X25519Crypto.loadPublic(
|
||||
Ed25519Crypto.publicToX25519(ikPublicRemoteEd)
|
||||
)
|
||||
|
||||
// Generate ephemeral keypair
|
||||
val (ekPrivate, ekPublic) = X25519Crypto.generateKeypair()
|
||||
|
||||
// Four DH computations
|
||||
val dh1 = X25519Crypto.dh(ikPrivateX, spkRemote)
|
||||
val dh2 = X25519Crypto.dh(ekPrivate, ikPublicRemoteX)
|
||||
val dh3 = X25519Crypto.dh(ekPrivate, spkRemote)
|
||||
|
||||
var dhConcat = dh1 + dh2 + dh3
|
||||
if (opkRemote != null) {
|
||||
val dh4 = X25519Crypto.dh(ekPrivate, opkRemote)
|
||||
dhConcat += dh4
|
||||
}
|
||||
|
||||
// Derive shared secret
|
||||
val sharedSecret = HkdfUtils.derive(
|
||||
inputKey = dhConcat,
|
||||
salt = ZERO_SALT,
|
||||
info = HkdfUtils.X3DH_INFO.toByteArray(),
|
||||
length = 32,
|
||||
)
|
||||
|
||||
return X3DHResult(
|
||||
sharedSecret = sharedSecret,
|
||||
ephemeralPrivate = ekPrivate,
|
||||
ephemeralPublic = ekPublic,
|
||||
)
|
||||
} catch (e: CryptoException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw CryptoException.X3DHFailed("X3DH initiate failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Responder (Bob) side of X3DH.
|
||||
*
|
||||
* @param ikPrivateEd Bob's Ed25519 identity private key
|
||||
* @param spkPrivate Bob's signed pre-key private (X25519)
|
||||
* @param ikRemoteEd Alice's Ed25519 identity public key
|
||||
* @param ekRemote Alice's ephemeral public key (X25519)
|
||||
* @param opkPrivate Bob's one-time pre-key private (X25519, optional)
|
||||
* @return 32-byte shared secret
|
||||
*/
|
||||
fun respond(
|
||||
ikPrivateEd: Ed25519PrivateKeyParameters,
|
||||
spkPrivate: X25519PrivateKeyParameters,
|
||||
ikRemoteEd: Ed25519PublicKeyParameters,
|
||||
ekRemote: X25519PublicKeyParameters,
|
||||
opkPrivate: X25519PrivateKeyParameters? = null,
|
||||
): ByteArray {
|
||||
try {
|
||||
// Convert identity keys Ed25519 -> X25519
|
||||
val ikPrivateX = X25519Crypto.loadPrivate(Ed25519Crypto.privateToX25519(ikPrivateEd))
|
||||
val ikRemoteX = X25519Crypto.loadPublic(Ed25519Crypto.publicToX25519(ikRemoteEd))
|
||||
|
||||
// Mirror DH computations
|
||||
val dh1 = X25519Crypto.dh(spkPrivate, ikRemoteX)
|
||||
val dh2 = X25519Crypto.dh(ikPrivateX, ekRemote)
|
||||
val dh3 = X25519Crypto.dh(spkPrivate, ekRemote)
|
||||
|
||||
var dhConcat = dh1 + dh2 + dh3
|
||||
if (opkPrivate != null) {
|
||||
val dh4 = X25519Crypto.dh(opkPrivate, ekRemote)
|
||||
dhConcat += dh4
|
||||
}
|
||||
|
||||
return HkdfUtils.derive(
|
||||
inputKey = dhConcat,
|
||||
salt = ZERO_SALT,
|
||||
info = HkdfUtils.X3DH_INFO.toByteArray(),
|
||||
length = 32,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw CryptoException.X3DHFailed("X3DH respond failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SignedPreKey(
|
||||
val id: String,
|
||||
val privateKey: X25519PrivateKeyParameters,
|
||||
val publicKey: X25519PublicKeyParameters,
|
||||
val signature: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is SignedPreKey) return false
|
||||
return id == other.id
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = id.hashCode()
|
||||
}
|
||||
|
||||
data class OneTimePreKey(
|
||||
val id: String,
|
||||
val privateKey: X25519PrivateKeyParameters,
|
||||
val publicKey: X25519PublicKeyParameters,
|
||||
)
|
||||
|
||||
data class X3DHResult(
|
||||
val sharedSecret: ByteArray,
|
||||
val ephemeralPrivate: X25519PrivateKeyParameters,
|
||||
val ephemeralPublic: X25519PublicKeyParameters,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is X3DHResult) return false
|
||||
return sharedSecret.contentEquals(other.sharedSecret)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = sharedSecret.contentHashCode()
|
||||
}
|
||||
25
app/src/main/java/com/kecalek/chat/data/local/AppDatabase.kt
Normal file
25
app/src/main/java/com/kecalek/chat/data/local/AppDatabase.kt
Normal file
@@ -0,0 +1,25 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
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,
|
||||
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,
|
||||
@ColumnInfo(name = "image_json") val imageJson: String? = null,
|
||||
@ColumnInfo(name = "is_deleted") val isDeleted: Boolean = false,
|
||||
@ColumnInfo(name = "read_by_json") val readByJson: String? = null,
|
||||
@ColumnInfo(name = "reactions_json") val reactionsJson: String? = null,
|
||||
@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,
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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,
|
||||
var avatarFile: String? = null,
|
||||
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,
|
||||
var username: String,
|
||||
var email: String,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.kecalek.chat.data.model
|
||||
|
||||
import android.util.Base64
|
||||
|
||||
data class DeviceBundle(
|
||||
val deviceId: String,
|
||||
val identityKey: ByteArray,
|
||||
val spk: ByteArray,
|
||||
val spkSignature: ByteArray,
|
||||
val spkId: String,
|
||||
val opk: ByteArray? = null,
|
||||
val opkId: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
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
|
||||
?: Base64.decode(
|
||||
map["identity_key"] as? String
|
||||
?: throw IllegalArgumentException("Missing identity_key"),
|
||||
Base64.DEFAULT
|
||||
)
|
||||
|
||||
val spkB64 = (map["signed_prekey"] as? String) ?: (map["spk"] as? String)
|
||||
?: throw IllegalArgumentException("Missing signed_prekey")
|
||||
val spk = Base64.decode(spkB64, Base64.DEFAULT)
|
||||
|
||||
val spkSigB64 = map["spk_signature"] as? String
|
||||
?: throw IllegalArgumentException("Missing spk_signature")
|
||||
val spkSig = Base64.decode(spkSigB64, Base64.DEFAULT)
|
||||
|
||||
val spkId = (map["signed_prekey_id"] as? String) ?: (map["spk_id"] as? String)
|
||||
?: throw IllegalArgumentException("Missing signed_prekey_id")
|
||||
|
||||
val opkB64 = (map["one_time_prekey"] as? String) ?: (map["opk"] as? String)
|
||||
val opk = opkB64?.let { Base64.decode(it, 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()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.kecalek.chat.data.model
|
||||
|
||||
data class Invitation(
|
||||
val id: String,
|
||||
val conversationId: String,
|
||||
val conversationName: String,
|
||||
val invitedBy: String,
|
||||
val invitedByUsername: String,
|
||||
)
|
||||
66
app/src/main/java/com/kecalek/chat/data/model/Message.kt
Normal file
66
app/src/main/java/com/kecalek/chat/data/model/Message.kt
Normal file
@@ -0,0 +1,66 @@
|
||||
package com.kecalek.chat.data.model
|
||||
|
||||
import java.util.Date
|
||||
|
||||
data class Message(
|
||||
val id: String,
|
||||
val conversationId: String,
|
||||
val senderId: String,
|
||||
var senderUsername: String,
|
||||
val createdAt: Date,
|
||||
var text: String? = null,
|
||||
var replyTo: String? = null,
|
||||
var imageFileId: String? = null,
|
||||
var file: FileInfo? = null,
|
||||
var image: ImageInfo? = null,
|
||||
var isDeleted: Boolean = false,
|
||||
var readBy: Set<String> = emptySet(),
|
||||
var reactions: List<MessageReaction> = emptyList(),
|
||||
var forwardedFrom: ForwardedFrom? = null,
|
||||
var pinnedAt: Date? = null,
|
||||
var pinnedBy: String? = null,
|
||||
) {
|
||||
fun isMine(currentUserId: String): Boolean = senderId == currentUserId
|
||||
}
|
||||
|
||||
data class MessageReaction(
|
||||
val userId: String,
|
||||
val reaction: String,
|
||||
val createdAt: Date,
|
||||
)
|
||||
|
||||
data class ForwardedFrom(
|
||||
val sender: String,
|
||||
val conversationId: String,
|
||||
val messageId: String,
|
||||
)
|
||||
|
||||
data class FileInfo(
|
||||
val fileId: String,
|
||||
val aesKey: String,
|
||||
val iv: String,
|
||||
val filename: String,
|
||||
val size: Int,
|
||||
val mimeType: String,
|
||||
)
|
||||
|
||||
data class ImageInfo(
|
||||
val fileId: String,
|
||||
val aesKey: String,
|
||||
val iv: String,
|
||||
val thumbnail: String?,
|
||||
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",
|
||||
)
|
||||
}
|
||||
26
app/src/main/java/com/kecalek/chat/data/model/User.kt
Normal file
26
app/src/main/java/com/kecalek/chat/data/model/User.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.kecalek.chat.data.model
|
||||
|
||||
data class User(
|
||||
val id: String,
|
||||
var username: String,
|
||||
var email: String,
|
||||
var identityKey: ByteArray? = null,
|
||||
) {
|
||||
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,
|
||||
var username: String? = null,
|
||||
var email: String? = null,
|
||||
var phone: String? = null,
|
||||
var phoneVisible: Boolean = true,
|
||||
var location: String? = null,
|
||||
var locationVisible: Boolean = true,
|
||||
var avatarFile: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
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 org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
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 insertConversation(conversation: Conversation) {
|
||||
conversationDao.insert(conversation.toEntity())
|
||||
}
|
||||
|
||||
suspend fun insertConversations(conversations: List<Conversation>) {
|
||||
conversationDao.insertAll(conversations.map { it.toEntity() })
|
||||
}
|
||||
|
||||
suspend fun updateUnreadCount(conversationId: String, count: Int) {
|
||||
conversationDao.updateUnreadCount(conversationId, count)
|
||||
}
|
||||
|
||||
suspend fun updateFavorite(conversationId: String, isFavorite: Boolean) {
|
||||
conversationDao.updateFavorite(conversationId, isFavorite)
|
||||
}
|
||||
|
||||
suspend fun updateName(conversationId: String, name: String) {
|
||||
conversationDao.updateName(conversationId, name)
|
||||
}
|
||||
|
||||
suspend fun updateMembers(conversationId: String, members: List<ConversationMember>) {
|
||||
val entity = conversationDao.getById(conversationId) ?: return
|
||||
val updated = entity.copy(membersJson = serializeMembers(members))
|
||||
conversationDao.update(updated)
|
||||
}
|
||||
|
||||
suspend fun deleteConversation(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) },
|
||||
)
|
||||
|
||||
// --- Domain -> Entity mapping ---
|
||||
|
||||
private fun Conversation.toEntity(): ConversationEntity = ConversationEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
createdBy = createdBy,
|
||||
avatarFile = avatarFile,
|
||||
unreadCount = unreadCount,
|
||||
isFavorite = isFavorite,
|
||||
lastMessageTime = lastMessageTime?.time,
|
||||
membersJson = if (members.isNotEmpty()) serializeMembers(members) else null,
|
||||
)
|
||||
|
||||
// --- JSON parsing helpers ---
|
||||
|
||||
private fun parseMembers(jsonStr: String): List<ConversationMember> = try {
|
||||
val array = JSONArray(jsonStr)
|
||||
val result = mutableListOf<ConversationMember>()
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.getJSONObject(i)
|
||||
result.add(
|
||||
ConversationMember(
|
||||
userId = obj.getString("user_id"),
|
||||
username = obj.getString("username"),
|
||||
email = obj.getString("email"),
|
||||
)
|
||||
)
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// --- JSON serialization helpers ---
|
||||
|
||||
private fun serializeMembers(members: List<ConversationMember>): String =
|
||||
JSONArray().apply {
|
||||
members.forEach { m ->
|
||||
put(JSONObject().apply {
|
||||
put("user_id", m.userId)
|
||||
put("username", m.username)
|
||||
put("email", m.email)
|
||||
})
|
||||
}
|
||||
}.toString()
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
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.FileInfo
|
||||
import com.kecalek.chat.data.model.ForwardedFrom
|
||||
import com.kecalek.chat.data.model.ImageInfo
|
||||
import com.kecalek.chat.data.model.Message
|
||||
import com.kecalek.chat.data.model.MessageReaction
|
||||
import com.kecalek.chat.util.DateFormatter
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MessageRepository @Inject constructor(
|
||||
private val messageDao: MessageDao,
|
||||
) {
|
||||
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 insertMessage(message: Message) {
|
||||
messageDao.insert(message.toEntity())
|
||||
}
|
||||
|
||||
suspend fun insertMessages(messages: List<Message>) {
|
||||
messageDao.insertAll(messages.map { it.toEntity() })
|
||||
}
|
||||
|
||||
suspend fun markDeleted(messageId: String) {
|
||||
messageDao.markDeleted(messageId)
|
||||
}
|
||||
|
||||
suspend fun updateReactions(messageId: String, reactions: List<MessageReaction>) {
|
||||
val reactionsJson = serializeReactions(reactions)
|
||||
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 = serializeStringSet(readBy)
|
||||
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,
|
||||
)
|
||||
|
||||
// --- Domain -> Entity mapping ---
|
||||
|
||||
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()) serializeStringSet(readBy) else null,
|
||||
reactionsJson = if (reactions.isNotEmpty()) serializeReactions(reactions) else null,
|
||||
forwardedFromJson = forwardedFrom?.let { serializeForwardedFrom(it) },
|
||||
pinnedAt = pinnedAt?.time,
|
||||
pinnedBy = pinnedBy,
|
||||
)
|
||||
|
||||
// --- JSON parsing helpers ---
|
||||
|
||||
private fun parseFileInfo(jsonStr: String): FileInfo? = try {
|
||||
val obj = JSONObject(jsonStr)
|
||||
FileInfo(
|
||||
fileId = obj.getString("file_id"),
|
||||
aesKey = obj.getString("aes_key"),
|
||||
iv = obj.getString("iv"),
|
||||
filename = obj.getString("filename"),
|
||||
size = obj.getInt("size"),
|
||||
mimeType = obj.getString("mime_type"),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun parseImageInfo(jsonStr: String): ImageInfo? = try {
|
||||
val obj = JSONObject(jsonStr)
|
||||
ImageInfo(
|
||||
fileId = obj.getString("file_id"),
|
||||
aesKey = obj.getString("aes_key"),
|
||||
iv = obj.getString("iv"),
|
||||
thumbnail = obj.optString("thumbnail", null),
|
||||
filename = obj.getString("filename"),
|
||||
size = obj.getInt("size"),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun parseStringSet(jsonStr: String): Set<String> = try {
|
||||
val array = JSONArray(jsonStr)
|
||||
val result = mutableSetOf<String>()
|
||||
for (i in 0 until array.length()) {
|
||||
result.add(array.getString(i))
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
private fun parseReactions(jsonStr: String): List<MessageReaction> = try {
|
||||
val array = JSONArray(jsonStr)
|
||||
val result = mutableListOf<MessageReaction>()
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.getJSONObject(i)
|
||||
val createdAt = obj.optString("created_at", null)?.let { DateFormatter.parse(it) } ?: Date()
|
||||
result.add(
|
||||
MessageReaction(
|
||||
userId = obj.getString("user_id"),
|
||||
reaction = obj.getString("reaction"),
|
||||
createdAt = createdAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private fun parseForwardedFrom(jsonStr: String): ForwardedFrom? = try {
|
||||
val obj = JSONObject(jsonStr)
|
||||
ForwardedFrom(
|
||||
sender = obj.getString("sender"),
|
||||
conversationId = obj.getString("conversation_id"),
|
||||
messageId = obj.getString("message_id"),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// --- JSON serialization helpers ---
|
||||
|
||||
private fun serializeFileInfo(info: FileInfo): String =
|
||||
JSONObject().apply {
|
||||
put("file_id", info.fileId)
|
||||
put("aes_key", info.aesKey)
|
||||
put("iv", info.iv)
|
||||
put("filename", info.filename)
|
||||
put("size", info.size)
|
||||
put("mime_type", info.mimeType)
|
||||
}.toString()
|
||||
|
||||
private fun serializeImageInfo(info: ImageInfo): String =
|
||||
JSONObject().apply {
|
||||
put("file_id", info.fileId)
|
||||
put("aes_key", info.aesKey)
|
||||
put("iv", info.iv)
|
||||
put("thumbnail", info.thumbnail)
|
||||
put("filename", info.filename)
|
||||
put("size", info.size)
|
||||
}.toString()
|
||||
|
||||
private fun serializeStringSet(set: Set<String>): String =
|
||||
JSONArray().apply {
|
||||
set.forEach { put(it) }
|
||||
}.toString()
|
||||
|
||||
private fun serializeReactions(reactions: List<MessageReaction>): String =
|
||||
JSONArray().apply {
|
||||
reactions.forEach { r ->
|
||||
put(JSONObject().apply {
|
||||
put("user_id", r.userId)
|
||||
put("reaction", r.reaction)
|
||||
put("created_at", DateFormatter.format(r.createdAt))
|
||||
})
|
||||
}
|
||||
}.toString()
|
||||
|
||||
private fun serializeForwardedFrom(fwd: ForwardedFrom): String =
|
||||
JSONObject().apply {
|
||||
put("sender", fwd.sender)
|
||||
put("conversation_id", fwd.conversationId)
|
||||
put("message_id", fwd.messageId)
|
||||
}.toString()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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 insertUser(user: User) {
|
||||
userCacheDao.insert(user.toEntity())
|
||||
}
|
||||
|
||||
suspend fun insertUsers(users: List<User>) {
|
||||
users.forEach { userCacheDao.insert(it.toEntity()) }
|
||||
}
|
||||
|
||||
suspend fun updateIdentityKey(userId: String, identityKey: ByteArray) {
|
||||
val entity = userCacheDao.getById(userId) ?: return
|
||||
val updated = entity.copy(identityKey = identityKey, updatedAt = System.currentTimeMillis())
|
||||
userCacheDao.insert(updated)
|
||||
}
|
||||
|
||||
suspend fun clearCache() {
|
||||
userCacheDao.deleteAll()
|
||||
}
|
||||
|
||||
// --- Entity -> Domain mapping ---
|
||||
|
||||
private fun UserCacheEntity.toDomain(): User = User(
|
||||
id = id,
|
||||
username = username,
|
||||
email = email,
|
||||
identityKey = identityKey,
|
||||
)
|
||||
|
||||
// --- Domain -> Entity mapping ---
|
||||
|
||||
private fun User.toEntity(): UserCacheEntity = UserCacheEntity(
|
||||
id = id,
|
||||
username = username,
|
||||
email = email,
|
||||
identityKey = identityKey,
|
||||
)
|
||||
}
|
||||
38
app/src/main/java/com/kecalek/chat/di/AppModule.kt
Normal file
38
app/src/main/java/com/kecalek/chat/di/AppModule.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
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.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
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.
|
||||
// Note: System.loadLibrary("sqlcipher") is called in KecalekApp.onCreate()
|
||||
val passphrase = "TODO_REPLACE_WITH_DERIVED_KEY".toByteArray()
|
||||
val factory = SupportOpenHelperFactory(passphrase)
|
||||
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
AppDatabase::class.java,
|
||||
"kecalek_chat.db"
|
||||
)
|
||||
.openHelperFactory(factory)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
22
app/src/main/java/com/kecalek/chat/di/CryptoModule.kt
Normal file
22
app/src/main/java/com/kecalek/chat/di/CryptoModule.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
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
|
||||
}
|
||||
24
app/src/main/java/com/kecalek/chat/di/DatabaseModule.kt
Normal file
24
app/src/main/java/com/kecalek/chat/di/DatabaseModule.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
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()
|
||||
}
|
||||
18
app/src/main/java/com/kecalek/chat/di/NetworkModule.kt
Normal file
18
app/src/main/java/com/kecalek/chat/di/NetworkModule.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
}
|
||||
202
app/src/main/java/com/kecalek/chat/network/ConnectionManager.kt
Normal file
202
app/src/main/java/com/kecalek/chat/network/ConnectionManager.kt
Normal file
@@ -0,0 +1,202 @@
|
||||
package com.kecalek.chat.network
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.json.JSONObject
|
||||
import java.net.Socket
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
/**
|
||||
* TCP/TLS socket connection manager.
|
||||
* Handles connect, disconnect, auto-reconnect with exponential backoff.
|
||||
*
|
||||
* State machine: Disconnected -> Connecting -> Connected -> Disconnected
|
||||
*/
|
||||
@Singleton
|
||||
class ConnectionManager @Inject constructor() {
|
||||
|
||||
enum class State { DISCONNECTED, CONNECTING, CONNECTED }
|
||||
|
||||
private val _state = MutableStateFlow(State.DISCONNECTED)
|
||||
val state: StateFlow<State> = _state
|
||||
|
||||
val protocol = ProtocolHandler()
|
||||
|
||||
private var socket: Socket? = null
|
||||
private var host: String = ""
|
||||
private var port: Int = 0
|
||||
private var useTls: Boolean = false
|
||||
private var readJob: Job? = null
|
||||
private var reconnectJob: Job? = null
|
||||
private var scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Callbacks
|
||||
var onMessage: ((JSONObject) -> Unit)? = null
|
||||
var onDisconnected: (() -> Unit)? = null
|
||||
var onConnected: (() -> Unit)? = null
|
||||
|
||||
private var reconnectEnabled = true
|
||||
private var reconnectDelay = RECONNECT_BASE_DELAY_MS
|
||||
|
||||
companion object {
|
||||
const val RECONNECT_BASE_DELAY_MS = 1_000L
|
||||
const val RECONNECT_MAX_DELAY_MS = 30_000L
|
||||
const val SEND_RECEIVE_TIMEOUT_MS = 30_000
|
||||
private const val TAG = "ConnectionManager"
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the server.
|
||||
*/
|
||||
suspend fun connect(host: String, port: Int, useTls: Boolean = false) {
|
||||
if (_state.value == State.CONNECTED) return
|
||||
|
||||
this.host = host
|
||||
this.port = port
|
||||
this.useTls = useTls
|
||||
_state.value = State.CONNECTING
|
||||
|
||||
Log.d(TAG, "Connecting to $host:$port (tls=$useTls)")
|
||||
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val sock = if (useTls) {
|
||||
SSLSocketFactory.getDefault().createSocket(host, port) as Socket
|
||||
} else {
|
||||
Socket(host, port)
|
||||
}
|
||||
sock.soTimeout = SEND_RECEIVE_TIMEOUT_MS
|
||||
sock.tcpNoDelay = true
|
||||
|
||||
socket = sock
|
||||
protocol.attach(sock)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Connected to $host:$port")
|
||||
_state.value = State.CONNECTED
|
||||
reconnectDelay = RECONNECT_BASE_DELAY_MS
|
||||
onConnected?.invoke()
|
||||
startReading()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Connection to $host:$port failed: ${e::class.simpleName}: ${e.message}")
|
||||
_state.value = State.DISCONNECTED
|
||||
scheduleReconnect()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the server.
|
||||
*/
|
||||
fun disconnect() {
|
||||
reconnectEnabled = false
|
||||
reconnectJob?.cancel()
|
||||
readJob?.cancel()
|
||||
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {}
|
||||
|
||||
socket = null
|
||||
protocol.detach()
|
||||
_state.value = State.DISCONNECTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and wait for matching response.
|
||||
* Write happens on IO dispatcher; suspension resumes when the read loop delivers the reply.
|
||||
* @return ServerResponse
|
||||
*/
|
||||
suspend fun sendRequest(type: String, fields: Map<String, Any?> = emptyMap()): ServerResponse {
|
||||
val (requestId, json) = protocol.buildRequest(type, fields)
|
||||
|
||||
// Socket write must be on IO dispatcher — never on Main
|
||||
withContext(Dispatchers.IO) {
|
||||
protocol.writeMessage(json)
|
||||
}
|
||||
|
||||
// Wait for response with matching request_id
|
||||
return withTimeout(SEND_RECEIVE_TIMEOUT_MS.toLong()) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
pendingRequests[requestId] = cont
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ConcurrentHashMap: written from calling coroutine, removed from IO read loop
|
||||
private val pendingRequests = java.util.concurrent.ConcurrentHashMap<String, CancellableContinuation<ServerResponse>>()
|
||||
|
||||
private fun startReading() {
|
||||
readJob = scope.launch {
|
||||
try {
|
||||
while (isActive && protocol.isConnected) {
|
||||
val json = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
protocol.readMessage()
|
||||
}
|
||||
} catch (_: java.net.SocketTimeoutException) {
|
||||
// soTimeout expired with no data — connection is still alive, keep reading
|
||||
continue
|
||||
} ?: break // null = EOF = server closed connection
|
||||
|
||||
val requestId = json.optString("request_id", "")
|
||||
|
||||
if (requestId.isNotEmpty() && pendingRequests.containsKey(requestId)) {
|
||||
// Response to a pending request
|
||||
val response = protocol.parseResponse(json)
|
||||
pendingRequests.remove(requestId)?.resumeWith(Result.success(response))
|
||||
} else {
|
||||
// Push notification or unrequested message
|
||||
onMessage?.invoke(json)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (isActive) {
|
||||
Log.e(TAG, "Read loop error: ${e::class.simpleName}: ${e.message}")
|
||||
handleDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDisconnect() {
|
||||
Log.w(TAG, "Disconnected from $host:$port")
|
||||
socket = null
|
||||
protocol.detach()
|
||||
_state.value = State.DISCONNECTED
|
||||
|
||||
// Fail all pending requests
|
||||
val error = ProtocolException("Disconnected")
|
||||
pendingRequests.values.forEach { it.resumeWith(Result.failure(error)) }
|
||||
pendingRequests.clear()
|
||||
|
||||
onDisconnected?.invoke()
|
||||
if (reconnectEnabled) scheduleReconnect()
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (!reconnectEnabled) return
|
||||
|
||||
Log.d(TAG, "Scheduling reconnect in ${reconnectDelay}ms")
|
||||
reconnectJob = scope.launch {
|
||||
delay(reconnectDelay)
|
||||
reconnectDelay = (reconnectDelay * 2).coerceAtMost(RECONNECT_MAX_DELAY_MS)
|
||||
try {
|
||||
connect(host, port, useTls)
|
||||
} catch (_: Exception) {
|
||||
// Will retry via handleDisconnect -> scheduleReconnect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable auto-reconnect after manual disconnect.
|
||||
*/
|
||||
fun enableReconnect() {
|
||||
reconnectEnabled = true
|
||||
}
|
||||
}
|
||||
134
app/src/main/java/com/kecalek/chat/network/ProtocolHandler.kt
Normal file
134
app/src/main/java/com/kecalek/chat/network/ProtocolHandler.kt
Normal file
@@ -0,0 +1,134 @@
|
||||
package com.kecalek.chat.network
|
||||
|
||||
import android.util.Base64
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.Socket
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Newline-delimited JSON protocol codec.
|
||||
* Compatible with Python ProtocolReader/ProtocolWriter.
|
||||
*
|
||||
* Binary data is encoded as base64.
|
||||
* Each JSON message is terminated by \n.
|
||||
* Max message size: 65536 bytes.
|
||||
*/
|
||||
class ProtocolHandler(private var socket: Socket? = null) {
|
||||
|
||||
private var reader: BufferedReader? = null
|
||||
private var writer: BufferedWriter? = null
|
||||
|
||||
companion object {
|
||||
const val MAX_MESSAGE_BYTES = 65536
|
||||
const val VERSION = "0.8.5"
|
||||
}
|
||||
|
||||
fun attach(socket: Socket) {
|
||||
this.socket = socket
|
||||
reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
reader = null
|
||||
writer = null
|
||||
socket = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one JSON message from the socket.
|
||||
* Blocks until a line is available.
|
||||
* @return parsed JSONObject, or null if connection closed
|
||||
*/
|
||||
fun readMessage(): JSONObject? {
|
||||
val line = reader?.readLine() ?: return null
|
||||
if (line.toByteArray().size > MAX_MESSAGE_BYTES) {
|
||||
throw ProtocolException("Message exceeds max size: ${line.toByteArray().size}")
|
||||
}
|
||||
return JSONObject(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a JSON message to the socket.
|
||||
*/
|
||||
fun writeMessage(message: JSONObject) {
|
||||
val line = message.toString()
|
||||
if (line.toByteArray().size > MAX_MESSAGE_BYTES) {
|
||||
throw ProtocolException("Message exceeds max size: ${line.toByteArray().size}")
|
||||
}
|
||||
val w = writer ?: throw ProtocolException("Not connected")
|
||||
synchronized(w) {
|
||||
w.write(line)
|
||||
w.write("\n")
|
||||
w.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a request message.
|
||||
* @param type endpoint type (e.g., "login_start")
|
||||
* @param fields additional request fields
|
||||
* @return (requestId, JSONObject)
|
||||
*/
|
||||
fun buildRequest(type: String, fields: Map<String, Any?> = emptyMap()): Pair<String, JSONObject> {
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val json = JSONObject()
|
||||
json.put("type", type)
|
||||
json.put("request_id", requestId)
|
||||
for ((key, value) in fields) {
|
||||
when (value) {
|
||||
null -> json.put(key, JSONObject.NULL)
|
||||
is Map<*, *> -> json.put(key, JSONObject(value as Map<String, Any?>))
|
||||
is List<*> -> json.put(key, JSONArray(value))
|
||||
else -> json.put(key, value)
|
||||
}
|
||||
}
|
||||
return Pair(requestId, json)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a response message.
|
||||
* @return ServerResponse with type, status, data, requestId
|
||||
*/
|
||||
fun parseResponse(json: JSONObject): ServerResponse {
|
||||
return ServerResponse(
|
||||
type = json.getString("type"),
|
||||
status = json.getString("status"),
|
||||
data = json.optJSONObject("data") ?: JSONObject(),
|
||||
requestId = json.optString("request_id", ""),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is a push notification (no request_id).
|
||||
*/
|
||||
fun isPush(json: JSONObject): Boolean {
|
||||
return !json.has("request_id") || json.optString("request_id", "").isEmpty()
|
||||
}
|
||||
|
||||
val isConnected: Boolean
|
||||
get() = socket?.isConnected == true && socket?.isClosed == false
|
||||
}
|
||||
|
||||
data class ServerResponse(
|
||||
val type: String,
|
||||
val status: String,
|
||||
val data: JSONObject,
|
||||
val requestId: String,
|
||||
) {
|
||||
val isOk: Boolean get() = status == "ok"
|
||||
val errorMessage: String get() = data.optString("message", "Unknown error")
|
||||
}
|
||||
|
||||
class ProtocolException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
|
||||
// --- Base64 helpers for binary encoding ---
|
||||
|
||||
fun encodeBinary(data: ByteArray): String = Base64.encodeToString(data, Base64.NO_WRAP)
|
||||
|
||||
fun decodeBinary(data: String): ByteArray = Base64.decode(data, Base64.DEFAULT)
|
||||
430
app/src/main/java/com/kecalek/chat/network/ServerApi.kt
Normal file
430
app/src/main/java/com/kecalek/chat/network/ServerApi.kt
Normal file
@@ -0,0 +1,430 @@
|
||||
package com.kecalek.chat.network
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* All server API endpoint wrappers.
|
||||
* Each method builds a request, sends it, and returns parsed response.
|
||||
*
|
||||
* 50 endpoints matching Python server.py handlers exactly.
|
||||
* Binary fields use encodeBinary/decodeBinary for base64 encoding.
|
||||
*/
|
||||
@Singleton
|
||||
class ServerApi @Inject constructor(
|
||||
private val connection: ConnectionManager,
|
||||
) {
|
||||
|
||||
// ===== PRE-LOGIN ENDPOINTS =====
|
||||
|
||||
suspend fun register(
|
||||
username: String,
|
||||
email: String,
|
||||
publicKeyPem: String,
|
||||
identityKeyBase64: String,
|
||||
): ServerResponse {
|
||||
return connection.sendRequest("register", mapOf(
|
||||
"username" to username,
|
||||
"email" to email,
|
||||
"public_key" to publicKeyPem,
|
||||
"identity_key" to identityKeyBase64,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun registerConfirm(email: String, code: String): ServerResponse {
|
||||
return connection.sendRequest("register_confirm", mapOf(
|
||||
"email" to email,
|
||||
"code" to code,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun loginStart(email: String): ServerResponse {
|
||||
return connection.sendRequest("login_start", mapOf(
|
||||
"email" to email,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun loginFinish(
|
||||
email: String,
|
||||
signatureBase64: String,
|
||||
clientVersion: String = ProtocolHandler.VERSION,
|
||||
deviceId: String? = null,
|
||||
deviceName: String? = null,
|
||||
): ServerResponse {
|
||||
val fields = mutableMapOf<String, Any?>(
|
||||
"email" to email,
|
||||
"signature" to signatureBase64,
|
||||
"client_version" to clientVersion,
|
||||
)
|
||||
deviceId?.let { fields["device_id"] = it }
|
||||
deviceName?.let { fields["device_name"] = it }
|
||||
return connection.sendRequest("login_finish", fields)
|
||||
}
|
||||
|
||||
suspend fun pairingStart(email: String, tempPublicKey: String): ServerResponse {
|
||||
return connection.sendRequest("pairing_start", mapOf(
|
||||
"email" to email,
|
||||
"temp_public_key" to tempPublicKey,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun pairingPoll(code: String, pollToken: String): ServerResponse {
|
||||
return connection.sendRequest("pairing_poll", mapOf(
|
||||
"code" to code,
|
||||
"poll_token" to pollToken,
|
||||
))
|
||||
}
|
||||
|
||||
// ===== USER INFO =====
|
||||
|
||||
suspend fun getUserInfo(email: String? = null, userId: String? = null): ServerResponse {
|
||||
val fields = mutableMapOf<String, Any?>()
|
||||
email?.let { fields["email"] = it }
|
||||
userId?.let { fields["user_id"] = it }
|
||||
return connection.sendRequest("get_user_info", fields)
|
||||
}
|
||||
|
||||
// ===== KEY MANAGEMENT =====
|
||||
|
||||
suspend fun uploadPrekeys(
|
||||
signedPrekey: Map<String, String>,
|
||||
oneTimePrekeys: List<Map<String, String>>? = null,
|
||||
): ServerResponse {
|
||||
val fields = mutableMapOf<String, Any?>(
|
||||
"signed_prekey" to signedPrekey,
|
||||
)
|
||||
oneTimePrekeys?.let { fields["one_time_prekeys"] = it }
|
||||
return connection.sendRequest("upload_prekeys", fields)
|
||||
}
|
||||
|
||||
suspend fun getKeyBundle(userId: String): ServerResponse {
|
||||
return connection.sendRequest("get_key_bundle", mapOf(
|
||||
"user_id" to userId,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun getPrekeyCount(): ServerResponse {
|
||||
return connection.sendRequest("get_prekey_count")
|
||||
}
|
||||
|
||||
suspend fun ensurePrekeys(
|
||||
signedPrekey: Map<String, String>? = null,
|
||||
oneTimePrekeys: List<Map<String, String>>? = null,
|
||||
): ServerResponse {
|
||||
val fields = mutableMapOf<String, Any?>()
|
||||
signedPrekey?.let { fields["signed_prekey"] = it }
|
||||
oneTimePrekeys?.let { fields["one_time_prekeys"] = it }
|
||||
return connection.sendRequest("ensure_prekeys", fields)
|
||||
}
|
||||
|
||||
// ===== CONVERSATIONS =====
|
||||
|
||||
suspend fun createConversation(
|
||||
members: List<String>,
|
||||
name: String? = null,
|
||||
): ServerResponse {
|
||||
val fields = mutableMapOf<String, Any?>(
|
||||
"members" to members,
|
||||
)
|
||||
name?.let { fields["name"] = it }
|
||||
return connection.sendRequest("create_conversation", fields)
|
||||
}
|
||||
|
||||
suspend fun findConversation(email: String): ServerResponse {
|
||||
return connection.sendRequest("find_conversation", mapOf(
|
||||
"email" to email,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun listConversations(): ServerResponse {
|
||||
return connection.sendRequest("list_conversations")
|
||||
}
|
||||
|
||||
suspend fun renameConversation(conversationId: String, name: String): ServerResponse {
|
||||
return connection.sendRequest("rename_conversation", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
"name" to name,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun deleteConversation(conversationId: String): ServerResponse {
|
||||
return connection.sendRequest("delete_conversation", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
))
|
||||
}
|
||||
|
||||
// ===== MEMBERS =====
|
||||
|
||||
suspend fun addMember(conversationId: String, email: String): ServerResponse {
|
||||
return connection.sendRequest("add_member", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
"email" to email,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun removeMember(conversationId: String, userId: String): ServerResponse {
|
||||
return connection.sendRequest("remove_member", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
"user_id" to userId,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun leaveGroup(conversationId: String): ServerResponse {
|
||||
return connection.sendRequest("leave_group", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
))
|
||||
}
|
||||
|
||||
// ===== INVITATIONS =====
|
||||
|
||||
suspend fun listInvitations(): ServerResponse {
|
||||
return connection.sendRequest("list_invitations")
|
||||
}
|
||||
|
||||
suspend fun acceptInvitation(conversationId: String): ServerResponse {
|
||||
return connection.sendRequest("accept_invitation", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun declineInvitation(conversationId: String): ServerResponse {
|
||||
return connection.sendRequest("decline_invitation", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
))
|
||||
}
|
||||
|
||||
// ===== MESSAGES =====
|
||||
|
||||
suspend fun sendMessage(
|
||||
conversationId: String,
|
||||
ratchetHeader: Map<String, Any>,
|
||||
recipients: List<Map<String, Any?>>,
|
||||
x3dhHeader: Map<String, Any>? = null,
|
||||
senderChainId: String? = null,
|
||||
senderChainN: Int? = null,
|
||||
imageFileId: String? = null,
|
||||
): ServerResponse {
|
||||
val fields = mutableMapOf<String, Any?>(
|
||||
"conversation_id" to conversationId,
|
||||
"ratchet_header" to ratchetHeader,
|
||||
"recipients" to recipients,
|
||||
)
|
||||
x3dhHeader?.let { fields["x3dh_header"] = it }
|
||||
senderChainId?.let { fields["sender_chain_id"] = it }
|
||||
senderChainN?.let { fields["sender_chain_n"] = it }
|
||||
imageFileId?.let { fields["image_file_id"] = it }
|
||||
return connection.sendRequest("send_message", fields)
|
||||
}
|
||||
|
||||
suspend fun getMessages(
|
||||
conversationId: String,
|
||||
limit: Int = 50,
|
||||
offset: Int = 0,
|
||||
afterTs: String? = null,
|
||||
): ServerResponse {
|
||||
val fields = mutableMapOf<String, Any?>(
|
||||
"conversation_id" to conversationId,
|
||||
"limit" to limit,
|
||||
"offset" to offset,
|
||||
)
|
||||
afterTs?.let { fields["after_ts"] = it }
|
||||
return connection.sendRequest("get_messages", fields)
|
||||
}
|
||||
|
||||
suspend fun markRead(conversationId: String, messageIds: List<String>): ServerResponse {
|
||||
return connection.sendRequest("mark_read", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
"message_ids" to messageIds,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun markConversationRead(conversationId: String): ServerResponse {
|
||||
return connection.sendRequest("mark_conversation_read", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun confirmDelivery(conversationId: String, messageIds: List<String>): ServerResponse {
|
||||
return connection.sendRequest("confirm_delivery", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
"message_ids" to messageIds,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun deleteMessage(messageId: String): ServerResponse {
|
||||
return connection.sendRequest("delete_message", mapOf(
|
||||
"message_id" to messageId,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun getDeletedSince(conversationId: String, sinceTs: String): ServerResponse {
|
||||
return connection.sendRequest("get_deleted_since", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
"since_ts" to sinceTs,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun reencryptMessages(updates: List<Map<String, String>>): ServerResponse {
|
||||
return connection.sendRequest("reencrypt_messages", mapOf(
|
||||
"updates" to updates,
|
||||
))
|
||||
}
|
||||
|
||||
// ===== REACTIONS & PINS =====
|
||||
|
||||
suspend fun reactMessage(
|
||||
messageId: String,
|
||||
reaction: String,
|
||||
action: String = "add",
|
||||
): ServerResponse {
|
||||
return connection.sendRequest("react_message", mapOf(
|
||||
"message_id" to messageId,
|
||||
"reaction" to reaction,
|
||||
"action" to action,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun pinMessage(
|
||||
messageId: String,
|
||||
conversationId: String,
|
||||
action: String = "pin",
|
||||
): ServerResponse {
|
||||
return connection.sendRequest("pin_message", mapOf(
|
||||
"message_id" to messageId,
|
||||
"conversation_id" to conversationId,
|
||||
"action" to action,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun getPinnedMessages(conversationId: String): ServerResponse {
|
||||
return connection.sendRequest("get_pinned_messages", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
))
|
||||
}
|
||||
|
||||
// ===== FILE UPLOAD/DOWNLOAD =====
|
||||
|
||||
suspend fun uploadImageStart(
|
||||
conversationId: String,
|
||||
fileId: String,
|
||||
fileSize: Int,
|
||||
fileType: String = "image",
|
||||
): ServerResponse {
|
||||
return connection.sendRequest("upload_image_start", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
"file_id" to fileId,
|
||||
"file_size" to fileSize,
|
||||
"file_type" to fileType,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun uploadImageChunk(fileId: String, dataBase64: String): ServerResponse {
|
||||
return connection.sendRequest("upload_image_chunk", mapOf(
|
||||
"file_id" to fileId,
|
||||
"data" to dataBase64,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun uploadImageEnd(fileId: String): ServerResponse {
|
||||
return connection.sendRequest("upload_image_end", mapOf(
|
||||
"file_id" to fileId,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun downloadImage(fileId: String, offset: Int = 0): ServerResponse {
|
||||
return connection.sendRequest("download_image", mapOf(
|
||||
"file_id" to fileId,
|
||||
"offset" to offset,
|
||||
))
|
||||
}
|
||||
|
||||
// ===== PROFILE =====
|
||||
|
||||
suspend fun getProfile(userId: String? = null): ServerResponse {
|
||||
val fields = mutableMapOf<String, Any?>()
|
||||
userId?.let { fields["user_id"] = it }
|
||||
return connection.sendRequest("get_profile", fields)
|
||||
}
|
||||
|
||||
suspend fun updateProfile(updates: Map<String, Any>): ServerResponse {
|
||||
return connection.sendRequest("update_profile", updates)
|
||||
}
|
||||
|
||||
suspend fun updateAvatar(dataBase64: String): ServerResponse {
|
||||
return connection.sendRequest("update_avatar", mapOf(
|
||||
"data" to dataBase64,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun getAvatar(userId: String): ServerResponse {
|
||||
return connection.sendRequest("get_avatar", mapOf(
|
||||
"user_id" to userId,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun updateGroupAvatar(conversationId: String, dataBase64: String): ServerResponse {
|
||||
return connection.sendRequest("update_group_avatar", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
"data" to dataBase64,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun getGroupAvatar(conversationId: String): ServerResponse {
|
||||
return connection.sendRequest("get_group_avatar", mapOf(
|
||||
"conversation_id" to conversationId,
|
||||
))
|
||||
}
|
||||
|
||||
// ===== ACCOUNT =====
|
||||
|
||||
suspend fun changeUsername(username: String): ServerResponse {
|
||||
return connection.sendRequest("change_username", mapOf(
|
||||
"username" to username,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun rotateKeys(publicKeyPem: String): ServerResponse {
|
||||
return connection.sendRequest("rotate_keys", mapOf(
|
||||
"public_key" to publicKeyPem,
|
||||
))
|
||||
}
|
||||
|
||||
// ===== PAIRING (authenticated) =====
|
||||
|
||||
suspend fun pairingClaim(code: String): ServerResponse {
|
||||
return connection.sendRequest("pairing_claim", mapOf(
|
||||
"code" to code,
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun pairingSend(code: String, payload: Map<String, Any>): ServerResponse {
|
||||
return connection.sendRequest("pairing_send", mapOf(
|
||||
"code" to code,
|
||||
"payload" to payload,
|
||||
))
|
||||
}
|
||||
|
||||
// ===== DEVICES =====
|
||||
|
||||
suspend fun listDevices(): ServerResponse {
|
||||
return connection.sendRequest("list_devices")
|
||||
}
|
||||
|
||||
suspend fun removeDevice(deviceId: String): ServerResponse {
|
||||
return connection.sendRequest("remove_device", mapOf(
|
||||
"device_id" to deviceId,
|
||||
))
|
||||
}
|
||||
|
||||
// ===== SESSION =====
|
||||
|
||||
suspend fun sessionReset(peerUserId: String, peerDeviceId: String? = null): ServerResponse {
|
||||
val fields = mutableMapOf<String, Any?>(
|
||||
"peer_user_id" to peerUserId,
|
||||
)
|
||||
peerDeviceId?.let { fields["peer_device_id"] = it }
|
||||
return connection.sendRequest("session_reset", fields)
|
||||
}
|
||||
}
|
||||
302
app/src/main/java/com/kecalek/chat/ui/auth/AuthViewModel.kt
Normal file
302
app/src/main/java/com/kecalek/chat/ui/auth/AuthViewModel.kt
Normal file
@@ -0,0 +1,302 @@
|
||||
package com.kecalek.chat.ui.auth
|
||||
|
||||
import android.util.Base64
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.kecalek.chat.core.AuthException
|
||||
import com.kecalek.chat.core.KeyStorage
|
||||
import com.kecalek.chat.core.SessionManager
|
||||
import com.kecalek.chat.crypto.Ed25519Crypto
|
||||
import com.kecalek.chat.crypto.RSACrypto
|
||||
import com.kecalek.chat.util.Constants
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.security.interfaces.RSAPrivateKey
|
||||
import java.security.interfaces.RSAPublicKey
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* UI state for all auth screens (Login, Register, Pairing).
|
||||
*/
|
||||
data class AuthUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val loadingMessage: String? = null, // e.g. "Generating keys…", "Connecting…"
|
||||
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 isPairingComplete: Boolean = false,
|
||||
val serverHost: String = Constants.DEFAULT_HOST,
|
||||
val serverPort: Int = Constants.DEFAULT_PORT,
|
||||
val useTls: Boolean = true,
|
||||
val biometricAvailable: Boolean = false,
|
||||
val hasExistingAccount: Boolean = false,
|
||||
val registeredEmail: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Holds the already-decrypted key material between register() and confirmRegistration()
|
||||
* so we can auto-login immediately after email confirmation without re-asking for the
|
||||
* password or repeating the expensive PBKDF2 derivation.
|
||||
*
|
||||
* Cleared as soon as it is consumed (or when the ViewModel is cleared).
|
||||
*/
|
||||
private data class PendingAuth(
|
||||
val email: String,
|
||||
val rsaPrivate: RSAPrivateKey,
|
||||
val identityPrivateBytes: ByteArray, // raw Ed25519 seed — used for initLocalKey
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val sessionManager: SessionManager,
|
||||
private val keyStorage: KeyStorage,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AuthUiState())
|
||||
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
||||
|
||||
// Holds key material between register() and confirmRegistration(). Never leaves memory.
|
||||
private var pendingAuth: PendingAuth? = null
|
||||
|
||||
init {
|
||||
_uiState.update { it.copy(hasExistingAccount = keyStorage.hasRsaKeys()) }
|
||||
}
|
||||
|
||||
fun login(emailOrUsername: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, loadingMessage = "Dešifruji klíče…", error = null) }
|
||||
try {
|
||||
if (!keyStorage.hasRsaKeys()) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = "No account on this device. Register or pair first."
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Load RSA private key (decrypted with user's password via ECP1).
|
||||
// PBKDF2 600k iterations — must run off the main thread.
|
||||
val rsaPrivate = try {
|
||||
withContext(Dispatchers.Default) {
|
||||
keyStorage.loadRsaPrivate(password)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Wrong password or corrupted key.")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
|
||||
|
||||
val state = _uiState.value
|
||||
sessionManager.login(
|
||||
email = emailOrUsername,
|
||||
rsaPrivateKey = rsaPrivate,
|
||||
host = state.serverHost,
|
||||
port = state.serverPort,
|
||||
useTls = state.useTls,
|
||||
)
|
||||
|
||||
// Load identity key and init local storage key (also PBKDF2 — off main thread)
|
||||
if (keyStorage.hasIdentityKeys()) {
|
||||
val identityPrivate = withContext(Dispatchers.Default) {
|
||||
keyStorage.loadIdentityPrivate(password)
|
||||
}
|
||||
keyStorage.initLocalKey(Ed25519Crypto.serializePrivate(identityPrivate))
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(isLoading = false, loadingMessage = null, isLoggedIn = true) }
|
||||
} catch (e: AuthException) {
|
||||
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, loadingMessage = null, error = "Connection failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(username: String, email: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, loadingMessage = "Generuji klíče…", error = null) }
|
||||
try {
|
||||
// Steps 1-4 are CPU-intensive (RSA-4096 keygen + 2× PBKDF2 600k iters).
|
||||
// Run on Default dispatcher to avoid blocking the UI thread.
|
||||
data class KeyMaterial(
|
||||
val rsaPublicPem: String,
|
||||
val identityKeyBase64: String,
|
||||
val rsaPrivate: RSAPrivateKey,
|
||||
val identityPrivateBytes: ByteArray,
|
||||
)
|
||||
val keys = withContext(Dispatchers.Default) {
|
||||
// 1. Generate RSA-4096 keypair (~5-30 seconds)
|
||||
val (rsaPrivate, rsaPublic) = RSACrypto.generateKeypair()
|
||||
|
||||
// 2. Generate Ed25519 identity keypair (fast)
|
||||
val (identityPrivate, identityPublic) = Ed25519Crypto.generateKeypair()
|
||||
|
||||
// 3. Save keys encrypted with password (PBKDF2 600k iters each)
|
||||
keyStorage.saveRsaKeys(rsaPrivate, rsaPublic, password)
|
||||
keyStorage.saveIdentityKeys(identityPrivate, identityPublic, password)
|
||||
|
||||
// 4. Convert to server format and capture private key material
|
||||
KeyMaterial(
|
||||
rsaPublicPem = rsaPublicKeyToPem(rsaPublic),
|
||||
identityKeyBase64 = Base64.encodeToString(
|
||||
Ed25519Crypto.serializePublic(identityPublic),
|
||||
Base64.NO_WRAP,
|
||||
),
|
||||
rsaPrivate = rsaPrivate,
|
||||
identityPrivateBytes = Ed25519Crypto.serializePrivate(identityPrivate),
|
||||
)
|
||||
}
|
||||
|
||||
// Save decrypted keys for use in confirmRegistration (auto-login).
|
||||
// pendingAuth is cleared after use or when this ViewModel is destroyed.
|
||||
pendingAuth = PendingAuth(
|
||||
email = email,
|
||||
rsaPrivate = keys.rsaPrivate,
|
||||
identityPrivateBytes = keys.identityPrivateBytes,
|
||||
)
|
||||
|
||||
_uiState.update { it.copy(loadingMessage = "Připojuji se k serveru…") }
|
||||
|
||||
// 5. Register on server (network I/O — SessionManager uses IO dispatcher internally)
|
||||
val state = _uiState.value
|
||||
sessionManager.register(
|
||||
username = username,
|
||||
email = email,
|
||||
rsaPublicKeyPem = keys.rsaPublicPem,
|
||||
identityKeyBase64 = keys.identityKeyBase64,
|
||||
host = state.serverHost,
|
||||
port = state.serverPort,
|
||||
useTls = state.useTls,
|
||||
)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
loadingMessage = null,
|
||||
isRegistered = true,
|
||||
needsConfirmation = true,
|
||||
registeredEmail = email,
|
||||
hasExistingAccount = true,
|
||||
)
|
||||
}
|
||||
} catch (e: AuthException) {
|
||||
pendingAuth = null
|
||||
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
||||
} catch (e: Exception) {
|
||||
pendingAuth = null
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, loadingMessage = null, error = "Registration failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmRegistration(email: String, code: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, loadingMessage = "Potvrzuji kód…", error = null) }
|
||||
try {
|
||||
sessionManager.confirmRegistration(email, code)
|
||||
|
||||
// Auto-login immediately after confirmation using the already-decrypted
|
||||
// key material from register(). This avoids re-asking for the password.
|
||||
val auth = pendingAuth
|
||||
if (auth != null && auth.email == email) {
|
||||
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
|
||||
val state = _uiState.value
|
||||
sessionManager.login(
|
||||
email = email,
|
||||
rsaPrivateKey = auth.rsaPrivate,
|
||||
host = state.serverHost,
|
||||
port = state.serverPort,
|
||||
useTls = state.useTls,
|
||||
)
|
||||
// Init local DB encryption key (no password needed — bytes already decrypted)
|
||||
keyStorage.initLocalKey(auth.identityPrivateBytes)
|
||||
pendingAuth = null // consumed — clear for security
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
loadingMessage = null,
|
||||
isLoggedIn = true,
|
||||
needsConfirmation = false,
|
||||
)
|
||||
}
|
||||
} catch (e: AuthException) {
|
||||
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, loadingMessage = null, error = "Confirmation failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startPairing() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Device pairing not yet implemented.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelPairing() {
|
||||
_uiState.update {
|
||||
it.copy(isPairingWaiting = false, pairingCode = null, isPairingComplete = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun loginWithBiometric() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Biometric login not yet implemented.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateServerConfig(host: String, port: Int, useTls: Boolean) {
|
||||
_uiState.update {
|
||||
it.copy(serverHost = host, serverPort = port, useTls = useTls)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
fun resetState() {
|
||||
pendingAuth = null
|
||||
_uiState.value = AuthUiState(hasExistingAccount = keyStorage.hasRsaKeys())
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
pendingAuth = null // Ensure key material doesn't linger after ViewModel is destroyed
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun rsaPublicKeyToPem(key: RSAPublicKey): String {
|
||||
val der = key.encoded
|
||||
val base64 = Base64.encodeToString(der, Base64.NO_WRAP)
|
||||
val lines = base64.chunked(64).joinToString("\n")
|
||||
return "-----BEGIN PUBLIC KEY-----\n$lines\n-----END PUBLIC KEY-----"
|
||||
}
|
||||
}
|
||||
}
|
||||
389
app/src/main/java/com/kecalek/chat/ui/auth/LoginScreen.kt
Normal file
389
app/src/main/java/com/kecalek/chat/ui/auth/LoginScreen.kt
Normal file
@@ -0,0 +1,389 @@
|
||||
package com.kecalek.chat.ui.auth
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import com.kecalek.chat.ui.navigation.Routes
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
import com.kecalek.chat.util.Constants
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: AuthViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
var emailOrUsername by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var passwordVisible by rememberSaveable { mutableStateOf(false) }
|
||||
var serverExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var serverHost by rememberSaveable { mutableStateOf(Constants.DEFAULT_HOST) }
|
||||
var serverPort by rememberSaveable { mutableStateOf(Constants.DEFAULT_PORT.toString()) }
|
||||
var useTls by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
// Navigate to conversation list on successful login
|
||||
LaunchedEffect(uiState.isLoggedIn) {
|
||||
if (uiState.isLoggedIn) {
|
||||
navController.navigate(Routes.CONVERSATION_LIST) {
|
||||
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val textFieldColors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = CatppuccinMocha.Text,
|
||||
unfocusedTextColor = CatppuccinMocha.Text,
|
||||
cursorColor = CatppuccinMocha.Lavender,
|
||||
focusedBorderColor = CatppuccinMocha.Lavender,
|
||||
unfocusedBorderColor = CatppuccinMocha.Surface2,
|
||||
focusedLabelColor = CatppuccinMocha.Lavender,
|
||||
unfocusedLabelColor = CatppuccinMocha.Subtext0,
|
||||
focusedContainerColor = CatppuccinMocha.Surface0,
|
||||
unfocusedContainerColor = CatppuccinMocha.Surface0,
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 400.dp)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// App title
|
||||
Text(
|
||||
text = "Kecalek",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Subtitle
|
||||
Text(
|
||||
text = "Encrypted Messaging",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Email/Username field
|
||||
OutlinedTextField(
|
||||
value = emailOrUsername,
|
||||
onValueChange = {
|
||||
emailOrUsername = it
|
||||
viewModel.clearError()
|
||||
},
|
||||
label = { Text("Email or Username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = textFieldColors,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) },
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Password field
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = {
|
||||
password = it
|
||||
viewModel.clearError()
|
||||
},
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = textFieldColors,
|
||||
visualTransformation = if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = if (passwordVisible) "Hide password" else "Show password",
|
||||
tint = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (emailOrUsername.isNotBlank() && password.isNotBlank()) {
|
||||
viewModel.updateServerConfig(
|
||||
host = serverHost,
|
||||
port = serverPort.toIntOrNull() ?: Constants.DEFAULT_PORT,
|
||||
useTls = useTls,
|
||||
)
|
||||
viewModel.login(emailOrUsername, password)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Error message
|
||||
if (uiState.error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = uiState.error!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Red,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Login button / Loading indicator
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = CatppuccinMocha.Lavender,
|
||||
trackColor = CatppuccinMocha.Surface2,
|
||||
)
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateServerConfig(
|
||||
host = serverHost,
|
||||
port = serverPort.toIntOrNull() ?: Constants.DEFAULT_PORT,
|
||||
useTls = useTls,
|
||||
)
|
||||
viewModel.login(emailOrUsername, password)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
enabled = emailOrUsername.isNotBlank() && password.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Lavender,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
disabledContainerColor = CatppuccinMocha.Surface2,
|
||||
disabledContentColor = CatppuccinMocha.Overlay0,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = "Login",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Biometric login button
|
||||
if (uiState.biometricAvailable && !uiState.isLoading) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
IconButton(
|
||||
onClick = { viewModel.loginWithBiometric() },
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Fingerprint,
|
||||
contentDescription = "Login with biometrics",
|
||||
tint = CatppuccinMocha.Lavender,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Create Account button
|
||||
TextButton(
|
||||
onClick = { navController.navigate(Routes.REGISTER) },
|
||||
) {
|
||||
Text(
|
||||
text = "Create Account",
|
||||
color = CatppuccinMocha.Lavender,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
|
||||
// Link Device button
|
||||
TextButton(
|
||||
onClick = { navController.navigate(Routes.PAIRING) },
|
||||
) {
|
||||
Text(
|
||||
text = "Link Device",
|
||||
color = CatppuccinMocha.Mauve,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Server configuration (collapsible)
|
||||
TextButton(
|
||||
onClick = { serverExpanded = !serverExpanded },
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Server Configuration",
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (serverExpanded) {
|
||||
Icons.Filled.KeyboardArrowUp
|
||||
} else {
|
||||
Icons.Filled.KeyboardArrowDown
|
||||
},
|
||||
contentDescription = if (serverExpanded) "Collapse" else "Expand",
|
||||
tint = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = serverExpanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Host field
|
||||
OutlinedTextField(
|
||||
value = serverHost,
|
||||
onValueChange = { serverHost = it },
|
||||
label = { Text("Host") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = textFieldColors,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) },
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Port field
|
||||
OutlinedTextField(
|
||||
value = serverPort,
|
||||
onValueChange = { newValue ->
|
||||
if (newValue.all { it.isDigit() } && newValue.length <= 5) {
|
||||
serverPort = newValue
|
||||
}
|
||||
},
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = textFieldColors,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { focusManager.clearFocus() },
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// TLS toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Use TLS",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
Switch(
|
||||
checked = useTls,
|
||||
onCheckedChange = { useTls = it },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = CatppuccinMocha.Lavender,
|
||||
checkedTrackColor = CatppuccinMocha.Lavender.copy(alpha = 0.3f),
|
||||
uncheckedThumbColor = CatppuccinMocha.Overlay1,
|
||||
uncheckedTrackColor = CatppuccinMocha.Surface1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
236
app/src/main/java/com/kecalek/chat/ui/auth/PairingScreen.kt
Normal file
236
app/src/main/java/com/kecalek/chat/ui/auth/PairingScreen.kt
Normal file
@@ -0,0 +1,236 @@
|
||||
package com.kecalek.chat.ui.auth
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import com.kecalek.chat.ui.navigation.Routes
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PairingScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: AuthViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// Start pairing when screen opens
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.startPairing()
|
||||
}
|
||||
|
||||
// Navigate to conversation list on successful pairing + login
|
||||
LaunchedEffect(uiState.isLoggedIn) {
|
||||
if (uiState.isLoggedIn) {
|
||||
navController.navigate(Routes.CONVERSATION_LIST) {
|
||||
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animated dots for "Waiting for authorization..."
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "dots")
|
||||
val dotCount by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 4f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "dotAnimation",
|
||||
)
|
||||
val dots = ".".repeat(dotCount.toInt())
|
||||
|
||||
Scaffold(
|
||||
containerColor = CatppuccinMocha.Base,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Link New Device",
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
viewModel.cancelPairing()
|
||||
navController.popBackStack()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = CatppuccinMocha.Base,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 400.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// Info text
|
||||
Text(
|
||||
text = "Enter this code on your primary device to link this device to your account.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// 8-digit pairing code display
|
||||
if (uiState.pairingCode != null) {
|
||||
val formattedCode = uiState.pairingCode!!.let { code ->
|
||||
if (code.length == 8) {
|
||||
"${code.substring(0, 4)} ${code.substring(4)}"
|
||||
} else {
|
||||
code
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = formattedCode,
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 36.sp,
|
||||
letterSpacing = 6.sp,
|
||||
),
|
||||
color = CatppuccinMocha.Lavender,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
} else if (uiState.isLoading) {
|
||||
// Loading placeholder while fetching code
|
||||
Text(
|
||||
text = "---- ----",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 36.sp,
|
||||
letterSpacing = 6.sp,
|
||||
),
|
||||
color = CatppuccinMocha.Surface2,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Progress indicator and status text
|
||||
if (uiState.isPairingWaiting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = CatppuccinMocha.Lavender,
|
||||
trackColor = CatppuccinMocha.Surface2,
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Waiting for authorization$dots",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
// Pairing complete status
|
||||
if (uiState.isPairingComplete) {
|
||||
Text(
|
||||
text = "Device authorized",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = CatppuccinMocha.Green,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
// Error message
|
||||
if (uiState.error != null) {
|
||||
Text(
|
||||
text = uiState.error!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Red,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Cancel button
|
||||
if (uiState.isPairingWaiting && !uiState.isPairingComplete) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.cancelPairing()
|
||||
navController.popBackStack()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = CatppuccinMocha.Subtext1,
|
||||
),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true),
|
||||
) {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
505
app/src/main/java/com/kecalek/chat/ui/auth/RegisterScreen.kt
Normal file
505
app/src/main/java/com/kecalek/chat/ui/auth/RegisterScreen.kt
Normal file
@@ -0,0 +1,505 @@
|
||||
package com.kecalek.chat.ui.auth
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import com.kecalek.chat.ui.navigation.Routes
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: AuthViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
var email by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var confirmPassword by rememberSaveable { mutableStateOf("") }
|
||||
var passwordVisible by rememberSaveable { mutableStateOf(false) }
|
||||
var confirmPasswordVisible by rememberSaveable { mutableStateOf(false) }
|
||||
var verificationCode by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
// Navigate to conversation list on successful login after confirmation
|
||||
LaunchedEffect(uiState.isLoggedIn) {
|
||||
if (uiState.isLoggedIn) {
|
||||
navController.navigate(Routes.CONVERSATION_LIST) {
|
||||
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val textFieldColors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = CatppuccinMocha.Text,
|
||||
unfocusedTextColor = CatppuccinMocha.Text,
|
||||
cursorColor = CatppuccinMocha.Lavender,
|
||||
focusedBorderColor = CatppuccinMocha.Lavender,
|
||||
unfocusedBorderColor = CatppuccinMocha.Surface2,
|
||||
focusedLabelColor = CatppuccinMocha.Lavender,
|
||||
unfocusedLabelColor = CatppuccinMocha.Subtext0,
|
||||
focusedContainerColor = CatppuccinMocha.Surface0,
|
||||
unfocusedContainerColor = CatppuccinMocha.Surface0,
|
||||
)
|
||||
|
||||
val passwordsMatch = confirmPassword.isEmpty() || password == confirmPassword
|
||||
val passwordStrength = calculatePasswordStrength(password)
|
||||
val formValid = username.isNotBlank()
|
||||
&& email.isNotBlank()
|
||||
&& password.isNotBlank()
|
||||
&& confirmPassword.isNotBlank()
|
||||
&& passwordsMatch
|
||||
|
||||
Scaffold(
|
||||
containerColor = CatppuccinMocha.Base,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Create Account",
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = CatppuccinMocha.Base,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 24.dp),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 400.dp)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Registration form (hidden after successful registration)
|
||||
AnimatedVisibility(
|
||||
visible = !uiState.needsConfirmation,
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Username field
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = {
|
||||
username = it
|
||||
viewModel.clearError()
|
||||
},
|
||||
label = { Text("Username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = textFieldColors,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) },
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Email field
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = {
|
||||
email = it
|
||||
viewModel.clearError()
|
||||
},
|
||||
label = { Text("Email") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = textFieldColors,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) },
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Password field
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = {
|
||||
password = it
|
||||
viewModel.clearError()
|
||||
},
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = textFieldColors,
|
||||
visualTransformation = if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = if (passwordVisible) "Hide password" else "Show password",
|
||||
tint = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) },
|
||||
),
|
||||
)
|
||||
|
||||
// Password strength indicator
|
||||
if (password.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { passwordStrength.score },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp),
|
||||
color = passwordStrength.color,
|
||||
trackColor = CatppuccinMocha.Surface1,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = passwordStrength.label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = passwordStrength.color,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Confirm password field
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = {
|
||||
confirmPassword = it
|
||||
viewModel.clearError()
|
||||
},
|
||||
label = { Text("Confirm Password") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = textFieldColors,
|
||||
isError = !passwordsMatch,
|
||||
supportingText = if (!passwordsMatch) {
|
||||
{ Text("Passwords do not match", color = CatppuccinMocha.Red) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
visualTransformation = if (confirmPasswordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (confirmPasswordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = if (confirmPasswordVisible) "Hide password" else "Show password",
|
||||
tint = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (formValid) {
|
||||
viewModel.register(username, email, password)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Error message
|
||||
if (uiState.error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = uiState.error!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Red,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Register button / Loading indicator
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = CatppuccinMocha.Lavender,
|
||||
trackColor = CatppuccinMocha.Surface2,
|
||||
)
|
||||
uiState.loadingMessage?.let { msg ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
androidx.compose.material3.Text(
|
||||
text = msg,
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.register(username, email, password)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
enabled = formValid,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Lavender,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
disabledContainerColor = CatppuccinMocha.Surface2,
|
||||
disabledContentColor = CatppuccinMocha.Overlay0,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = "Register",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Already have an account link
|
||||
TextButton(
|
||||
onClick = { navController.popBackStack() },
|
||||
) {
|
||||
Text(
|
||||
text = "Already have an account? Login",
|
||||
color = CatppuccinMocha.Lavender,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verification code section (shown after successful registration)
|
||||
AnimatedVisibility(
|
||||
visible = uiState.needsConfirmation,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "Check your email for a verification code",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = email,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Lavender,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 6-digit code input
|
||||
OutlinedTextField(
|
||||
value = verificationCode,
|
||||
onValueChange = { newValue ->
|
||||
if (newValue.all { it.isDigit() } && newValue.length <= 6) {
|
||||
verificationCode = newValue
|
||||
viewModel.clearError()
|
||||
}
|
||||
},
|
||||
label = { Text("Verification Code") },
|
||||
placeholder = { Text("000000", color = CatppuccinMocha.Overlay0) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = textFieldColors,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (verificationCode.length == 6) {
|
||||
viewModel.confirmRegistration(email, verificationCode)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Error message for confirmation
|
||||
if (uiState.error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = uiState.error!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Red,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Confirm button / Loading indicator
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = CatppuccinMocha.Lavender,
|
||||
trackColor = CatppuccinMocha.Surface2,
|
||||
)
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.confirmRegistration(email, verificationCode)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
enabled = verificationCode.length == 6,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Lavender,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
disabledContainerColor = CatppuccinMocha.Surface2,
|
||||
disabledContentColor = CatppuccinMocha.Overlay0,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = "Confirm",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Password strength classification for the visual indicator.
|
||||
*/
|
||||
data class PasswordStrength(
|
||||
val score: Float,
|
||||
val label: String,
|
||||
val color: androidx.compose.ui.graphics.Color,
|
||||
)
|
||||
|
||||
/**
|
||||
* Calculates a basic password strength score.
|
||||
*/
|
||||
fun calculatePasswordStrength(password: String): PasswordStrength {
|
||||
if (password.isEmpty()) {
|
||||
return PasswordStrength(0f, "", CatppuccinMocha.Surface2)
|
||||
}
|
||||
|
||||
var score = 0
|
||||
if (password.length >= 8) score++
|
||||
if (password.length >= 12) score++
|
||||
if (password.any { it.isUpperCase() }) score++
|
||||
if (password.any { it.isLowerCase() }) score++
|
||||
if (password.any { it.isDigit() }) score++
|
||||
if (password.any { !it.isLetterOrDigit() }) score++
|
||||
|
||||
return when {
|
||||
score <= 2 -> PasswordStrength(0.25f, "Weak", CatppuccinMocha.Red)
|
||||
score <= 3 -> PasswordStrength(0.5f, "Fair", CatppuccinMocha.Peach)
|
||||
score <= 4 -> PasswordStrength(0.75f, "Good", CatppuccinMocha.Yellow)
|
||||
else -> PasswordStrength(1f, "Strong", CatppuccinMocha.Green)
|
||||
}
|
||||
}
|
||||
211
app/src/main/java/com/kecalek/chat/ui/chat/AttachmentSheet.kt
Normal file
211
app/src/main/java/com/kecalek/chat/ui/chat/AttachmentSheet.kt
Normal file
@@ -0,0 +1,211 @@
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Bottom sheet for selecting attachment type: Image, File, or Camera.
|
||||
*
|
||||
* Uses ActivityResultContracts for modern file/image/camera picking.
|
||||
*
|
||||
* @param isVisible Whether the bottom sheet is currently shown.
|
||||
* @param onDismiss Called when the sheet is dismissed.
|
||||
* @param onImageSelected Called with the URI of the selected image from the gallery.
|
||||
* @param onFileSelected Called with the URI of the selected file from the document picker.
|
||||
* @param onPhotoTaken Called with the URI of the photo captured by the camera.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AttachmentSheet(
|
||||
isVisible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onImageSelected: (Uri) -> Unit,
|
||||
onFileSelected: (Uri) -> Unit,
|
||||
onPhotoTaken: (Uri) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Prepare a temporary file URI for the camera capture
|
||||
val cameraUri = remember {
|
||||
val photoFile = File(context.cacheDir, "camera_photo_${System.currentTimeMillis()}.jpg")
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
photoFile,
|
||||
)
|
||||
}
|
||||
|
||||
// Image picker using PickVisualMedia
|
||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia(),
|
||||
) { uri: Uri? ->
|
||||
uri?.let { onImageSelected(it) }
|
||||
}
|
||||
|
||||
// File picker using OpenDocument
|
||||
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri: Uri? ->
|
||||
uri?.let { onFileSelected(it) }
|
||||
}
|
||||
|
||||
// Camera using TakePicture
|
||||
val cameraLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture(),
|
||||
) { success: Boolean ->
|
||||
if (success) {
|
||||
onPhotoTaken(cameraUri)
|
||||
}
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(),
|
||||
containerColor = CatppuccinMocha.Surface0,
|
||||
contentColor = CatppuccinMocha.Text,
|
||||
) {
|
||||
AttachmentSheetOptions(
|
||||
onImageClick = {
|
||||
onDismiss()
|
||||
imagePickerLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||
)
|
||||
},
|
||||
onFileClick = {
|
||||
onDismiss()
|
||||
filePickerLauncher.launch(arrayOf("*/*"))
|
||||
},
|
||||
onCameraClick = {
|
||||
onDismiss()
|
||||
cameraLauncher.launch(cameraUri)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentSheetOptions(
|
||||
onImageClick: () -> Unit,
|
||||
onFileClick: () -> Unit,
|
||||
onCameraClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 32.dp),
|
||||
) {
|
||||
// Image option
|
||||
TextButton(
|
||||
onClick = onImageClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Image,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Blue,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Image",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// File option
|
||||
TextButton(
|
||||
onClick = onFileClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Lavender,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "File",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Camera option
|
||||
TextButton(
|
||||
onClick = onCameraClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CameraAlt,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Green,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Camera",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
486
app/src/main/java/com/kecalek/chat/ui/chat/ChatScreen.kt
Normal file
486
app/src/main/java/com/kecalek/chat/ui/chat/ChatScreen.kt
Normal file
@@ -0,0 +1,486 @@
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.kecalek.chat.data.model.Message
|
||||
import com.kecalek.chat.ui.navigation.Routes
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
conversationId: String,
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToGroupInfo: (String) -> Unit,
|
||||
onNavigateToImageViewer: (String) -> Unit,
|
||||
viewModel: ChatViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
val isAtBottom by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex == 0 &&
|
||||
listState.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
}
|
||||
|
||||
// Group messages by date for separators
|
||||
val groupedMessages = remember(uiState.messages) {
|
||||
groupMessagesByDate(uiState.messages)
|
||||
}
|
||||
|
||||
// Build a map of messageId -> Message for reply lookups
|
||||
val messagesById = remember(uiState.messages) {
|
||||
uiState.messages.associateBy { it.id }
|
||||
}
|
||||
|
||||
val conversationName = remember(uiState.conversation, uiState.currentUserId) {
|
||||
uiState.conversation?.displayName(uiState.currentUserId) ?: ""
|
||||
}
|
||||
|
||||
val isGroup = uiState.conversation?.isGroup ?: false
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadMessages()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = CatppuccinMocha.Base,
|
||||
topBar = {
|
||||
if (uiState.isSearchActive) {
|
||||
SearchTopBar(
|
||||
query = uiState.searchQuery,
|
||||
resultCount = uiState.searchResults.size,
|
||||
currentIndex = uiState.currentSearchIndex,
|
||||
onQueryChange = { viewModel.search(it) },
|
||||
onNext = { viewModel.nextSearchResult() },
|
||||
onPrev = { viewModel.prevSearchResult() },
|
||||
onClose = { viewModel.toggleSearch() },
|
||||
)
|
||||
} else {
|
||||
ChatTopBar(
|
||||
conversationName = conversationName,
|
||||
verificationStatus = uiState.verificationStatus,
|
||||
onBack = onNavigateBack,
|
||||
onSearchClick = { viewModel.toggleSearch() },
|
||||
onInfoClick = {
|
||||
uiState.conversation?.id?.let { onNavigateToGroupInfo(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Message list
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
groupedMessages.forEach { (dateLabel, messages) ->
|
||||
items(
|
||||
items = messages,
|
||||
key = { it.id },
|
||||
) { message ->
|
||||
MessageBubble(
|
||||
message = message,
|
||||
isOwnMessage = message.isMine(uiState.currentUserId),
|
||||
isGroupChat = isGroup,
|
||||
replyToMessage = message.replyTo?.let { messagesById[it] },
|
||||
onReply = { viewModel.setReplyTo(it) },
|
||||
onReact = { reaction -> viewModel.reactToMessage(message.id, reaction) },
|
||||
onCopyText = { text ->
|
||||
clipboardManager.setText(AnnotatedString(text))
|
||||
},
|
||||
onForward = { messageId ->
|
||||
// TODO: Show forward conversation picker
|
||||
},
|
||||
onPin = { messageId -> viewModel.pinMessage(messageId) },
|
||||
onDelete = { messageId -> viewModel.deleteMessage(messageId) },
|
||||
onImageClick = { imageFileId ->
|
||||
onNavigateToImageViewer(imageFileId)
|
||||
},
|
||||
onFileClick = { fileInfo ->
|
||||
viewModel.downloadFile(fileInfo.fileId)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Date separator (rendered after messages because of reverseLayout)
|
||||
item(key = "date_$dateLabel") {
|
||||
DateSeparator(dateLabel = dateLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading indicator
|
||||
if (uiState.isLoading) {
|
||||
item(key = "loading") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = CatppuccinMocha.Lavender,
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message input at bottom
|
||||
MessageInput(
|
||||
replyingTo = uiState.replyingTo,
|
||||
onSendMessage = { text -> viewModel.sendMessage(text) },
|
||||
onDismissReply = { viewModel.setReplyTo(null) },
|
||||
onAttachImage = { /* TODO: Launch image picker */ },
|
||||
onAttachFile = { /* TODO: Launch file picker */ },
|
||||
)
|
||||
}
|
||||
|
||||
// Scroll-to-bottom FAB
|
||||
AnimatedVisibility(
|
||||
visible = !isAtBottom,
|
||||
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
|
||||
exit = fadeOut() + slideOutVertically(targetOffsetY = { it }),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 80.dp),
|
||||
) {
|
||||
SmallFloatingActionButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
},
|
||||
containerColor = CatppuccinMocha.Surface1,
|
||||
contentColor = CatppuccinMocha.Lavender,
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 4.dp,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardDoubleArrowDown,
|
||||
contentDescription = "Scroll to bottom",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ChatTopBar(
|
||||
conversationName: String,
|
||||
verificationStatus: String,
|
||||
onBack: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = CatppuccinMocha.Mantle,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Avatar placeholder
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CatppuccinMocha.Surface2),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = conversationName.firstOrNull()?.uppercase() ?: "?",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = conversationName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = CatppuccinMocha.Text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (verificationStatus == "verified") {
|
||||
Icons.Default.VerifiedUser
|
||||
} else {
|
||||
Icons.Default.Lock
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = if (verificationStatus == "verified") {
|
||||
CatppuccinMocha.Green
|
||||
} else {
|
||||
CatppuccinMocha.Overlay1
|
||||
},
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = if (verificationStatus == "verified") "Verified" else "Encrypted",
|
||||
fontSize = 11.sp,
|
||||
color = if (verificationStatus == "verified") {
|
||||
CatppuccinMocha.Green
|
||||
} else {
|
||||
CatppuccinMocha.Overlay1
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onSearchClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = "Search",
|
||||
tint = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onInfoClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = "Info",
|
||||
tint = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SearchTopBar(
|
||||
query: String,
|
||||
resultCount: Int,
|
||||
currentIndex: Int,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrev: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = CatppuccinMocha.Mantle,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close search",
|
||||
tint = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = CatppuccinMocha.Surface1,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
text = "Search messages...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = CatppuccinMocha.Text,
|
||||
),
|
||||
cursorBrush = SolidColor(CatppuccinMocha.Lavender),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (resultCount > 0) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "${currentIndex + 1}/$resultCount",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onPrev, enabled = resultCount > 0) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowUp,
|
||||
contentDescription = "Previous result",
|
||||
tint = if (resultCount > 0) CatppuccinMocha.Text else CatppuccinMocha.Overlay0,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onNext, enabled = resultCount > 0) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = "Next result",
|
||||
tint = if (resultCount > 0) CatppuccinMocha.Text else CatppuccinMocha.Overlay0,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DateSeparator(dateLabel: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = CatppuccinMocha.Surface1.copy(alpha = 0.7f),
|
||||
) {
|
||||
Text(
|
||||
text = dateLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups messages by date label (e.g., "Today", "Yesterday", "March 5, 2026").
|
||||
* Returns a list of pairs where the first element is the date label
|
||||
* and the second is the list of messages for that date.
|
||||
* Messages within each group maintain their original order.
|
||||
*/
|
||||
private fun groupMessagesByDate(messages: List<Message>): List<Pair<String, List<Message>>> {
|
||||
if (messages.isEmpty()) return emptyList()
|
||||
|
||||
val dateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
|
||||
val calendar = Calendar.getInstance()
|
||||
val today = calendar.clone() as Calendar
|
||||
|
||||
calendar.add(Calendar.DAY_OF_YEAR, -1)
|
||||
val yesterday = calendar.clone() as Calendar
|
||||
|
||||
return messages
|
||||
.groupBy { message ->
|
||||
val msgCalendar = Calendar.getInstance().apply { time = message.createdAt }
|
||||
when {
|
||||
isSameDay(msgCalendar, today) -> "Today"
|
||||
isSameDay(msgCalendar, yesterday) -> "Yesterday"
|
||||
else -> dateFormat.format(message.createdAt)
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun isSameDay(cal1: Calendar, cal2: Calendar): Boolean {
|
||||
return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
|
||||
cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR)
|
||||
}
|
||||
105
app/src/main/java/com/kecalek/chat/ui/chat/ChatViewModel.kt
Normal file
105
app/src/main/java/com/kecalek/chat/ui/chat/ChatViewModel.kt
Normal file
@@ -0,0 +1,105 @@
|
||||
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(),
|
||||
val currentSearchIndex: Int = -1,
|
||||
val currentUserId: String = "",
|
||||
val verificationStatus: String = "encrypted",
|
||||
)
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
124
app/src/main/java/com/kecalek/chat/ui/chat/DownloadProgress.kt
Normal file
124
app/src/main/java/com/kecalek/chat/ui/chat/DownloadProgress.kt
Normal file
@@ -0,0 +1,124 @@
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
import com.kecalek.chat.util.FileUtils
|
||||
|
||||
/**
|
||||
* Reusable download progress composable.
|
||||
*
|
||||
* Displays a circular progress indicator with percentage text in the center,
|
||||
* a label showing bytes downloaded vs total, and a cancel button.
|
||||
*
|
||||
* @param progress Current progress from 0f to 1f.
|
||||
* @param downloadedBytes Number of bytes downloaded so far.
|
||||
* @param totalBytes Total file size in bytes.
|
||||
* @param onCancel Called when the cancel button is tapped.
|
||||
* @param modifier Optional modifier.
|
||||
*/
|
||||
@Composable
|
||||
fun DownloadProgress(
|
||||
progress: Float,
|
||||
downloadedBytes: Long,
|
||||
totalBytes: Long,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val percentText = remember(progress) {
|
||||
"${(progress * 100).toInt()}%"
|
||||
}
|
||||
|
||||
val sizeText = remember(downloadedBytes, totalBytes) {
|
||||
"${FileUtils.formatFileSize(downloadedBytes)} / ${FileUtils.formatFileSize(totalBytes)}"
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = CatppuccinMocha.Surface1,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Circular progress with percentage text overlay
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
progress = { progress },
|
||||
color = CatppuccinMocha.Lavender,
|
||||
trackColor = CatppuccinMocha.Surface2,
|
||||
modifier = Modifier.size(48.dp),
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
Text(
|
||||
text = percentText,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Size info
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Downloading...",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = sizeText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
IconButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Cancel download",
|
||||
tint = CatppuccinMocha.Overlay1,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
181
app/src/main/java/com/kecalek/chat/ui/chat/FileCard.kt
Normal file
181
app/src/main/java/com/kecalek/chat/ui/chat/FileCard.kt
Normal file
@@ -0,0 +1,181 @@
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.AudioFile
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.PlayCircle
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kecalek.chat.data.model.FileInfo
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
import com.kecalek.chat.util.FileUtils
|
||||
|
||||
/**
|
||||
* Download state for a file attachment.
|
||||
*/
|
||||
enum class DownloadState {
|
||||
/** File has not been downloaded yet. */
|
||||
NOT_DOWNLOADED,
|
||||
/** File is currently being downloaded. */
|
||||
DOWNLOADING,
|
||||
/** File has been downloaded and is available locally. */
|
||||
DOWNLOADED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a file attachment card in the chat bubble.
|
||||
*
|
||||
* Shows a colored file-type icon, filename, file size + MIME type,
|
||||
* and a download/progress/checkmark indicator on the right.
|
||||
*
|
||||
* Background is Surface1 with a 1dp Surface2 border and 8dp rounded corners.
|
||||
*
|
||||
* @param fileInfo The file metadata from the message.
|
||||
* @param downloadState Current download state of the file.
|
||||
* @param downloadProgress Download progress from 0f to 1f (used when [downloadState] is [DownloadState.DOWNLOADING]).
|
||||
* @param onClick Called when the card is tapped. Triggers download if not downloaded, opens if downloaded.
|
||||
* @param onDownloadClick Called when the download button is explicitly tapped.
|
||||
* @param modifier Optional modifier.
|
||||
*/
|
||||
@Composable
|
||||
fun FileCard(
|
||||
fileInfo: FileInfo,
|
||||
downloadState: DownloadState = DownloadState.NOT_DOWNLOADED,
|
||||
downloadProgress: Float = 0f,
|
||||
onClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit = onClick,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val fileTypeIcon = remember(fileInfo.mimeType) {
|
||||
FileUtils.getFileTypeIcon(fileInfo.mimeType)
|
||||
}
|
||||
|
||||
val (icon, iconTint) = remember(fileTypeIcon) {
|
||||
getFileTypeIconAndColor(fileTypeIcon)
|
||||
}
|
||||
|
||||
val formattedSize = remember(fileInfo.size) {
|
||||
FileUtils.formatFileSize(fileInfo.size.toLong())
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = CatppuccinMocha.Surface1,
|
||||
border = BorderStroke(1.dp, CatppuccinMocha.Surface2),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// File type icon (40dp)
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = fileTypeIcon.name,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
// Filename and size
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = fileInfo.filename,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = CatppuccinMocha.Text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "$formattedSize \u2022 ${fileInfo.mimeType}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Download state indicator
|
||||
when (downloadState) {
|
||||
DownloadState.NOT_DOWNLOADED -> {
|
||||
IconButton(
|
||||
onClick = onDownloadClick,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = "Download",
|
||||
tint = CatppuccinMocha.Lavender,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DownloadState.DOWNLOADING -> {
|
||||
CircularProgressIndicator(
|
||||
progress = { downloadProgress },
|
||||
color = CatppuccinMocha.Lavender,
|
||||
trackColor = CatppuccinMocha.Surface2,
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.5.dp,
|
||||
)
|
||||
}
|
||||
|
||||
DownloadState.DOWNLOADED -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = "Downloaded",
|
||||
tint = CatppuccinMocha.Green,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a [FileUtils.FileTypeIcon] to its Material icon and CatppuccinMocha color.
|
||||
*/
|
||||
private fun getFileTypeIconAndColor(
|
||||
fileTypeIcon: FileUtils.FileTypeIcon,
|
||||
): Pair<ImageVector, Color> = when (fileTypeIcon) {
|
||||
FileUtils.FileTypeIcon.PDF -> Icons.Default.Description to CatppuccinMocha.Red
|
||||
FileUtils.FileTypeIcon.IMAGE -> Icons.Default.Image to CatppuccinMocha.Blue
|
||||
FileUtils.FileTypeIcon.VIDEO -> Icons.Default.PlayCircle to CatppuccinMocha.Mauve
|
||||
FileUtils.FileTypeIcon.AUDIO -> Icons.Default.AudioFile to CatppuccinMocha.Green
|
||||
FileUtils.FileTypeIcon.ARCHIVE -> Icons.Default.Archive to CatppuccinMocha.Yellow
|
||||
FileUtils.FileTypeIcon.DOCUMENT -> Icons.Default.InsertDriveFile to CatppuccinMocha.Overlay1
|
||||
}
|
||||
146
app/src/main/java/com/kecalek/chat/ui/chat/ImageThumbnail.kt
Normal file
146
app/src/main/java/com/kecalek/chat/ui/chat/ImageThumbnail.kt
Normal file
@@ -0,0 +1,146 @@
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kecalek.chat.data.model.ImageInfo
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
/**
|
||||
* Displays an image thumbnail in the chat bubble.
|
||||
*
|
||||
* Decodes the base64 JPEG thumbnail from [ImageInfo.thumbnail],
|
||||
* shows a shimmer placeholder while loading, and navigates
|
||||
* to the full image viewer on tap.
|
||||
*
|
||||
* @param imageInfo The image metadata including base64 thumbnail.
|
||||
* @param isDownloading Whether the full-resolution image is currently being downloaded.
|
||||
* @param downloadProgress Download progress from 0f to 1f (only used when [isDownloading] is true).
|
||||
* @param onClick Called when the thumbnail is tapped (navigate to full image viewer).
|
||||
* @param modifier Optional modifier.
|
||||
*/
|
||||
@Composable
|
||||
fun ImageThumbnail(
|
||||
imageInfo: ImageInfo,
|
||||
isDownloading: Boolean = false,
|
||||
downloadProgress: Float = 0f,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val thumbnailBitmap = remember(imageInfo.thumbnail) {
|
||||
imageInfo.thumbnail?.let { base64String ->
|
||||
try {
|
||||
val bytes = Base64.decode(base64String, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(max = 200.dp)
|
||||
.clip(shape)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (thumbnailBitmap != null) {
|
||||
// Decoded bitmap thumbnail
|
||||
Image(
|
||||
bitmap = thumbnailBitmap,
|
||||
contentDescription = imageInfo.filename,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
} else {
|
||||
// Shimmer placeholder when thumbnail is missing or still decoding
|
||||
ShimmerPlaceholder(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(4f / 3f),
|
||||
)
|
||||
}
|
||||
|
||||
// Download progress overlay
|
||||
if (isDownloading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(CatppuccinMocha.Base.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
progress = { downloadProgress },
|
||||
color = CatppuccinMocha.Lavender,
|
||||
trackColor = CatppuccinMocha.Surface2,
|
||||
modifier = Modifier.size(40.dp),
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A shimmer loading effect placeholder.
|
||||
* Uses an animated gradient sweep across the surface.
|
||||
*/
|
||||
@Composable
|
||||
private fun ShimmerPlaceholder(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||
val translateAnim by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1000f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "shimmerTranslate",
|
||||
)
|
||||
|
||||
val shimmerBrush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
CatppuccinMocha.Surface1,
|
||||
CatppuccinMocha.Surface2,
|
||||
CatppuccinMocha.Surface1,
|
||||
),
|
||||
start = Offset(translateAnim - 500f, translateAnim - 500f),
|
||||
end = Offset(translateAnim, translateAnim),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(shimmerBrush),
|
||||
)
|
||||
}
|
||||
143
app/src/main/java/com/kecalek/chat/ui/chat/ImageViewer.kt
Normal file
143
app/src/main/java/com/kecalek/chat/ui/chat/ImageViewer.kt
Normal file
@@ -0,0 +1,143 @@
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ImageViewer(
|
||||
imageUrl: String,
|
||||
filename: String = "",
|
||||
onBack: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.background(Color.Black),
|
||||
containerColor = Color.Black,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = filename.ifEmpty { "Image" },
|
||||
color = CatppuccinMocha.Text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f),
|
||||
),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Black.copy(alpha = 0.7f))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onDownload) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = "Download",
|
||||
tint = CatppuccinMocha.Text,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onShare) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "Share",
|
||||
tint = CatppuccinMocha.Text,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
scale = (scale * zoom).coerceIn(0.5f, 5f)
|
||||
offset = if (scale > 1f) {
|
||||
Offset(
|
||||
x = offset.x + pan.x,
|
||||
y = offset.y + pan.y,
|
||||
)
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = filename.ifEmpty { "Full size image" },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = offset.x,
|
||||
translationY = offset.y,
|
||||
),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
623
app/src/main/java/com/kecalek/chat/ui/chat/MessageBubble.kt
Normal file
623
app/src/main/java/com/kecalek/chat/ui/chat/MessageBubble.kt
Normal file
@@ -0,0 +1,623 @@
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material.icons.filled.Forward
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Reply
|
||||
import androidx.compose.material.icons.filled.SentimentSatisfied
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.kecalek.chat.data.model.FileInfo
|
||||
import com.kecalek.chat.data.model.ImageInfo
|
||||
import com.kecalek.chat.data.model.Message
|
||||
import com.kecalek.chat.data.model.ReactionEmoji
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
message: Message,
|
||||
isOwnMessage: Boolean,
|
||||
isGroupChat: Boolean,
|
||||
replyToMessage: Message?,
|
||||
onReply: (Message) -> Unit,
|
||||
onReact: (String) -> Unit,
|
||||
onCopyText: (String) -> Unit,
|
||||
onForward: (String) -> Unit,
|
||||
onPin: (String) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onImageClick: (String) -> Unit,
|
||||
onFileClick: (FileInfo) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val maxBubbleWidth = screenWidth * 0.8f
|
||||
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
var showReactionPicker by remember { mutableStateOf(false) }
|
||||
|
||||
val bubbleShape = remember(isOwnMessage) {
|
||||
if (isOwnMessage) {
|
||||
RoundedCornerShape(12.dp, 12.dp, 4.dp, 12.dp)
|
||||
} else {
|
||||
RoundedCornerShape(12.dp, 12.dp, 12.dp, 4.dp)
|
||||
}
|
||||
}
|
||||
|
||||
val bubbleColor = if (isOwnMessage) {
|
||||
CatppuccinMocha.Lavender.copy(alpha = 0.15f)
|
||||
} else {
|
||||
CatppuccinMocha.Surface0
|
||||
}
|
||||
|
||||
val alignment = if (isOwnMessage) Alignment.CenterEnd else Alignment.CenterStart
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
contentAlignment = alignment,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = if (isOwnMessage) Alignment.End else Alignment.Start,
|
||||
) {
|
||||
Box {
|
||||
Surface(
|
||||
shape = bubbleShape,
|
||||
color = bubbleColor,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { showContextMenu = true },
|
||||
),
|
||||
) {
|
||||
if (message.isDeleted) {
|
||||
DeletedMessageContent()
|
||||
} else {
|
||||
MessageContent(
|
||||
message = message,
|
||||
isOwnMessage = isOwnMessage,
|
||||
isGroupChat = isGroupChat,
|
||||
replyToMessage = replyToMessage,
|
||||
onImageClick = onImageClick,
|
||||
onFileClick = onFileClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu
|
||||
DropdownMenu(
|
||||
expanded = showContextMenu,
|
||||
onDismissRequest = { showContextMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Reply") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onReply(message)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Reply, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("React") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
showReactionPicker = true
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.SentimentSatisfied, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
},
|
||||
)
|
||||
if (message.text != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy text") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onCopyText(message.text ?: "")
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Forward") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onForward(message.id)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Forward, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(if (message.pinnedAt != null) "Unpin" else "Pin")
|
||||
},
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onPin(message.id)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.PushPin, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
},
|
||||
)
|
||||
if (isOwnMessage) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text("Delete", color = CatppuccinMocha.Red)
|
||||
},
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onDelete(message.id)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Red,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Reaction picker popup
|
||||
DropdownMenu(
|
||||
expanded = showReactionPicker,
|
||||
onDismissRequest = { showReactionPicker = false },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
ReactionEmoji.allowed.forEach { reactionKey ->
|
||||
val emoji = ReactionEmoji.display[reactionKey] ?: reactionKey
|
||||
Text(
|
||||
text = emoji,
|
||||
fontSize = 24.sp,
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
showReactionPicker = false
|
||||
onReact(reactionKey)
|
||||
}
|
||||
.padding(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reaction badges below the bubble
|
||||
if (message.reactions.isNotEmpty() && !message.isDeleted) {
|
||||
ReactionBadges(
|
||||
reactions = message.reactions.groupBy { it.reaction },
|
||||
onReactionClick = onReact,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeletedMessageContent() {
|
||||
Text(
|
||||
text = "This message was deleted",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
),
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageContent(
|
||||
message: Message,
|
||||
isOwnMessage: Boolean,
|
||||
isGroupChat: Boolean,
|
||||
replyToMessage: Message?,
|
||||
onImageClick: (String) -> Unit,
|
||||
onFileClick: (FileInfo) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
// Sender name (only for other users in group chats)
|
||||
if (!isOwnMessage && isGroupChat) {
|
||||
Text(
|
||||
text = message.senderUsername,
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = CatppuccinMocha.Lavender,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
|
||||
// Forwarded indicator
|
||||
message.forwardedFrom?.let { forwarded ->
|
||||
ForwardedHeader(senderName = forwarded.sender)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Reply reference
|
||||
if (message.replyTo != null) {
|
||||
ReplyReference(replyToMessage = replyToMessage)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Message text with link and mention highlighting
|
||||
message.text?.let { text ->
|
||||
AnnotatedMessageText(text = text)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
|
||||
// Image thumbnail
|
||||
message.image?.let { imageInfo ->
|
||||
ImageThumbnail(
|
||||
imageInfo = imageInfo,
|
||||
onClick = { onImageClick(imageInfo.fileId) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// File card
|
||||
message.file?.let { fileInfo ->
|
||||
FileCard(
|
||||
fileInfo = fileInfo,
|
||||
onClick = { onFileClick(fileInfo) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Bottom row: timestamp + pin + read receipt
|
||||
BottomInfoRow(
|
||||
message = message,
|
||||
isOwnMessage = isOwnMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ForwardedHeader(senderName: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
.height(16.dp)
|
||||
.background(CatppuccinMocha.Blue),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Forwarded from $senderName",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
),
|
||||
color = CatppuccinMocha.Blue,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyReference(replyToMessage: Message?) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = CatppuccinMocha.Surface1.copy(alpha = 0.5f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
.height(16.dp)
|
||||
.background(CatppuccinMocha.Lavender),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Column {
|
||||
if (replyToMessage != null) {
|
||||
Text(
|
||||
text = replyToMessage.senderUsername,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = CatppuccinMocha.Lavender,
|
||||
)
|
||||
Text(
|
||||
text = replyToMessage.text ?: "[Attachment]",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Original message",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnnotatedMessageText(text: String) {
|
||||
val urlPattern = remember {
|
||||
Regex("(https?://\\S+)")
|
||||
}
|
||||
val mentionPattern = remember {
|
||||
Regex("@\\w+")
|
||||
}
|
||||
|
||||
val annotatedString = remember(text) {
|
||||
buildAnnotatedString {
|
||||
var lastIndex = 0
|
||||
val allMatches = (urlPattern.findAll(text) + mentionPattern.findAll(text))
|
||||
.sortedBy { it.range.first }
|
||||
|
||||
for (match in allMatches) {
|
||||
if (match.range.first < lastIndex) continue
|
||||
|
||||
// Append text before the match
|
||||
append(text.substring(lastIndex, match.range.first))
|
||||
|
||||
// Append the match with style
|
||||
val isUrl = match.value.startsWith("http")
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = CatppuccinMocha.Blue,
|
||||
fontWeight = if (!isUrl) FontWeight.Bold else FontWeight.Normal,
|
||||
textDecoration = if (isUrl) TextDecoration.Underline else TextDecoration.None,
|
||||
)
|
||||
) {
|
||||
append(match.value)
|
||||
}
|
||||
lastIndex = match.range.last + 1
|
||||
}
|
||||
|
||||
// Append remaining text
|
||||
if (lastIndex < text.length) {
|
||||
append(text.substring(lastIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = annotatedString,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageThumbnail(
|
||||
imageInfo: ImageInfo,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageInfo.thumbnail ?: imageInfo.fileId,
|
||||
contentDescription = imageInfo.filename,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileCard(
|
||||
fileInfo: FileInfo,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = CatppuccinMocha.Surface1,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Lavender,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = fileInfo.filename,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = formatFileSize(fileInfo.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomInfoRow(
|
||||
message: Message,
|
||||
isOwnMessage: Boolean,
|
||||
) {
|
||||
val timeFormatter = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Timestamp
|
||||
Text(
|
||||
text = timeFormatter.format(message.createdAt),
|
||||
fontSize = 11.sp,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
|
||||
// Pin indicator
|
||||
if (message.pinnedAt != null) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.PushPin,
|
||||
contentDescription = "Pinned",
|
||||
tint = CatppuccinMocha.Overlay1,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Read receipt (own messages only)
|
||||
if (isOwnMessage) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
ReadReceiptIcon(readBy = message.readBy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReadReceiptIcon(readBy: Set<String>) {
|
||||
val readCount = readBy.size
|
||||
when {
|
||||
readCount >= 2 -> {
|
||||
// Read (blue double check)
|
||||
Icon(
|
||||
imageVector = Icons.Default.DoneAll,
|
||||
contentDescription = "Read",
|
||||
tint = CatppuccinMocha.Blue,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
readCount == 1 -> {
|
||||
// Delivered (gray double check)
|
||||
Icon(
|
||||
imageVector = Icons.Default.DoneAll,
|
||||
contentDescription = "Delivered",
|
||||
tint = CatppuccinMocha.Overlay1,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// Sent (single check)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Done,
|
||||
contentDescription = "Sent",
|
||||
tint = CatppuccinMocha.Overlay1,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ReactionBadges(
|
||||
reactions: Map<String, List<com.kecalek.chat.data.model.MessageReaction>>,
|
||||
onReactionClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
reactions.forEach { (reactionKey, reactionList) ->
|
||||
val emoji = ReactionEmoji.display[reactionKey] ?: reactionKey
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = CatppuccinMocha.Surface1,
|
||||
modifier = Modifier.clickable { onReactionClick(reactionKey) },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(text = emoji, fontSize = 14.sp)
|
||||
Text(
|
||||
text = "${reactionList.size}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFileSize(bytes: Int): String {
|
||||
return when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
else -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
290
app/src/main/java/com/kecalek/chat/ui/chat/MessageInput.kt
Normal file
290
app/src/main/java/com/kecalek/chat/ui/chat/MessageInput.kt
Normal file
@@ -0,0 +1,290 @@
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kecalek.chat.data.model.Message
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessageInput(
|
||||
replyingTo: Message?,
|
||||
onSendMessage: (String) -> Unit,
|
||||
onDismissReply: () -> Unit,
|
||||
onAttachImage: () -> Unit,
|
||||
onAttachFile: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
var showAttachmentSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(CatppuccinMocha.Mantle)
|
||||
.imePadding(),
|
||||
) {
|
||||
// Reply preview bar
|
||||
AnimatedVisibility(visible = replyingTo != null) {
|
||||
replyingTo?.let { message ->
|
||||
ReplyPreview(
|
||||
message = message,
|
||||
onDismiss = onDismissReply,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Input row
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
// Attachment button
|
||||
IconButton(
|
||||
onClick = { showAttachmentSheet = true },
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AttachFile,
|
||||
contentDescription = "Attach",
|
||||
tint = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
// Text field (pill-shaped)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = CatppuccinMocha.Surface1,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (text.isEmpty()) {
|
||||
Text(
|
||||
text = "Message...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = CatppuccinMocha.Text,
|
||||
),
|
||||
cursorBrush = SolidColor(CatppuccinMocha.Lavender),
|
||||
maxLines = 4,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
// Send button (only visible when text is non-empty)
|
||||
AnimatedVisibility(
|
||||
visible = text.isNotBlank(),
|
||||
enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }),
|
||||
exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it }),
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (text.isNotBlank()) {
|
||||
onSendMessage(text.trim())
|
||||
text = ""
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = CatppuccinMocha.Lavender,
|
||||
shape = CircleShape,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
tint = CatppuccinMocha.Base,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment bottom sheet
|
||||
if (showAttachmentSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showAttachmentSheet = false },
|
||||
sheetState = rememberModalBottomSheetState(),
|
||||
containerColor = CatppuccinMocha.Surface0,
|
||||
) {
|
||||
AttachmentSheetContent(
|
||||
onImageClick = {
|
||||
showAttachmentSheet = false
|
||||
onAttachImage()
|
||||
},
|
||||
onFileClick = {
|
||||
showAttachmentSheet = false
|
||||
onAttachFile()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyPreview(
|
||||
message: Message,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(CatppuccinMocha.Surface0)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Lavender vertical bar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.height(32.dp)
|
||||
.background(
|
||||
color = CatppuccinMocha.Lavender,
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = message.senderUsername,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = CatppuccinMocha.Lavender,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = message.text ?: "[Attachment]",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Cancel reply",
|
||||
tint = CatppuccinMocha.Overlay1,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentSheetContent(
|
||||
onImageClick: () -> Unit,
|
||||
onFileClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 32.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onImageClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Image,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Lavender,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Image",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = CatppuccinMocha.Text,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onFileClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Lavender,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "File",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = CatppuccinMocha.Text,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.kecalek.chat.data.model.Message
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Bottom sheet displaying a list of pinned messages for the current conversation.
|
||||
*
|
||||
* @param pinnedMessages List of messages that have been pinned (pinnedAt != null).
|
||||
* @param onMessageClick Called with the message ID when a pinned message is tapped,
|
||||
* allowing the caller to scroll to that message in the chat and dismiss the sheet.
|
||||
* @param onDismiss Called when the sheet is dismissed.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PinnedMessagesSheet(
|
||||
pinnedMessages: List<Message>,
|
||||
onMessageClick: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = CatppuccinMocha.Surface0,
|
||||
dragHandle = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
) {
|
||||
// Header
|
||||
PinnedMessagesHeader(onClose = onDismiss)
|
||||
|
||||
HorizontalDivider(
|
||||
color = CatppuccinMocha.Surface2,
|
||||
thickness = 0.5.dp,
|
||||
)
|
||||
|
||||
if (pinnedMessages.isEmpty()) {
|
||||
// Empty state
|
||||
PinnedMessagesEmptyState()
|
||||
} else {
|
||||
// Pinned messages list
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
items(
|
||||
items = pinnedMessages,
|
||||
key = { it.id },
|
||||
) { message ->
|
||||
PinnedMessageItem(
|
||||
message = message,
|
||||
onClick = {
|
||||
onMessageClick(message.id)
|
||||
},
|
||||
)
|
||||
HorizontalDivider(
|
||||
color = CatppuccinMocha.Surface1,
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinnedMessagesHeader(onClose: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PushPin,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Lavender,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Pinned Messages",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = CatppuccinMocha.Text,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = CatppuccinMocha.Overlay1,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinnedMessagesEmptyState() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 48.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PushPin,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Overlay0,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "No pinned messages",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinnedMessageItem(
|
||||
message: Message,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val pinDateFormatter = remember { SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) }
|
||||
val pinDateText = message.pinnedAt?.let { pinDateFormatter.format(it) } ?: ""
|
||||
|
||||
Surface(
|
||||
color = CatppuccinMocha.Surface0,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
// Pin icon accent bar
|
||||
Surface(
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
color = CatppuccinMocha.Lavender,
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.height(40.dp),
|
||||
) {}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Sender name
|
||||
Text(
|
||||
text = message.senderUsername,
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
color = CatppuccinMocha.Lavender,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
// Message text preview
|
||||
Text(
|
||||
text = message.text ?: "[Attachment]",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Text,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Pin date
|
||||
if (pinDateText.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Pinned $pinDateText",
|
||||
fontSize = 11.sp,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
app/src/main/java/com/kecalek/chat/ui/chat/ReactionBadge.kt
Normal file
122
app/src/main/java/com/kecalek/chat/ui/chat/ReactionBadge.kt
Normal file
@@ -0,0 +1,122 @@
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.data.model.MessageReaction
|
||||
import com.kecalek.chat.data.model.ReactionEmoji
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
/**
|
||||
* Displays reaction chips below a message bubble as a FlowRow.
|
||||
*
|
||||
* Each chip shows the emoji and the count of users who reacted with it.
|
||||
* If the current user has reacted with a particular emoji, that chip is
|
||||
* highlighted with a Lavender tint. Tapping a chip toggles the current
|
||||
* user's reaction.
|
||||
*
|
||||
* @param reactions Map of reaction key to list of [MessageReaction] for that key.
|
||||
* @param currentUserId The ID of the current user, used to determine highlight state.
|
||||
* @param onReactionClick Called with the reaction key when a chip is tapped.
|
||||
* @param modifier Optional modifier for the FlowRow container.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ReactionBadge(
|
||||
reactions: Map<String, List<MessageReaction>>,
|
||||
currentUserId: String,
|
||||
onReactionClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (reactions.isEmpty()) return
|
||||
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
reactions.forEach { (reactionKey, reactionList) ->
|
||||
val emoji = ReactionEmoji.display[reactionKey] ?: reactionKey
|
||||
val userReacted = reactionList.any { it.userId == currentUserId }
|
||||
val count = reactionList.size
|
||||
|
||||
ReactionChip(
|
||||
emoji = emoji,
|
||||
count = count,
|
||||
isHighlighted = userReacted,
|
||||
onClick = { onReactionClick(reactionKey) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single reaction chip showing an emoji and a count.
|
||||
*
|
||||
* @param emoji The emoji character(s) to display.
|
||||
* @param count The number of users who reacted with this emoji.
|
||||
* @param isHighlighted True if the current user reacted (Lavender highlight).
|
||||
* @param onClick Called when the chip is tapped.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReactionChip(
|
||||
emoji: String,
|
||||
count: Int,
|
||||
isHighlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val backgroundColor = if (isHighlighted) {
|
||||
CatppuccinMocha.Lavender.copy(alpha = 0.2f)
|
||||
} else {
|
||||
CatppuccinMocha.Surface1
|
||||
}
|
||||
|
||||
val borderColor = if (isHighlighted) {
|
||||
CatppuccinMocha.Lavender
|
||||
} else {
|
||||
CatppuccinMocha.Surface2
|
||||
}
|
||||
|
||||
val countColor = if (isHighlighted) {
|
||||
CatppuccinMocha.Lavender
|
||||
} else {
|
||||
CatppuccinMocha.Subtext0
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = backgroundColor,
|
||||
border = BorderStroke(1.dp, borderColor),
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = emoji,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Text(
|
||||
text = "$count",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontSize = 12.sp,
|
||||
color = countColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
117
app/src/main/java/com/kecalek/chat/ui/chat/ReactionPicker.kt
Normal file
117
app/src/main/java/com/kecalek/chat/ui/chat/ReactionPicker.kt
Normal file
@@ -0,0 +1,117 @@
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import com.kecalek.chat.data.model.ReactionEmoji
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
/**
|
||||
* Horizontal row of 6 emoji reaction buttons displayed as a popup overlay.
|
||||
*
|
||||
* @param visible Whether the picker is currently visible.
|
||||
* @param onReactionSelected Called with the reaction key (e.g. "thumbsup") when an emoji is tapped.
|
||||
* @param onDismiss Called when the user taps outside the picker to dismiss it.
|
||||
*/
|
||||
@Composable
|
||||
fun ReactionPicker(
|
||||
visible: Boolean,
|
||||
onReactionSelected: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
if (!visible) return
|
||||
|
||||
// Scale-in animation state
|
||||
var animationTriggered by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(visible) {
|
||||
animationTriggered = true
|
||||
}
|
||||
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (animationTriggered) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 250),
|
||||
label = "reaction_picker_scale",
|
||||
)
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.TopCenter,
|
||||
onDismissRequest = onDismiss,
|
||||
properties = PopupProperties(focusable = true),
|
||||
) {
|
||||
Box {
|
||||
// Invisible scrim for outside-tap dismissal
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
|
||||
// Emoji row card with scale animation
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = CatppuccinMocha.Surface1,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 8.dp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
alpha = scale
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ReactionEmoji.allowed.forEach { reactionKey ->
|
||||
val emoji = ReactionEmoji.display[reactionKey] ?: reactionKey
|
||||
|
||||
Text(
|
||||
text = emoji,
|
||||
fontSize = 28.sp,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
onReactionSelected(reactionKey)
|
||||
}
|
||||
.padding(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
256
app/src/main/java/com/kecalek/chat/ui/chat/SearchOverlay.kt
Normal file
256
app/src/main/java/com/kecalek/chat/ui/chat/SearchOverlay.kt
Normal file
@@ -0,0 +1,256 @@
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Search bar overlay displayed at the top of the chat screen.
|
||||
*
|
||||
* Features:
|
||||
* - TextField with search icon placeholder
|
||||
* - Match count display ("3 of 12")
|
||||
* - Up/Down arrow buttons to cycle through results
|
||||
* - Close button to dismiss search
|
||||
* - Debounced search with 300ms delay
|
||||
*
|
||||
* @param query Current search query text.
|
||||
* @param totalMatches Total number of matches found.
|
||||
* @param currentMatchIndex 0-based index of the currently focused match.
|
||||
* @param onQueryChange Called with debounced query text as the user types.
|
||||
* @param onNextMatch Called when the user taps the down arrow to go to the next match.
|
||||
* @param onPreviousMatch Called when the user taps the up arrow to go to the previous match.
|
||||
* @param onClose Called when the user taps the close (X) button or presses Escape.
|
||||
*/
|
||||
@Composable
|
||||
fun SearchOverlay(
|
||||
query: String,
|
||||
totalMatches: Int,
|
||||
currentMatchIndex: Int,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onNextMatch: () -> Unit,
|
||||
onPreviousMatch: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
// Internal text state for debouncing
|
||||
var internalText by remember { mutableStateOf(query) }
|
||||
|
||||
// Debounce: emit onQueryChange 300ms after last keystroke
|
||||
LaunchedEffect(internalText) {
|
||||
delay(300L)
|
||||
onQueryChange(internalText)
|
||||
}
|
||||
|
||||
// Auto-focus the search field when the overlay appears
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = CatppuccinMocha.Mantle,
|
||||
shadowElevation = 4.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Search text field
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = CatppuccinMocha.Surface1,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Overlay1,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
if (internalText.isEmpty()) {
|
||||
Text(
|
||||
text = "Search messages...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
value = internalText,
|
||||
onValueChange = { internalText = it },
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = CatppuccinMocha.Text,
|
||||
),
|
||||
cursorBrush = SolidColor(CatppuccinMocha.Lavender),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match count
|
||||
if (totalMatches > 0) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "${currentMatchIndex + 1} of $totalMatches",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
} else if (internalText.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "0 results",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
|
||||
// Up arrow (previous result)
|
||||
IconButton(
|
||||
onClick = onPreviousMatch,
|
||||
enabled = totalMatches > 0,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowUp,
|
||||
contentDescription = "Previous result",
|
||||
tint = if (totalMatches > 0) CatppuccinMocha.Text else CatppuccinMocha.Overlay0,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Down arrow (next result)
|
||||
IconButton(
|
||||
onClick = onNextMatch,
|
||||
enabled = totalMatches > 0,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = "Next result",
|
||||
tint = if (totalMatches > 0) CatppuccinMocha.Text else CatppuccinMocha.Overlay0,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Close button
|
||||
IconButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close search",
|
||||
tint = CatppuccinMocha.Text,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an AnnotatedString with yellow background highlighting on portions of [text]
|
||||
* that match [query] (case-insensitive).
|
||||
*
|
||||
* @param text The full message text to display.
|
||||
* @param query The search query to highlight within the text.
|
||||
* @param isCurrentMatch Whether this particular message contains the currently focused match
|
||||
* (uses a brighter highlight color).
|
||||
* @return An AnnotatedString with highlighted spans.
|
||||
*/
|
||||
@Composable
|
||||
fun highlightedSearchText(
|
||||
text: String,
|
||||
query: String,
|
||||
isCurrentMatch: Boolean = false,
|
||||
): androidx.compose.ui.text.AnnotatedString {
|
||||
if (query.isBlank()) {
|
||||
return buildAnnotatedString { append(text) }
|
||||
}
|
||||
|
||||
val highlightColor = if (isCurrentMatch) {
|
||||
CatppuccinMocha.Yellow
|
||||
} else {
|
||||
CatppuccinMocha.Yellow.copy(alpha = 0.4f)
|
||||
}
|
||||
|
||||
return buildAnnotatedString {
|
||||
var startIndex = 0
|
||||
val lowerText = text.lowercase()
|
||||
val lowerQuery = query.lowercase()
|
||||
|
||||
while (startIndex < text.length) {
|
||||
val matchIndex = lowerText.indexOf(lowerQuery, startIndex)
|
||||
if (matchIndex == -1) {
|
||||
append(text.substring(startIndex))
|
||||
break
|
||||
}
|
||||
|
||||
// Append text before the match
|
||||
if (matchIndex > startIndex) {
|
||||
append(text.substring(startIndex, matchIndex))
|
||||
}
|
||||
|
||||
// Append the match with yellow background
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
background = highlightColor,
|
||||
color = CatppuccinMocha.Crust,
|
||||
)
|
||||
) {
|
||||
append(text.substring(matchIndex, matchIndex + query.length))
|
||||
}
|
||||
|
||||
startIndex = matchIndex + query.length
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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 = 48.dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (!imageUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = name,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
val colors = listOf(
|
||||
Color(0xFFF38BA8), // Red
|
||||
Color(0xFFFAB387), // Peach
|
||||
Color(0xFFF9E2AF), // Yellow
|
||||
Color(0xFFA6E3A1), // Green
|
||||
Color(0xFF89DCEB), // Sky
|
||||
Color(0xFF89B4FA), // Blue
|
||||
Color(0xFFCBA6F7), // Mauve
|
||||
Color(0xFFF5C2E7), // Pink
|
||||
)
|
||||
val color = colors[name.hashCode().absoluteValue % colors.size]
|
||||
val initials = name
|
||||
.split(" ")
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.joinToString("") { it.first().uppercase() }
|
||||
.ifEmpty { "?" }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = initials,
|
||||
color = Color(0xFF1E1E2E), // CatppuccinMocha.Base
|
||||
fontSize = (size.value * 0.4).sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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)
|
||||
.border(2.dp, CatppuccinMocha.Base, CircleShape)
|
||||
.background(CatppuccinMocha.Green, CircleShape)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.kecalek.chat.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
/**
|
||||
* Unread message count badge.
|
||||
* Shows a Lavender circle with Base-colored count text.
|
||||
* Displays "99+" for counts exceeding 99.
|
||||
* Hidden (emits nothing) when count is 0.
|
||||
*/
|
||||
@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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package com.kecalek.chat.ui.conversations
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.kecalek.chat.data.model.Invitation
|
||||
import com.kecalek.chat.ui.navigation.Routes
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
/**
|
||||
* Main conversation list screen. Displays pending invitations,
|
||||
* a searchable list of conversations sorted by favorites then last message time,
|
||||
* and a FAB for creating new conversations.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConversationListScreen(
|
||||
navController: NavController,
|
||||
viewModel: ConversationListVM = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
var showNewConversationSheet by remember { mutableStateOf(false) }
|
||||
|
||||
// Navigate to chat when a DM/group is created or found
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.navigateToChat.collect { conversationId ->
|
||||
navController.navigate(Routes.chat(conversationId))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: favorites first, then by lastMessageTime descending
|
||||
val sortedConversations = remember(uiState.conversations) {
|
||||
uiState.conversations.sortedWith(
|
||||
compareByDescending<com.kecalek.chat.data.model.Conversation> { it.isFavorite }
|
||||
.thenByDescending { it.lastMessageTime }
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
val filteredConversations = remember(sortedConversations, searchQuery) {
|
||||
if (searchQuery.isBlank()) {
|
||||
sortedConversations
|
||||
} else {
|
||||
sortedConversations.filter { conversation ->
|
||||
conversation.displayName(uiState.currentUserId)
|
||||
.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show errors as Snackbar
|
||||
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
||||
LaunchedEffect(uiState.error) {
|
||||
uiState.error?.let { error ->
|
||||
snackbarHostState.showSnackbar(error)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = {
|
||||
androidx.compose.material3.SnackbarHost(hostState = snackbarHostState)
|
||||
},
|
||||
containerColor = CatppuccinMocha.Base,
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Kecalek",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
ConnectionIndicator(isConnected = true) // TODO: wire to real state
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showSearch = !showSearch }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = "Search",
|
||||
tint = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { navController.navigate(Routes.SETTINGS) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
contentDescription = "Settings",
|
||||
tint = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = CatppuccinMocha.Mantle,
|
||||
),
|
||||
)
|
||||
|
||||
// Search bar (animated visibility)
|
||||
AnimatedVisibility(
|
||||
visible = showSearch,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
TextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { viewModel.onSearchQueryChanged(it) },
|
||||
placeholder = { Text("Search conversations...") },
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = CatppuccinMocha.Surface0,
|
||||
unfocusedContainerColor = CatppuccinMocha.Surface0,
|
||||
focusedTextColor = CatppuccinMocha.Text,
|
||||
unfocusedTextColor = CatppuccinMocha.Text,
|
||||
cursorColor = CatppuccinMocha.Lavender,
|
||||
focusedIndicatorColor = CatppuccinMocha.Lavender,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedPlaceholderColor = CatppuccinMocha.Overlay1,
|
||||
unfocusedPlaceholderColor = CatppuccinMocha.Overlay1,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(CatppuccinMocha.Mantle),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { showNewConversationSheet = true },
|
||||
containerColor = CatppuccinMocha.Lavender,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = "New conversation",
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = uiState.isLoading,
|
||||
onRefresh = { viewModel.refresh() },
|
||||
state = pullToRefreshState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
if (filteredConversations.isEmpty() && uiState.invitations.isEmpty() && !uiState.isLoading) {
|
||||
// Empty state
|
||||
EmptyConversationsState(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
// Invitations section
|
||||
if (uiState.invitations.isNotEmpty()) {
|
||||
item(key = "invitations_header") {
|
||||
Text(
|
||||
text = "Pending Invitations",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = CatppuccinMocha.Peach,
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
top = 12.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
item(key = "invitations_row") {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(
|
||||
items = uiState.invitations,
|
||||
key = { it.id },
|
||||
) { invitation ->
|
||||
InvitationCard(
|
||||
invitation = invitation,
|
||||
onAccept = { viewModel.acceptInvitation(invitation.id) },
|
||||
onDecline = { viewModel.declineInvitation(invitation.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Conversations header
|
||||
if (filteredConversations.isNotEmpty()) {
|
||||
item(key = "conversations_header") {
|
||||
Text(
|
||||
text = "Chats",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
top = 8.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation rows
|
||||
items(
|
||||
items = filteredConversations,
|
||||
key = { it.id },
|
||||
) { conversation ->
|
||||
ConversationRow(
|
||||
conversation = conversation,
|
||||
currentUserId = uiState.currentUserId,
|
||||
isOnline = conversation.dmPartnerId(uiState.currentUserId)
|
||||
?.let { it in uiState.onlineUsers } == true,
|
||||
lastMessagePreview = null, // TODO: wire to last message text
|
||||
onClick = {
|
||||
navController.navigate(Routes.chat(conversation.id))
|
||||
},
|
||||
onToggleFavorite = { viewModel.toggleFavorite(conversation.id) },
|
||||
onMarkAsRead = { viewModel.markAsRead(conversation.id) },
|
||||
)
|
||||
HorizontalDivider(
|
||||
color = CatppuccinMocha.Surface0,
|
||||
modifier = Modifier.padding(start = 76.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New conversation bottom sheet
|
||||
if (showNewConversationSheet) {
|
||||
NewConversationSheet(
|
||||
onDismiss = { showNewConversationSheet = false },
|
||||
onCreateDm = { email ->
|
||||
showNewConversationSheet = false
|
||||
viewModel.createDm(email)
|
||||
},
|
||||
onCreateGroup = { name, emails ->
|
||||
showNewConversationSheet = false
|
||||
viewModel.createGroup(name, emails)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small colored dot indicating WebSocket / server connection status.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConnectionIndicator(
|
||||
isConnected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(8.dp)
|
||||
.background(
|
||||
color = if (isConnected) CatppuccinMocha.Green else CatppuccinMocha.Red,
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitation card shown in the horizontal scrollable row.
|
||||
*/
|
||||
@Composable
|
||||
private fun InvitationCard(
|
||||
invitation: Invitation,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.width(260.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = CatppuccinMocha.Peach.copy(alpha = 0.15f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = invitation.conversationName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = CatppuccinMocha.Peach,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "Invited by ${invitation.invitedByUsername}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = onAccept,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Green,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text("Accept", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onDecline,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
"Decline",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = CatppuccinMocha.Red,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displayed when there are no conversations and no invitations.
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyConversationsState(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ChatBubbleOutline,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Overlay0,
|
||||
modifier = Modifier.size(72.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "No conversations yet",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Tap + to start a new chat",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.kecalek.chat.ui.conversations
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.kecalek.chat.core.SessionManager
|
||||
import com.kecalek.chat.data.model.Conversation
|
||||
import com.kecalek.chat.data.model.ConversationMember
|
||||
import com.kecalek.chat.data.model.Invitation
|
||||
import com.kecalek.chat.network.ServerApi
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ConversationListState(
|
||||
val conversations: List<Conversation> = emptyList(),
|
||||
val invitations: List<Invitation> = emptyList(),
|
||||
val onlineUsers: Set<String> = emptySet(),
|
||||
val searchQuery: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val currentUserId: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ConversationListVM @Inject constructor(
|
||||
private val api: ServerApi,
|
||||
private val sessionManager: SessionManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ConversationListState())
|
||||
val uiState: StateFlow<ConversationListState> = _uiState.asStateFlow()
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
/** Emits conversation ID to navigate to after create/find. */
|
||||
private val _navigateToChat = MutableSharedFlow<String>()
|
||||
val navigateToChat: SharedFlow<String> = _navigateToChat.asSharedFlow()
|
||||
|
||||
init {
|
||||
val userId = sessionManager.currentSession?.userId ?: ""
|
||||
_uiState.update { it.copy(currentUserId = userId) }
|
||||
loadConversations()
|
||||
loadInvitations()
|
||||
}
|
||||
|
||||
fun onSearchQueryChanged(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun loadConversations() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
try {
|
||||
val resp = api.listConversations()
|
||||
if (resp.isOk) {
|
||||
val jsonArray = resp.data.optJSONArray("conversations")
|
||||
val conversations = mutableListOf<Conversation>()
|
||||
if (jsonArray != null) {
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
conversations.add(parseConversation(jsonArray.getJSONObject(i)))
|
||||
}
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(conversations = conversations, isLoading = false, error = null)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = resp.errorMessage)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "loadConversations failed", e)
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Failed to load conversations: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadInvitations() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val resp = api.listInvitations()
|
||||
if (resp.isOk) {
|
||||
val jsonArray = resp.data.optJSONArray("invitations")
|
||||
val invitations = mutableListOf<Invitation>()
|
||||
if (jsonArray != null) {
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val obj = jsonArray.getJSONObject(i)
|
||||
invitations.add(
|
||||
Invitation(
|
||||
id = obj.getString("invitation_id"),
|
||||
conversationId = obj.getString("conversation_id"),
|
||||
conversationName = obj.optString("conversation_name", "Group"),
|
||||
invitedBy = obj.optString("invited_by", ""),
|
||||
invitedByUsername = obj.optString("invited_by_username", "Unknown"),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
_uiState.update { it.copy(invitations = invitations) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "loadInvitations failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptInvitation(id: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val invitation = _uiState.value.invitations.find { it.id == id } ?: return@launch
|
||||
val resp = api.acceptInvitation(invitation.conversationId)
|
||||
if (resp.isOk) {
|
||||
loadInvitations()
|
||||
loadConversations()
|
||||
} else {
|
||||
_uiState.update { it.copy(error = resp.errorMessage) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "acceptInvitation failed", e)
|
||||
_uiState.update { it.copy(error = "Failed to accept: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun declineInvitation(id: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val invitation = _uiState.value.invitations.find { it.id == id } ?: return@launch
|
||||
val resp = api.declineInvitation(invitation.conversationId)
|
||||
if (resp.isOk) {
|
||||
loadInvitations()
|
||||
} else {
|
||||
_uiState.update { it.copy(error = resp.errorMessage) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "declineInvitation failed", e)
|
||||
_uiState.update { it.copy(error = "Failed to decline: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createDm(email: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
// 1. Try to find existing DM conversation
|
||||
val findResp = api.findConversation(email)
|
||||
if (findResp.isOk && !findResp.data.isNull("conversation_id")) {
|
||||
val convId = findResp.data.getString("conversation_id")
|
||||
if (convId.isNotEmpty()) {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_navigateToChat.emit(convId)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Not found — create a new conversation
|
||||
val createResp = api.createConversation(members = listOf(email))
|
||||
if (createResp.isOk) {
|
||||
val convId = createResp.data.getString("conversation_id")
|
||||
loadConversations()
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_navigateToChat.emit(convId)
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = createResp.errorMessage)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "createDm failed", e)
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Failed to create chat: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createGroup(name: String, memberEmails: List<String>) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val resp = api.createConversation(members = memberEmails, name = name)
|
||||
if (resp.isOk) {
|
||||
val convId = resp.data.getString("conversation_id")
|
||||
loadConversations()
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_navigateToChat.emit(convId)
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = resp.errorMessage)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "createGroup failed", e)
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Failed to create group: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavorite(conversationId: String) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
conversations = state.conversations.map { conv ->
|
||||
if (conv.id == conversationId) conv.copy(isFavorite = !conv.isFavorite)
|
||||
else conv
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsRead(conversationId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val resp = api.markConversationRead(conversationId)
|
||||
if (resp.isOk) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
conversations = state.conversations.map { conv ->
|
||||
if (conv.id == conversationId) conv.copy(unreadCount = 0)
|
||||
else conv
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "markAsRead failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
loadConversations()
|
||||
loadInvitations()
|
||||
}
|
||||
|
||||
// ===== Parsing helpers =====
|
||||
|
||||
private fun parseConversation(json: JSONObject): Conversation {
|
||||
val membersArray = json.optJSONArray("members")
|
||||
val members = mutableListOf<ConversationMember>()
|
||||
if (membersArray != null) {
|
||||
for (i in 0 until membersArray.length()) {
|
||||
val m = membersArray.getJSONObject(i)
|
||||
members.add(
|
||||
ConversationMember(
|
||||
userId = m.getString("user_id"),
|
||||
username = m.optString("username", "Unknown"),
|
||||
email = m.optString("email", ""),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Conversation(
|
||||
id = json.getString("conversation_id"),
|
||||
name = json.optString("name", null),
|
||||
members = members,
|
||||
createdBy = json.optString("created_by", null),
|
||||
unreadCount = json.optInt("unread_count", 0),
|
||||
lastMessageTime = parseIsoDate(json.optString("last_message_time", null)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseIsoDate(dateStr: String?): Date? {
|
||||
if (dateStr.isNullOrEmpty()) return null
|
||||
return try {
|
||||
val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
|
||||
fmt.parse(dateStr)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConversationListVM"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.kecalek.chat.ui.conversations
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Verified
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kecalek.chat.data.model.Conversation
|
||||
import com.kecalek.chat.ui.components.CircularAvatar
|
||||
import com.kecalek.chat.ui.components.OnlineDot
|
||||
import com.kecalek.chat.ui.components.UnreadBadge
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Single row in the conversation list. Shows avatar (with optional online dot),
|
||||
* conversation name, last message preview, timestamp, unread badge, and favorite star.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ConversationRow(
|
||||
conversation: Conversation,
|
||||
currentUserId: String,
|
||||
isOnline: Boolean,
|
||||
lastMessagePreview: String?,
|
||||
isVerified: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onMarkAsRead: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = { showContextMenu = true },
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Avatar with optional online dot
|
||||
Box {
|
||||
CircularAvatar(
|
||||
imageUrl = conversation.avatarFile,
|
||||
name = conversation.displayName(currentUserId),
|
||||
size = 48.dp,
|
||||
)
|
||||
if (!conversation.isGroup && isOnline) {
|
||||
OnlineDot(
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Center content: name + preview
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// Top row: name + favorite star + verified badge
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = conversation.displayName(currentUserId),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = if (conversation.unreadCount > 0) FontWeight.Bold else FontWeight.Medium,
|
||||
color = CatppuccinMocha.Text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (conversation.isFavorite) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Star,
|
||||
contentDescription = "Favorite",
|
||||
tint = CatppuccinMocha.Yellow,
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp)
|
||||
.size(14.dp),
|
||||
)
|
||||
}
|
||||
if (isVerified && !conversation.isGroup) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
contentDescription = "Verified",
|
||||
tint = CatppuccinMocha.Green,
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp)
|
||||
.size(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom row: last message preview
|
||||
if (!lastMessagePreview.isNullOrBlank()) {
|
||||
Text(
|
||||
text = lastMessagePreview,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Right side: timestamp + unread badge
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
conversation.lastMessageTime?.let { time ->
|
||||
Text(
|
||||
text = formatTimestamp(time),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
if (conversation.unreadCount > 0) {
|
||||
UnreadBadge(
|
||||
count = conversation.unreadCount,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu on long-press
|
||||
DropdownMenu(
|
||||
expanded = showContextMenu,
|
||||
onDismissRequest = { showContextMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Mark as read") },
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onMarkAsRead()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
if (conversation.isFavorite) "Remove from favorites"
|
||||
else "Add to favorites"
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showContextMenu = false
|
||||
onToggleFavorite()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a [Date] into a human-readable timestamp for the conversation list.
|
||||
* - Today: "HH:mm"
|
||||
* - Yesterday: "Yesterday"
|
||||
* - This week: day name (e.g., "Mon")
|
||||
* - Older: "dd/MM/yy"
|
||||
*/
|
||||
private fun formatTimestamp(date: Date): String {
|
||||
val now = Calendar.getInstance()
|
||||
val then = Calendar.getInstance().apply { time = date }
|
||||
|
||||
return when {
|
||||
isSameDay(now, then) -> {
|
||||
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
|
||||
}
|
||||
isYesterday(now, then) -> {
|
||||
"Yesterday"
|
||||
}
|
||||
isSameWeek(now, then) -> {
|
||||
SimpleDateFormat("EEE", Locale.getDefault()).format(date)
|
||||
}
|
||||
else -> {
|
||||
SimpleDateFormat("dd/MM/yy", Locale.getDefault()).format(date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSameDay(a: Calendar, b: Calendar): Boolean =
|
||||
a.get(Calendar.YEAR) == b.get(Calendar.YEAR) &&
|
||||
a.get(Calendar.DAY_OF_YEAR) == b.get(Calendar.DAY_OF_YEAR)
|
||||
|
||||
private fun isYesterday(now: Calendar, then: Calendar): Boolean {
|
||||
val yesterday = now.clone() as Calendar
|
||||
yesterday.add(Calendar.DAY_OF_YEAR, -1)
|
||||
return isSameDay(yesterday, then)
|
||||
}
|
||||
|
||||
private fun isSameWeek(now: Calendar, then: Calendar): Boolean =
|
||||
now.get(Calendar.YEAR) == then.get(Calendar.YEAR) &&
|
||||
now.get(Calendar.WEEK_OF_YEAR) == then.get(Calendar.WEEK_OF_YEAR)
|
||||
@@ -0,0 +1,322 @@
|
||||
package com.kecalek.chat.ui.conversations
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.InputChip
|
||||
import androidx.compose.material3.InputChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.TabRowDefaults
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
/**
|
||||
* Modal bottom sheet for creating a new DM or group conversation.
|
||||
* Contains two tabs: "Direct Message" and "Create Group".
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun NewConversationSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onCreateDm: (email: String) -> Unit,
|
||||
onCreateGroup: (name: String, memberEmails: List<String>) -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = CatppuccinMocha.Mantle,
|
||||
contentColor = CatppuccinMocha.Text,
|
||||
) {
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
val tabs = listOf("Direct Message", "Create Group")
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
) {
|
||||
// Title
|
||||
Text(
|
||||
text = "New Conversation",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = CatppuccinMocha.Text,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
// Tabs
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
containerColor = CatppuccinMocha.Mantle,
|
||||
contentColor = CatppuccinMocha.Text,
|
||||
indicator = { tabPositions ->
|
||||
if (selectedTab < tabPositions.size) {
|
||||
TabRowDefaults.SecondaryIndicator(
|
||||
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTab]),
|
||||
color = CatppuccinMocha.Lavender,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = {
|
||||
Text(
|
||||
text = title,
|
||||
color = if (selectedTab == index) CatppuccinMocha.Lavender
|
||||
else CatppuccinMocha.Subtext0,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
when (selectedTab) {
|
||||
0 -> DmTab(onCreateDm = onCreateDm)
|
||||
1 -> GroupTab(onCreateGroup = onCreateGroup)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DmTab(
|
||||
onCreateDm: (email: String) -> Unit,
|
||||
) {
|
||||
var email by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = {
|
||||
email = it
|
||||
error = null
|
||||
},
|
||||
label = { Text("Email address") },
|
||||
placeholder = { Text("user@example.com") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (email.isNotBlank() && email.contains("@")) {
|
||||
onCreateDm(email.trim())
|
||||
} else {
|
||||
error = "Please enter a valid email"
|
||||
}
|
||||
},
|
||||
),
|
||||
isError = error != null,
|
||||
supportingText = error?.let { { Text(it) } },
|
||||
colors = outlinedFieldColors(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (email.isNotBlank() && email.contains("@")) {
|
||||
onCreateDm(email.trim())
|
||||
} else {
|
||||
error = "Please enter a valid email"
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Lavender,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Start Chat")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun GroupTab(
|
||||
onCreateGroup: (name: String, memberEmails: List<String>) -> Unit,
|
||||
) {
|
||||
var groupName by remember { mutableStateOf("") }
|
||||
var memberEmail by remember { mutableStateOf("") }
|
||||
val memberEmails = remember { mutableStateListOf<String>() }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column {
|
||||
// Group name
|
||||
OutlinedTextField(
|
||||
value = groupName,
|
||||
onValueChange = { groupName = it },
|
||||
label = { Text("Group name") },
|
||||
placeholder = { Text("My Group") },
|
||||
singleLine = true,
|
||||
colors = outlinedFieldColors(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Add member email
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = memberEmail,
|
||||
onValueChange = {
|
||||
memberEmail = it
|
||||
error = null
|
||||
},
|
||||
label = { Text("Member email") },
|
||||
placeholder = { Text("user@example.com") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { addMember(memberEmail, memberEmails) { memberEmail = ""; error = null } },
|
||||
),
|
||||
isError = error != null,
|
||||
supportingText = error?.let { { Text(it) } },
|
||||
colors = outlinedFieldColors(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (memberEmail.isNotBlank() && memberEmail.contains("@")) {
|
||||
if (memberEmails.contains(memberEmail.trim())) {
|
||||
error = "Already added"
|
||||
} else {
|
||||
memberEmails.add(memberEmail.trim())
|
||||
memberEmail = ""
|
||||
error = null
|
||||
}
|
||||
} else {
|
||||
error = "Invalid email"
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) {
|
||||
Text("Add")
|
||||
}
|
||||
}
|
||||
|
||||
// Member chips
|
||||
if (memberEmails.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
memberEmails.forEach { email ->
|
||||
InputChip(
|
||||
selected = false,
|
||||
onClick = { memberEmails.remove(email) },
|
||||
label = { Text(email, style = MaterialTheme.typography.bodySmall) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Remove $email",
|
||||
)
|
||||
},
|
||||
colors = InputChipDefaults.inputChipColors(
|
||||
containerColor = CatppuccinMocha.Surface1,
|
||||
labelColor = CatppuccinMocha.Text,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Create button
|
||||
Button(
|
||||
onClick = {
|
||||
if (groupName.isNotBlank() && memberEmails.isNotEmpty()) {
|
||||
onCreateGroup(groupName.trim(), memberEmails.toList())
|
||||
}
|
||||
},
|
||||
enabled = groupName.isNotBlank() && memberEmails.isNotEmpty(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Lavender,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Create Group")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMember(
|
||||
email: String,
|
||||
list: MutableList<String>,
|
||||
onSuccess: () -> Unit,
|
||||
) {
|
||||
val trimmed = email.trim()
|
||||
if (trimmed.isNotBlank() && trimmed.contains("@") && !list.contains(trimmed)) {
|
||||
list.add(trimmed)
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun outlinedFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = CatppuccinMocha.Lavender,
|
||||
unfocusedBorderColor = CatppuccinMocha.Surface2,
|
||||
cursorColor = CatppuccinMocha.Lavender,
|
||||
focusedLabelColor = CatppuccinMocha.Lavender,
|
||||
unfocusedLabelColor = CatppuccinMocha.Subtext0,
|
||||
focusedTextColor = CatppuccinMocha.Text,
|
||||
unfocusedTextColor = CatppuccinMocha.Text,
|
||||
)
|
||||
@@ -0,0 +1,329 @@
|
||||
package com.kecalek.chat.ui.devices
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
/**
|
||||
* Device management screen showing the user's linked devices.
|
||||
* Current device is highlighted. Other devices can be removed
|
||||
* with a confirmation dialog.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DeviceListScreen(
|
||||
navController: NavController,
|
||||
viewModel: DeviceViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
var deviceToRemove by remember { mutableStateOf<Device?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadDevices()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("My Devices") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = CatppuccinMocha.Mantle,
|
||||
titleContentColor = CatppuccinMocha.Text,
|
||||
navigationIconContentColor = CatppuccinMocha.Text,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = CatppuccinMocha.Base,
|
||||
) { padding ->
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(color = CatppuccinMocha.Lavender)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Header icon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Devices,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = CatppuccinMocha.Lavender,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// -- Device list --
|
||||
items(
|
||||
items = uiState.devices,
|
||||
key = { it.id },
|
||||
) { device ->
|
||||
DeviceCard(
|
||||
device = device,
|
||||
onRemove = if (!device.isCurrentDevice) {
|
||||
{ deviceToRemove = device }
|
||||
} else null,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// -- Info text --
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider(color = CatppuccinMocha.Surface1)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Removing a device will end its session. The device will need to re-authenticate to access your account.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
// -- Error display --
|
||||
uiState.error?.let { error ->
|
||||
item {
|
||||
Text(
|
||||
text = error,
|
||||
color = CatppuccinMocha.Red,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Remove device confirmation dialog --
|
||||
deviceToRemove?.let { device ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deviceToRemove = null },
|
||||
title = {
|
||||
Text(
|
||||
text = "Remove Device",
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = "Remove \"${device.name ?: device.id.take(8)}\"? This will end the session on that device.",
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.removeDevice(device.id)
|
||||
deviceToRemove = null
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = "Remove",
|
||||
color = CatppuccinMocha.Red,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deviceToRemove = null }) {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
}
|
||||
},
|
||||
containerColor = CatppuccinMocha.Surface0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceCard(
|
||||
device: Device,
|
||||
onRemove: (() -> Unit)?,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (device.isCurrentDevice)
|
||||
CatppuccinMocha.Lavender.copy(alpha = 0.1f)
|
||||
else
|
||||
CatppuccinMocha.Surface0,
|
||||
),
|
||||
border = if (device.isCurrentDevice) {
|
||||
androidx.compose.foundation.BorderStroke(1.dp, CatppuccinMocha.Lavender.copy(alpha = 0.3f))
|
||||
} else null,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Device icon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (device.isCurrentDevice)
|
||||
CatppuccinMocha.Lavender.copy(alpha = 0.2f)
|
||||
else
|
||||
CatppuccinMocha.Surface1,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PhoneAndroid,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (device.isCurrentDevice)
|
||||
CatppuccinMocha.Lavender
|
||||
else
|
||||
CatppuccinMocha.Subtext0,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Device name or truncated ID
|
||||
Text(
|
||||
text = device.name ?: device.id.take(8),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = CatppuccinMocha.Text,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (device.isCurrentDevice) {
|
||||
Text(
|
||||
text = "(This device)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = CatppuccinMocha.Lavender,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Device ID (truncated, monospace)
|
||||
Text(
|
||||
text = "ID: ${device.id.take(12)}...",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
|
||||
// Last seen
|
||||
Text(
|
||||
text = "Last seen: ${device.lastSeen}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
}
|
||||
|
||||
// Remove button (only for non-current devices)
|
||||
if (onRemove != null) {
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Remove device",
|
||||
tint = CatppuccinMocha.Red,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.kecalek.chat.ui.devices
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
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 for device management operations
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DeviceListState())
|
||||
val uiState: StateFlow<DeviceListState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadDevices() {
|
||||
// TODO: Load devices from ChatClient / server
|
||||
// 1. ChatClient.get_devices() or similar
|
||||
// 2. Identify current device by stored device_id
|
||||
// 3. Sort: current device first, then by last seen
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
// TODO: actual device list loading
|
||||
delay(0) // placeholder
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load devices",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeDevice(deviceId: String) {
|
||||
// TODO: Remove device via ChatClient
|
||||
// 1. ChatClient.remove_device(deviceId)
|
||||
// 2. On success: remove from local list
|
||||
// 3. On failure: show error
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
// TODO: actual device removal
|
||||
delay(0) // placeholder
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
isLoading = false,
|
||||
devices = current.devices.filter { it.id != deviceId },
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to remove device",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
192
app/src/main/java/com/kecalek/chat/ui/groups/CreateGroupSheet.kt
Normal file
192
app/src/main/java/com/kecalek/chat/ui/groups/CreateGroupSheet.kt
Normal file
@@ -0,0 +1,192 @@
|
||||
package com.kecalek.chat.ui.groups
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
/**
|
||||
* Bottom sheet for creating a new group conversation.
|
||||
* Provides a group name field, email-based member addition,
|
||||
* and a chip list showing added members.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CreateGroupSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (groupName: String, memberEmails: List<String>) -> Unit,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
var groupName by remember { mutableStateOf("") }
|
||||
var emailInput by remember { mutableStateOf("") }
|
||||
val memberEmails = remember { mutableStateListOf<String>() }
|
||||
|
||||
val textFieldColors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = CatppuccinMocha.Lavender,
|
||||
unfocusedBorderColor = CatppuccinMocha.Surface2,
|
||||
cursorColor = CatppuccinMocha.Lavender,
|
||||
focusedLabelColor = CatppuccinMocha.Lavender,
|
||||
unfocusedLabelColor = CatppuccinMocha.Subtext0,
|
||||
focusedTextColor = CatppuccinMocha.Text,
|
||||
unfocusedTextColor = CatppuccinMocha.Text,
|
||||
)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = CatppuccinMocha.Surface0,
|
||||
contentColor = CatppuccinMocha.Text,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Create Group",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = CatppuccinMocha.Text,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// -- Group name --
|
||||
OutlinedTextField(
|
||||
value = groupName,
|
||||
onValueChange = { groupName = it },
|
||||
label = { Text("Group name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = textFieldColors,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// -- Add member by email --
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = emailInput,
|
||||
onValueChange = { emailInput = it },
|
||||
label = { Text("Member email") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = textFieldColors,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
val trimmed = emailInput.trim()
|
||||
if (trimmed.isNotEmpty() && trimmed !in memberEmails) {
|
||||
memberEmails.add(trimmed)
|
||||
emailInput = ""
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = "Add member",
|
||||
tint = CatppuccinMocha.Lavender,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// -- Member chips --
|
||||
if (memberEmails.isNotEmpty()) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
memberEmails.forEach { email ->
|
||||
AssistChip(
|
||||
onClick = { memberEmails.remove(email) },
|
||||
label = {
|
||||
Text(
|
||||
text = email,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Remove $email",
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = CatppuccinMocha.Surface1,
|
||||
labelColor = CatppuccinMocha.Text,
|
||||
trailingIconContentColor = CatppuccinMocha.Red,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// -- Create button --
|
||||
Button(
|
||||
onClick = { onCreate(groupName.trim(), memberEmails.toList()) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = groupName.isNotBlank() && memberEmails.isNotEmpty(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Lavender,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
disabledContainerColor = CatppuccinMocha.Surface2,
|
||||
disabledContentColor = CatppuccinMocha.Overlay0,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text("Create")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
547
app/src/main/java/com/kecalek/chat/ui/groups/GroupInfoScreen.kt
Normal file
547
app/src/main/java/com/kecalek/chat/ui/groups/GroupInfoScreen.kt
Normal file
@@ -0,0 +1,547 @@
|
||||
package com.kecalek.chat.ui.groups
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.kecalek.chat.data.model.ConversationMember
|
||||
import com.kecalek.chat.ui.navigation.Routes
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
/**
|
||||
* Group info / settings screen. Displays group avatar, name, members list,
|
||||
* and admin actions (add member, leave, delete group).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupInfoScreen(
|
||||
conversationId: String,
|
||||
navController: NavController,
|
||||
// In a real app, this would use a GroupViewModel via hiltViewModel()
|
||||
) {
|
||||
// TODO: Replace with ViewModel state
|
||||
var groupName by remember { mutableStateOf("Group Name") }
|
||||
var isEditing by remember { mutableStateOf(false) }
|
||||
val isCreator = true // TODO: Determine from ViewModel
|
||||
val currentUserId = "" // TODO: Get from session
|
||||
|
||||
val members = remember {
|
||||
listOf<ConversationMember>(
|
||||
// TODO: Load from ViewModel
|
||||
)
|
||||
}
|
||||
|
||||
var showLeaveDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var showAddMemberDialog by remember { mutableStateOf(false) }
|
||||
var memberToRemove by remember { mutableStateOf<ConversationMember?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Group Info") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = CatppuccinMocha.Mantle,
|
||||
titleContentColor = CatppuccinMocha.Text,
|
||||
navigationIconContentColor = CatppuccinMocha.Text,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = CatppuccinMocha.Base,
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// -- Group Avatar --
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CatppuccinMocha.Surface1)
|
||||
.then(
|
||||
if (isCreator) Modifier.clickable {
|
||||
// TODO: Open image picker for group avatar
|
||||
} else Modifier
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Group,
|
||||
contentDescription = "Group avatar",
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
if (isCreator) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CatppuccinMocha.Lavender),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CameraAlt,
|
||||
contentDescription = "Change group avatar",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = CatppuccinMocha.Base,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// -- Group Name (editable by creator) --
|
||||
item {
|
||||
if (isEditing && isCreator) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = groupName,
|
||||
onValueChange = { groupName = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = CatppuccinMocha.Lavender,
|
||||
unfocusedBorderColor = CatppuccinMocha.Surface2,
|
||||
cursorColor = CatppuccinMocha.Lavender,
|
||||
focusedTextColor = CatppuccinMocha.Text,
|
||||
unfocusedTextColor = CatppuccinMocha.Text,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(onClick = {
|
||||
isEditing = false
|
||||
// TODO: Save group name via ViewModel
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Save name",
|
||||
tint = CatppuccinMocha.Green,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = groupName,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
if (isCreator) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(onClick = { isEditing = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = "Edit group name",
|
||||
tint = CatppuccinMocha.Lavender,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Member count
|
||||
Text(
|
||||
text = "${members.size} members",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider(color = CatppuccinMocha.Surface1)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Section header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "Members",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = CatppuccinMocha.Text,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
// Add Member button
|
||||
IconButton(onClick = { showAddMemberDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = "Add member",
|
||||
tint = CatppuccinMocha.Lavender,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// -- Members List --
|
||||
items(
|
||||
items = members,
|
||||
key = { it.userId },
|
||||
) { member ->
|
||||
MemberRow(
|
||||
member = member,
|
||||
isCreator = false, // TODO: Check if member is creator
|
||||
isVerified = false, // TODO: Check verification status
|
||||
isSelf = member.userId == currentUserId,
|
||||
canRemove = isCreator && member.userId != currentUserId,
|
||||
onRemove = { memberToRemove = member },
|
||||
onTap = { navController.navigate(Routes.profile(member.userId)) },
|
||||
)
|
||||
}
|
||||
|
||||
// -- Action buttons --
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider(color = CatppuccinMocha.Surface1)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Leave Group
|
||||
OutlinedButton(
|
||||
onClick = { showLeaveDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = CatppuccinMocha.Red,
|
||||
),
|
||||
border = ButtonDefaults.outlinedButtonBorder.copy(
|
||||
brush = androidx.compose.ui.graphics.SolidColor(CatppuccinMocha.Red),
|
||||
),
|
||||
) {
|
||||
Text("Leave Group")
|
||||
}
|
||||
|
||||
// Delete Group (admin only)
|
||||
if (isCreator) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = { showDeleteDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Red,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text("Delete Group")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Confirmation Dialogs --
|
||||
|
||||
if (showLeaveDialog) {
|
||||
ConfirmationDialog(
|
||||
title = "Leave Group",
|
||||
message = "Are you sure you want to leave this group? You will no longer receive messages.",
|
||||
confirmText = "Leave",
|
||||
onConfirm = {
|
||||
showLeaveDialog = false
|
||||
// TODO: Leave group via ViewModel
|
||||
navController.popBackStack()
|
||||
},
|
||||
onDismiss = { showLeaveDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteDialog) {
|
||||
ConfirmationDialog(
|
||||
title = "Delete Group",
|
||||
message = "Are you sure you want to delete this group? This action cannot be undone. All members will be removed.",
|
||||
confirmText = "Delete",
|
||||
onConfirm = {
|
||||
showDeleteDialog = false
|
||||
// TODO: Delete group via ViewModel
|
||||
navController.popBackStack()
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
memberToRemove?.let { member ->
|
||||
ConfirmationDialog(
|
||||
title = "Remove Member",
|
||||
message = "Remove ${member.username} from this group?",
|
||||
confirmText = "Remove",
|
||||
onConfirm = {
|
||||
memberToRemove = null
|
||||
// TODO: Remove member via ViewModel
|
||||
},
|
||||
onDismiss = { memberToRemove = null },
|
||||
)
|
||||
}
|
||||
|
||||
if (showAddMemberDialog) {
|
||||
AddMemberDialog(
|
||||
onAdd = { email ->
|
||||
showAddMemberDialog = false
|
||||
// TODO: Add member via ViewModel
|
||||
},
|
||||
onDismiss = { showAddMemberDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberRow(
|
||||
member: ConversationMember,
|
||||
isCreator: Boolean,
|
||||
isVerified: Boolean,
|
||||
isSelf: Boolean,
|
||||
canRemove: Boolean,
|
||||
onRemove: () -> Unit,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onTap)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Avatar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CatppuccinMocha.Surface1),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = member.username,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
if (isCreator) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = "Admin",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = CatppuccinMocha.Yellow,
|
||||
)
|
||||
}
|
||||
if (isVerified && !isSelf) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Verified",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = CatppuccinMocha.Green,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = member.email,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
}
|
||||
|
||||
if (canRemove) {
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Remove member",
|
||||
tint = CatppuccinMocha.Red,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmationDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
confirmText: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = message,
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(
|
||||
text = confirmText,
|
||||
color = CatppuccinMocha.Red,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
}
|
||||
},
|
||||
containerColor = CatppuccinMocha.Surface0,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddMemberDialog(
|
||||
onAdd: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var email by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = "Add Member",
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("Email address") },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = CatppuccinMocha.Lavender,
|
||||
unfocusedBorderColor = CatppuccinMocha.Surface2,
|
||||
cursorColor = CatppuccinMocha.Lavender,
|
||||
focusedLabelColor = CatppuccinMocha.Lavender,
|
||||
unfocusedLabelColor = CatppuccinMocha.Subtext0,
|
||||
focusedTextColor = CatppuccinMocha.Text,
|
||||
unfocusedTextColor = CatppuccinMocha.Text,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onAdd(email) },
|
||||
enabled = email.isNotBlank(),
|
||||
) {
|
||||
Text(
|
||||
text = "Add",
|
||||
color = if (email.isNotBlank()) CatppuccinMocha.Lavender else CatppuccinMocha.Overlay0,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
}
|
||||
},
|
||||
containerColor = CatppuccinMocha.Surface0,
|
||||
)
|
||||
}
|
||||
107
app/src/main/java/com/kecalek/chat/ui/groups/InvitationBanner.kt
Normal file
107
app/src/main/java/com/kecalek/chat/ui/groups/InvitationBanner.kt
Normal file
@@ -0,0 +1,107 @@
|
||||
package com.kecalek.chat.ui.groups
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
/**
|
||||
* Invitation card displayed in the conversation list when
|
||||
* the user has a pending group invitation.
|
||||
*/
|
||||
@Composable
|
||||
fun InvitationBanner(
|
||||
groupName: String,
|
||||
invitedBy: String,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = CatppuccinMocha.Surface0,
|
||||
),
|
||||
border = BorderStroke(1.dp, CatppuccinMocha.Peach),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = groupName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = CatppuccinMocha.Text,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "Invited by $invitedBy",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Accept button
|
||||
IconButton(
|
||||
onClick = onAccept,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = CatppuccinMocha.Green.copy(alpha = 0.15f),
|
||||
contentColor = CatppuccinMocha.Green,
|
||||
),
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Accept invitation",
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Decline button
|
||||
IconButton(
|
||||
onClick = onDecline,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = CatppuccinMocha.Red.copy(alpha = 0.15f),
|
||||
contentColor = CatppuccinMocha.Red,
|
||||
),
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Decline invitation",
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
158
app/src/main/java/com/kecalek/chat/ui/navigation/NavGraph.kt
Normal file
158
app/src/main/java/com/kecalek/chat/ui/navigation/NavGraph.kt
Normal file
@@ -0,0 +1,158 @@
|
||||
package com.kecalek.chat.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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
|
||||
import com.kecalek.chat.core.SessionManager
|
||||
import com.kecalek.chat.ui.auth.LoginScreen
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import com.kecalek.chat.ui.auth.PairingScreen
|
||||
import com.kecalek.chat.ui.auth.RegisterScreen
|
||||
import com.kecalek.chat.ui.chat.ChatScreen
|
||||
import com.kecalek.chat.ui.chat.ImageViewer
|
||||
import com.kecalek.chat.ui.conversations.ConversationListScreen
|
||||
import com.kecalek.chat.ui.devices.DeviceListScreen
|
||||
import com.kecalek.chat.ui.groups.GroupInfoScreen
|
||||
import com.kecalek.chat.ui.profile.EditProfileScreen
|
||||
import com.kecalek.chat.ui.profile.ProfileScreen
|
||||
import com.kecalek.chat.ui.settings.SettingsScreen
|
||||
import com.kecalek.chat.ui.verification.SafetyNumberScreen
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
|
||||
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}"
|
||||
|
||||
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/${URLEncoder.encode(imageUrl, "UTF-8")}"
|
||||
}
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface NavGraphEntryPoint {
|
||||
fun sessionManager(): SessionManager
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun KecalekNavGraph(
|
||||
navController: NavHostController = rememberNavController(),
|
||||
startDestination: String = Routes.LOGIN,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val entryPoint = EntryPointAccessors.fromApplication(context, NavGraphEntryPoint::class.java)
|
||||
val sessionManager = entryPoint.sessionManager()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
) {
|
||||
composable(Routes.LOGIN) {
|
||||
LoginScreen(navController = navController)
|
||||
}
|
||||
composable(Routes.REGISTER) {
|
||||
RegisterScreen(navController = navController)
|
||||
}
|
||||
composable(Routes.PAIRING) {
|
||||
PairingScreen(navController = navController)
|
||||
}
|
||||
composable(Routes.CONVERSATION_LIST) {
|
||||
ConversationListScreen(navController = navController)
|
||||
}
|
||||
composable(
|
||||
route = Routes.CHAT,
|
||||
arguments = listOf(navArgument("conversationId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val conversationId = backStackEntry.arguments?.getString("conversationId") ?: return@composable
|
||||
ChatScreen(
|
||||
conversationId = conversationId,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToGroupInfo = { navController.navigate(Routes.groupInfo(it)) },
|
||||
onNavigateToImageViewer = { navController.navigate(Routes.imageViewer(it)) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.GROUP_INFO,
|
||||
arguments = listOf(navArgument("conversationId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val conversationId = backStackEntry.arguments?.getString("conversationId") ?: return@composable
|
||||
GroupInfoScreen(
|
||||
conversationId = conversationId,
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.PROFILE,
|
||||
arguments = listOf(navArgument("userId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val userId = backStackEntry.arguments?.getString("userId") ?: return@composable
|
||||
ProfileScreen(
|
||||
userId = userId,
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
composable(Routes.EDIT_PROFILE) {
|
||||
EditProfileScreen(navController = navController)
|
||||
}
|
||||
composable(
|
||||
route = Routes.VERIFICATION,
|
||||
arguments = listOf(navArgument("userId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val userId = backStackEntry.arguments?.getString("userId") ?: return@composable
|
||||
SafetyNumberScreen(
|
||||
userId = userId,
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
composable(Routes.DEVICE_LIST) {
|
||||
DeviceListScreen(navController = navController)
|
||||
}
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsScreen(
|
||||
navController = navController,
|
||||
onLogout = {
|
||||
sessionManager.logout()
|
||||
navController.navigate(Routes.LOGIN) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.IMAGE_VIEWER,
|
||||
arguments = listOf(navArgument("imageUrl") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val imageUrl = URLDecoder.decode(
|
||||
backStackEntry.arguments?.getString("imageUrl") ?: return@composable,
|
||||
"UTF-8"
|
||||
)
|
||||
ImageViewer(
|
||||
imageUrl = imageUrl,
|
||||
onBack = { navController.popBackStack() },
|
||||
onDownload = { /* TODO */ },
|
||||
onShare = { /* TODO */ },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.kecalek.chat.ui.profile
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditProfileScreen(
|
||||
navController: NavController,
|
||||
viewModel: ProfileViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val profile = uiState.profile
|
||||
|
||||
var username by remember(profile) { mutableStateOf(profile?.username ?: "") }
|
||||
var phone by remember(profile) { mutableStateOf(profile?.phone ?: "") }
|
||||
var phoneVisible by remember(profile) { mutableStateOf(profile?.phoneVisible ?: true) }
|
||||
var location by remember(profile) { mutableStateOf(profile?.location ?: "") }
|
||||
var locationVisible by remember(profile) { mutableStateOf(profile?.locationVisible ?: true) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadProfile()
|
||||
}
|
||||
|
||||
val textFieldColors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = CatppuccinMocha.Lavender,
|
||||
unfocusedBorderColor = CatppuccinMocha.Surface2,
|
||||
cursorColor = CatppuccinMocha.Lavender,
|
||||
focusedLabelColor = CatppuccinMocha.Lavender,
|
||||
unfocusedLabelColor = CatppuccinMocha.Subtext0,
|
||||
focusedTextColor = CatppuccinMocha.Text,
|
||||
unfocusedTextColor = CatppuccinMocha.Text,
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Edit Profile") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = CatppuccinMocha.Mantle,
|
||||
titleContentColor = CatppuccinMocha.Text,
|
||||
navigationIconContentColor = CatppuccinMocha.Text,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = CatppuccinMocha.Base,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// -- Avatar picker --
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CatppuccinMocha.Surface1)
|
||||
.clickable {
|
||||
// TODO: Open image picker for avatar
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
// Camera overlay
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CatppuccinMocha.Lavender),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CameraAlt,
|
||||
contentDescription = "Change avatar",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = CatppuccinMocha.Base,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// -- Username field --
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("Username") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = textFieldColors,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// -- Email (read-only) --
|
||||
OutlinedTextField(
|
||||
value = profile?.email ?: "",
|
||||
onValueChange = {},
|
||||
label = { Text("Email") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = false,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = textFieldColors,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// -- Phone field with visibility toggle --
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it },
|
||||
label = { Text("Phone") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = textFieldColors,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
VisibilityToggle(
|
||||
label = "Phone visible to contacts",
|
||||
checked = phoneVisible,
|
||||
onCheckedChange = { phoneVisible = it },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// -- Location field with visibility toggle --
|
||||
OutlinedTextField(
|
||||
value = location,
|
||||
onValueChange = { location = it },
|
||||
label = { Text("Location") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = textFieldColors,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
VisibilityToggle(
|
||||
label = "Location visible to contacts",
|
||||
checked = locationVisible,
|
||||
onCheckedChange = { locationVisible = it },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// -- Save button --
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.updateProfile(
|
||||
phone = phone.ifBlank { null },
|
||||
location = location.ifBlank { null },
|
||||
phoneVisible = phoneVisible,
|
||||
locationVisible = locationVisible,
|
||||
)
|
||||
navController.popBackStack()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Lavender,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VisibilityToggle(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = CatppuccinMocha.Base,
|
||||
checkedTrackColor = CatppuccinMocha.Green,
|
||||
uncheckedThumbColor = CatppuccinMocha.Overlay0,
|
||||
uncheckedTrackColor = CatppuccinMocha.Surface1,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
344
app/src/main/java/com/kecalek/chat/ui/profile/ProfileScreen.kt
Normal file
344
app/src/main/java/com/kecalek/chat/ui/profile/ProfileScreen.kt
Normal file
@@ -0,0 +1,344 @@
|
||||
package com.kecalek.chat.ui.profile
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.kecalek.chat.ui.navigation.Routes
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
userId: String,
|
||||
navController: NavController,
|
||||
viewModel: ProfileViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(userId) {
|
||||
viewModel.loadProfile()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Profile") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (uiState.isOwnProfile) {
|
||||
IconButton(
|
||||
onClick = { navController.navigate(Routes.EDIT_PROFILE) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = "Edit Profile",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = CatppuccinMocha.Mantle,
|
||||
titleContentColor = CatppuccinMocha.Text,
|
||||
navigationIconContentColor = CatppuccinMocha.Text,
|
||||
actionIconContentColor = CatppuccinMocha.Lavender,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = CatppuccinMocha.Base,
|
||||
) { padding ->
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(color = CatppuccinMocha.Lavender)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// -- Avatar --
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CatppuccinMocha.Surface1),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// -- Username --
|
||||
Text(
|
||||
text = uiState.profile?.username ?: "Unknown",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// -- Email --
|
||||
Text(
|
||||
text = uiState.profile?.email ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
|
||||
// -- Phone (if visible) --
|
||||
val phone = uiState.profile?.phone
|
||||
if (!phone.isNullOrEmpty() && (uiState.isOwnProfile || uiState.profile?.phoneVisible == true)) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = phone,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
}
|
||||
|
||||
// -- Location (if visible) --
|
||||
val location = uiState.profile?.location
|
||||
if (!location.isNullOrEmpty() && (uiState.isOwnProfile || uiState.profile?.locationVisible == true)) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = location,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext0,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider(color = CatppuccinMocha.Surface1)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (uiState.isOwnProfile) {
|
||||
// ---- Own Profile Actions ----
|
||||
|
||||
// Key Rotation
|
||||
Button(
|
||||
onClick = { viewModel.rotateKeys() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Peach,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text("Key Rotation")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Authorize Device
|
||||
OutlinedButton(
|
||||
onClick = { navController.navigate(Routes.DEVICE_LIST) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = CatppuccinMocha.Lavender,
|
||||
),
|
||||
) {
|
||||
Text("Authorize Device")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Logout
|
||||
Button(
|
||||
onClick = { viewModel.logout() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Red,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text("Logout")
|
||||
}
|
||||
} else {
|
||||
// ---- Other User Profile ----
|
||||
|
||||
// Verification status badge
|
||||
VerificationBadge(status = uiState.verificationStatus)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// View Safety Number / Verify Identity
|
||||
Button(
|
||||
onClick = { navController.navigate(Routes.verification(userId)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Lavender,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Shield,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("View Safety Number")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Fingerprint display
|
||||
if (uiState.fingerprint.isNotEmpty()) {
|
||||
Text(
|
||||
text = uiState.fingerprint,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
),
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Send Message
|
||||
Button(
|
||||
onClick = {
|
||||
// TODO: Navigate to or create DM conversation
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Green,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text("Send Message")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Block User
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
// TODO: Block user confirmation
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = CatppuccinMocha.Red,
|
||||
),
|
||||
border = ButtonDefaults.outlinedButtonBorder.copy(
|
||||
brush = androidx.compose.ui.graphics.SolidColor(CatppuccinMocha.Red),
|
||||
),
|
||||
) {
|
||||
Text("Block User")
|
||||
}
|
||||
}
|
||||
|
||||
// -- Error display --
|
||||
uiState.error?.let { error ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = error,
|
||||
color = CatppuccinMocha.Red,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerificationBadge(status: String) {
|
||||
val (label, bgColor, textColor) = when (status) {
|
||||
"verified" -> Triple("Verified", CatppuccinMocha.Green, CatppuccinMocha.Base)
|
||||
"trusted" -> Triple("Trusted", CatppuccinMocha.Lavender, CatppuccinMocha.Base)
|
||||
else -> Triple("Not Verified", CatppuccinMocha.Surface1, CatppuccinMocha.Subtext1)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(bgColor)
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.kecalek.chat.ui.profile
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.kecalek.chat.data.model.UserProfile
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
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 UserRepository, ChatClient, SessionManager
|
||||
) : ViewModel() {
|
||||
|
||||
val userId: String = savedStateHandle["userId"] ?: ""
|
||||
|
||||
private val _uiState = MutableStateFlow(ProfileUiState())
|
||||
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadProfile() {
|
||||
// TODO: Load user profile from repository
|
||||
// 1. Determine if userId == current user -> isOwnProfile = true
|
||||
// 2. Fetch UserProfile from local DB or server
|
||||
// 3. If other user: fetch verification status and fingerprint
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
// TODO: actual profile loading
|
||||
delay(0) // placeholder
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Failed to load profile") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProfile(
|
||||
phone: String?,
|
||||
location: String?,
|
||||
phoneVisible: Boolean,
|
||||
locationVisible: Boolean,
|
||||
) {
|
||||
// TODO: Update user profile via repository / ChatClient
|
||||
// 1. ChatClient.update_profile(phone, location, phoneVisible, locationVisible)
|
||||
// 2. Update local DB
|
||||
// 3. Update UI state
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
// TODO: actual profile update
|
||||
delay(0) // placeholder
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Failed to update profile") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAvatar(imageBytes: ByteArray) {
|
||||
// TODO: Upload avatar via ChatClient
|
||||
// 1. ChatClient.upload_avatar(imageBytes)
|
||||
// 2. Update local profile with new avatar URL
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
// TODO: actual avatar upload
|
||||
delay(0) // placeholder
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Failed to update avatar") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rotateKeys() {
|
||||
// TODO: Rotate identity keys via ChatClient
|
||||
// 1. ChatClient.rotate_keys()
|
||||
// 2. Re-establish sessions with all contacts
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
// TODO: actual key rotation
|
||||
delay(0) // placeholder
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Key rotation failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
// TODO: Logout via ChatClient / SessionManager
|
||||
// 1. ChatClient.logout()
|
||||
// 2. Clear local session data
|
||||
// 3. Navigate to LoginScreen
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
// TODO: actual logout
|
||||
delay(0) // placeholder
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Logout failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
595
app/src/main/java/com/kecalek/chat/ui/settings/SettingsScreen.kt
Normal file
595
app/src/main/java/com/kecalek/chat/ui/settings/SettingsScreen.kt
Normal file
@@ -0,0 +1,595 @@
|
||||
package com.kecalek.chat.ui.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Password
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kecalek.chat.ui.components.ConfirmationDialog
|
||||
import com.kecalek.chat.ui.navigation.Routes
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
import com.kecalek.chat.util.Constants
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
navController: NavController,
|
||||
onLogout: () -> Unit = {},
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
// Server config state
|
||||
var serverHost by rememberSaveable { mutableStateOf(Constants.DEFAULT_HOST) }
|
||||
var serverPort by rememberSaveable { mutableStateOf(Constants.DEFAULT_PORT.toString()) }
|
||||
var useTls by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Privacy state
|
||||
var privacyLockEnabled by rememberSaveable { mutableStateOf(false) }
|
||||
var lockTimeoutLabel by rememberSaveable { mutableStateOf("30s") }
|
||||
|
||||
// Dialog state
|
||||
var showDeleteAccountDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showLogoutDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showKeyRotationDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val textFieldColors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = CatppuccinMocha.Text,
|
||||
unfocusedTextColor = CatppuccinMocha.Text,
|
||||
cursorColor = CatppuccinMocha.Lavender,
|
||||
focusedBorderColor = CatppuccinMocha.Lavender,
|
||||
unfocusedBorderColor = CatppuccinMocha.Surface2,
|
||||
focusedLabelColor = CatppuccinMocha.Lavender,
|
||||
unfocusedLabelColor = CatppuccinMocha.Subtext0,
|
||||
focusedContainerColor = CatppuccinMocha.Surface0,
|
||||
unfocusedContainerColor = CatppuccinMocha.Surface0,
|
||||
)
|
||||
|
||||
val switchColors = SwitchDefaults.colors(
|
||||
checkedThumbColor = CatppuccinMocha.Lavender,
|
||||
checkedTrackColor = CatppuccinMocha.Lavender.copy(alpha = 0.3f),
|
||||
uncheckedThumbColor = CatppuccinMocha.Overlay1,
|
||||
uncheckedTrackColor = CatppuccinMocha.Surface1,
|
||||
)
|
||||
|
||||
// -- Confirmation Dialogs --
|
||||
if (showDeleteAccountDialog) {
|
||||
ConfirmationDialog(
|
||||
title = "Delete Account",
|
||||
message = "This action is irreversible. All your messages, keys, and account data will be permanently deleted. Are you sure?",
|
||||
confirmText = "Delete Account",
|
||||
dismissText = "Cancel",
|
||||
isDestructive = true,
|
||||
onConfirm = {
|
||||
showDeleteAccountDialog = false
|
||||
// TODO: Trigger account deletion
|
||||
},
|
||||
onDismiss = { showDeleteAccountDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showLogoutDialog) {
|
||||
ConfirmationDialog(
|
||||
title = "Logout",
|
||||
message = "You will be signed out of this device. Your encryption keys will remain stored locally.",
|
||||
confirmText = "Logout",
|
||||
dismissText = "Cancel",
|
||||
isDestructive = false,
|
||||
onConfirm = {
|
||||
showLogoutDialog = false
|
||||
onLogout()
|
||||
},
|
||||
onDismiss = { showLogoutDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showKeyRotationDialog) {
|
||||
ConfirmationDialog(
|
||||
title = "Rotate Keys",
|
||||
message = "This will generate new pre-keys and signed pre-key. Existing sessions will continue working. New sessions will use the new keys.",
|
||||
confirmText = "Rotate",
|
||||
dismissText = "Cancel",
|
||||
isDestructive = false,
|
||||
onConfirm = {
|
||||
showKeyRotationDialog = false
|
||||
// TODO: Trigger key rotation
|
||||
},
|
||||
onDismiss = { showKeyRotationDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = CatppuccinMocha.Mantle,
|
||||
titleContentColor = CatppuccinMocha.Text,
|
||||
navigationIconContentColor = CatppuccinMocha.Text,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = CatppuccinMocha.Base,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// ============================================================
|
||||
// SERVER CONFIGURATION
|
||||
// ============================================================
|
||||
SectionHeader(title = "Server Configuration")
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Host
|
||||
OutlinedTextField(
|
||||
value = serverHost,
|
||||
onValueChange = { serverHost = it },
|
||||
label = { Text("Host") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = textFieldColors,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) },
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Port
|
||||
OutlinedTextField(
|
||||
value = serverPort,
|
||||
onValueChange = { newValue ->
|
||||
if (newValue.all { it.isDigit() } && newValue.length <= 5) {
|
||||
serverPort = newValue
|
||||
}
|
||||
},
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = textFieldColors,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { focusManager.clearFocus() },
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// TLS Toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Use TLS",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
)
|
||||
Switch(
|
||||
checked = useTls,
|
||||
onCheckedChange = { useTls = it },
|
||||
colors = switchColors,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Save button
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
// TODO: Persist server config & trigger reconnection
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Lavender,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
SectionDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// ============================================================
|
||||
// ACCOUNT
|
||||
// ============================================================
|
||||
SectionHeader(title = "Account")
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Change Username
|
||||
SettingsButton(
|
||||
text = "Change Username",
|
||||
icon = Icons.Default.Person,
|
||||
iconTint = CatppuccinMocha.Lavender,
|
||||
onClick = { /* TODO: Navigate to change username */ },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Change Password
|
||||
SettingsButton(
|
||||
text = "Change Password",
|
||||
icon = Icons.Default.Password,
|
||||
iconTint = CatppuccinMocha.Lavender,
|
||||
onClick = { /* TODO: Navigate to change password */ },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Key Rotation
|
||||
Button(
|
||||
onClick = { showKeyRotationDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Peach,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Sync,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Key Rotation")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Generate new pre-keys for forward secrecy",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 4.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// My Devices
|
||||
SettingsButton(
|
||||
text = "My Devices",
|
||||
icon = Icons.Default.Devices,
|
||||
iconTint = CatppuccinMocha.Lavender,
|
||||
onClick = { navController.navigate(Routes.DEVICE_LIST) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
SectionDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// ============================================================
|
||||
// PRIVACY
|
||||
// ============================================================
|
||||
SectionHeader(title = "Privacy")
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Privacy Lock toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Lavender,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Privacy Lock",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = privacyLockEnabled,
|
||||
onCheckedChange = { privacyLockEnabled = it },
|
||||
colors = switchColors,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Require password or biometric to unlock the app",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
modifier = Modifier.padding(start = 28.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Lock Timeout selector
|
||||
if (privacyLockEnabled) {
|
||||
Text(
|
||||
text = "Lock Timeout",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
modifier = Modifier.padding(start = 28.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf("30s", "1 min", "5 min").forEach { option ->
|
||||
val isSelected = lockTimeoutLabel == option
|
||||
Button(
|
||||
onClick = { lockTimeoutLabel = option },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isSelected) {
|
||||
CatppuccinMocha.Lavender
|
||||
} else {
|
||||
CatppuccinMocha.Surface1
|
||||
},
|
||||
contentColor = if (isSelected) {
|
||||
CatppuccinMocha.Base
|
||||
} else {
|
||||
CatppuccinMocha.Subtext1
|
||||
},
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Text(option, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
SectionDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// ============================================================
|
||||
// ABOUT
|
||||
// ============================================================
|
||||
SectionHeader(title = "About")
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Version
|
||||
Text(
|
||||
text = "Kecalek v0.8.5",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = CatppuccinMocha.Text,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// E2EE info
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Shield,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Green,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "End-to-end encrypted",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Green,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Signal Protocol info
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Key,
|
||||
contentDescription = null,
|
||||
tint = CatppuccinMocha.Overlay1,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Signal Protocol (X3DH + Double Ratchet)",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
color = CatppuccinMocha.Overlay1,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
SectionDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// ============================================================
|
||||
// DANGER ZONE
|
||||
// ============================================================
|
||||
SectionHeader(title = "Danger Zone", color = CatppuccinMocha.Red)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Delete Account
|
||||
Button(
|
||||
onClick = { showDeleteAccountDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CatppuccinMocha.Red,
|
||||
contentColor = CatppuccinMocha.Base,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DeleteForever,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Delete Account")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Logout
|
||||
OutlinedButton(
|
||||
onClick = { showLogoutDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = CatppuccinMocha.Red,
|
||||
),
|
||||
border = ButtonDefaults.outlinedButtonBorder.copy(
|
||||
brush = androidx.compose.ui.graphics.SolidColor(CatppuccinMocha.Red),
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Logout")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helper composables
|
||||
// ============================================================
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(
|
||||
title: String,
|
||||
color: androidx.compose.ui.graphics.Color = CatppuccinMocha.Lavender,
|
||||
) {
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = color,
|
||||
letterSpacing = 1.5.sp,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionDivider() {
|
||||
HorizontalDivider(color = CatppuccinMocha.Surface1)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsButton(
|
||||
text: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
iconTint: androidx.compose.ui.graphics.Color = CatppuccinMocha.Lavender,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = CatppuccinMocha.Text,
|
||||
),
|
||||
border = ButtonDefaults.outlinedButtonBorder.copy(
|
||||
brush = androidx.compose.ui.graphics.SolidColor(CatppuccinMocha.Surface2),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/src/main/java/com/kecalek/chat/ui/theme/Color.kt
Normal file
33
app/src/main/java/com/kecalek/chat/ui/theme/Color.kt
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.kecalek.chat.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
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)
|
||||
}
|
||||
40
app/src/main/java/com/kecalek/chat/ui/theme/Theme.kt
Normal file
40
app/src/main/java/com/kecalek/chat/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,40 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
64
app/src/main/java/com/kecalek/chat/ui/theme/Type.kt
Normal file
64
app/src/main/java/com/kecalek/chat/ui/theme/Type.kt
Normal file
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.kecalek.chat.ui.verification
|
||||
|
||||
import android.Manifest
|
||||
import android.util.Size
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.graphics.ClipOp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.NavController
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
/**
|
||||
* Full-screen QR code scanner screen using CameraX.
|
||||
* Displays a camera preview with a scan area overlay frame.
|
||||
* On successful scan, verifies the identity key and reports the result.
|
||||
*/
|
||||
@Composable
|
||||
fun QRScannerScreen(
|
||||
userId: String,
|
||||
navController: NavController,
|
||||
onScanResult: (String) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var hasCameraPermission by remember { mutableStateOf(false) }
|
||||
var scanComplete by remember { mutableStateOf(false) }
|
||||
|
||||
// TODO: Request camera permission using accompanist or ActivityResultLauncher
|
||||
// For now, assume permission is handled externally
|
||||
LaunchedEffect(Unit) {
|
||||
// Check permission status
|
||||
hasCameraPermission = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.CAMERA
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (hasCameraPermission) {
|
||||
// -- CameraX Preview --
|
||||
val previewView = remember { PreviewView(context) }
|
||||
|
||||
AndroidView(
|
||||
factory = { previewView },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
// Setup CameraX
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
|
||||
cameraProviderFuture.addListener({
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
|
||||
val preview = Preview.Builder()
|
||||
.build()
|
||||
.also { it.setSurfaceProvider(previewView.surfaceProvider) }
|
||||
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setTargetResolution(Size(1280, 720))
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also { analysis ->
|
||||
analysis.setAnalyzer(
|
||||
ContextCompat.getMainExecutor(context)
|
||||
) { imageProxy ->
|
||||
if (!scanComplete) {
|
||||
// TODO: Use ZXing to decode QR code from imageProxy
|
||||
// 1. Convert imageProxy to InputImage or byte buffer
|
||||
// 2. Use MultiFormatReader to decode
|
||||
// 3. On success:
|
||||
// scanComplete = true
|
||||
// onScanResult(decodedText)
|
||||
}
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
cameraSelector,
|
||||
preview,
|
||||
imageAnalysis,
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// TODO: Handle camera binding failure
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
|
||||
onDispose {
|
||||
try {
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
cameraProvider.unbindAll()
|
||||
} catch (_: Exception) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Scan area overlay --
|
||||
ScanOverlay()
|
||||
} else {
|
||||
// No camera permission
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Camera permission is required to scan QR codes.\nPlease grant camera access in Settings.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = CatppuccinMocha.Subtext1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Back button overlay --
|
||||
FloatingActionButton(
|
||||
onClick = { navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(16.dp)
|
||||
.size(48.dp),
|
||||
shape = CircleShape,
|
||||
containerColor = CatppuccinMocha.Surface0.copy(alpha = 0.8f),
|
||||
contentColor = CatppuccinMocha.Text,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
|
||||
// -- Instruction text --
|
||||
Text(
|
||||
text = "Point your camera at a QR code",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 80.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay that darkens the area outside the scan frame
|
||||
* and draws a rounded rectangle indicating the scan area.
|
||||
*/
|
||||
@Composable
|
||||
private fun ScanOverlay() {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val scanSize = size.width * 0.65f
|
||||
val left = (size.width - scanSize) / 2f
|
||||
val top = (size.height - scanSize) / 2f
|
||||
|
||||
// Semi-transparent dark overlay
|
||||
val path = Path().apply {
|
||||
addRoundRect(
|
||||
RoundRect(
|
||||
rect = Rect(left, top, left + scanSize, top + scanSize),
|
||||
cornerRadius = CornerRadius(16.dp.toPx()),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
clipPath(path, clipOp = ClipOp.Difference) {
|
||||
drawRect(Color.Black.copy(alpha = 0.6f))
|
||||
}
|
||||
|
||||
// Scan frame border
|
||||
drawRoundRect(
|
||||
color = CatppuccinMocha.Lavender,
|
||||
topLeft = Offset(left, top),
|
||||
size = androidx.compose.ui.geometry.Size(scanSize, scanSize),
|
||||
cornerRadius = CornerRadius(16.dp.toPx()),
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 3.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user