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