13 Commits

Author SHA1 Message Date
zxq5 7e001d8769 idk 2026-06-03 19:12:23 +01:00
zxq5 2f34976f3e Merge remote-tracking branch 'origin/dev' into dev 2026-04-11 00:10:09 +01:00
zxq5 d1208f7e39 frontend v0.4.1-2
- added invite section to UI and some general bug fixes
2026-04-11 00:09:47 +01:00
zxq5 d6ba875297 addedd RELEASE_MODE=1 to run var to prevent crash in absence of .env
file
2026-04-08 00:05:54 +01:00
zxq5 529d09aabc frontend v0.4.1
- fixed most of the bugs with the rewrite. should be ready to deploy now
2026-04-08 00:00:28 +01:00
zxq5 5291e7dee6 rewritten docker compose files and updated giutignore 2026-04-06 15:42:20 +01:00
zxq5 3c52ade946 deleted some old files 2026-04-06 15:38:28 +01:00
zxq5 0f692e4372 updated docker compose and formatted backend. 2026-04-06 13:44:50 +01:00
zxq5 d33eee1281 frontend v0.4.0 2026-04-06 01:02:39 +01:00
zxq5 7c9b733813 updated gitignore 2026-04-06 01:00:27 +01:00
zxq5 bda1ef251a full backend rewrite.
calling this v0.4.0
2026-04-06 00:57:23 +01:00
zxq5 a2f7f5a505 refactoring, proper User implementation, more settings endpoints. backend probably needs a full refactor to an API/Service/Repository architecture for maintainability 2026-04-02 03:11:14 +01:00
zxq5 ad0cf85b34 frontend update, big refactor 2026-04-02 03:09:33 +01:00
820 changed files with 6790 additions and 95898 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
*/target
.env
.log*
Cargo.lock
.cargo/
docker-compose*
.sqlx/
+4 -1
View File
@@ -2,7 +2,10 @@
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/backend/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/backend/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="chatapp dev" uuid="81992477-fd6f-427e-a27e-7378c26db6ef">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://100.118.108.58:5432/chatapp_dev</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/android" />
</GradleProjectSettings>
</option>
</component>
</project>
+1
View File
@@ -1,4 +1,5 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" project-jdk-name="25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
+3
View File
@@ -1,7 +1,9 @@
*.iml
.gradle
/local.properties
/keystore.properties
/.idea/caches
/.idea/.cache
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
@@ -13,3 +15,4 @@
.externalNativeBuild
.cxx
local.properties
release/
+8
View File
@@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-04-02T14:33:39.814557661Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=00319362N000094" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="MainActivity">
<option name="selectionMode" value="DROPDOWN" />
+34 -3
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
@@ -8,6 +10,25 @@ android {
namespace = "dev.zxq5.chatapp.android"
compileSdk = 35
val keystorePropertiesFile = rootProject.file("local.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
signingConfigs {
create("release") {
storeFile = file("${System.getProperty("user.home")}/keystores/chatapp.jks")
storePassword = keystoreProperties["KEYSTORE_PASSWORD"] as String?
?: System.getenv("KEYSTORE_PASSWORD")
?: ""
keyAlias = "chatapp"
keyPassword = keystoreProperties["KEY_PASSWORD"] as String?
?: System.getenv("KEY_PASSWORD")
?: ""
}
}
defaultConfig {
applicationId = "dev.zxq5.chatapp.android"
minSdk = 26
@@ -20,19 +41,30 @@ android {
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true // shrinks code
isShrinkResources = true // removes unused resources
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("String", "BASE_URL", "\"https://chat.zxq5.dev\"")
}
debug {
isMinifyEnabled = false
isDebuggable = true
applicationIdSuffix = ".debug" // lets you install both side by side
buildConfigField("String", "BASE_URL", "\"http://zxq5-x1:8000\"")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
buildConfig = true
}
}
@@ -44,7 +76,6 @@ dependencies {
implementation(libs.ktor.client.auth) // Auth plugin
// Kotlinx Serialization
implementation(libs.kotlinx.serialization.json)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
@@ -73,4 +104,4 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
}
+27 -1
View File
@@ -18,4 +18,30 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile
# Ktor
-keep class io.ktor.** { *; }
-keep class kotlinx.coroutines.** { *; }
# Kotlinx serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keep,includedescriptorclasses class dev.zxq5.chatapp.android.**$$serializer { *; }
-keepclassmembers class dev.zxq5.chatapp.android.** {
*** Companion;
}
-keepclasseswithmembers class dev.zxq5.chatapp.android.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep model classes (serialization needs these)
-keep class dev.zxq5.chatapp.android.api.model.** { *; }
-keep class dev.zxq5.chatapp.android.data.model.** { *; }
# Fix for missing errorprone and javax annotations used by Tink and other libraries
-dontwarn com.google.errorprone.annotations.**
-dontwarn javax.annotation.**
# Fix for missing java.lang.management referenced by Ktor (not available on Android)
-dontwarn java.lang.management.**
+7 -1
View File
@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://tools.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:name=".ChatApplication"
@@ -15,6 +16,11 @@
android:supportsRtl="true"
android:theme="@style/Theme.Chatapp"
android:usesCleartextTraffic="true">
<service
android:name=".core.service.MessageStreamService"
android:exported="false"/>
<activity
android:name=".MainActivity"
android:exported="true"
@@ -1,11 +1,51 @@
package dev.zxq5.chatapp.android
import android.app.Application
import dev.zxq5.chatapp.android.api.ApiClient
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
class ChatApplication : Application() {
object AppState {
var isInForeground = false
}
val tokenStore by lazy { TokenStore(this) }
val authRepository by lazy { AuthRepository(tokenStore) }
val chatRepository by lazy { ChatRepository(tokenStore) }
val settingsRepository by lazy { SettingsRepository(tokenStore) }
override fun onCreate() {
super.onCreate()
ApiClient.init(this)
createNotificationChannels()
}
private fun createNotificationChannels() {
val messageChannel = NotificationChannel(
"messages",
"Messages",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "New message notifications"
enableVibration(true)
}
// add this — required for the foreground service persistent notification
val serviceChannel = NotificationChannel(
"service",
"Background connection",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Keeps messages running in background"
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(messageChannel)
manager.createNotificationChannel(serviceChannel)
}
}
@@ -1,50 +1,212 @@
package dev.zxq5.chatapp.android
import android.Manifest
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.PeopleOutline
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.LoginState
import dev.zxq5.chatapp.android.model.MainScreen
import dev.zxq5.chatapp.android.ui.components.AuthScreen
import dev.zxq5.chatapp.android.ui.components.ChatScreen
import dev.zxq5.chatapp.android.ui.components.SettingsScreen
import dev.zxq5.chatapp.android.ChatApplication.AppState
import dev.zxq5.chatapp.android.core.service.MessageStreamService
import dev.zxq5.chatapp.android.data.repository.AuthState
import dev.zxq5.chatapp.android.feature.auth.AuthScreen
import dev.zxq5.chatapp.android.feature.auth.AuthViewModel
import dev.zxq5.chatapp.android.feature.chat.ChatScreen
import dev.zxq5.chatapp.android.feature.chat.ChatViewModel
import dev.zxq5.chatapp.android.feature.chat.Screen
import dev.zxq5.chatapp.android.feature.contacts.ContactsScreen
import dev.zxq5.chatapp.android.feature.settings.SettingsScreen
import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel
import dev.zxq5.chatapp.android.ui.theme.ChatappTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val app = application as ChatApplication
val authRepository = app.authRepository
val chatRepository = app.chatRepository
val settingsRepository = app.settingsRepository
enableEdgeToEdge()
setContent {
ChatappTheme {
val viewModel: ChatViewModel = viewModel()
val loginState by viewModel.loginState.collectAsState()
val currentScreen by viewModel.currentScreen.collectAsState()
val authViewModel: AuthViewModel = viewModel(factory = ViewModelFactory(authRepository))
val chatViewModel: ChatViewModel = viewModel(factory = ViewModelFactory(chatRepository))
val settingsViewModel: SettingsViewModel = viewModel(factory = ViewModelFactory(settingsRepository))
val authState by authViewModel.authState.collectAsState()
val currentScreen by chatViewModel.currentScreen.collectAsState()
val selectedChannelId by chatViewModel.channelId.collectAsState()
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(innerPadding)) {
if (loginState is LoginState.Success) {
when (currentScreen) {
MainScreen.CHAT -> ChatScreen(viewModel = viewModel)
MainScreen.SETTINGS -> SettingsScreen(viewModel = viewModel)
}
} else {
AuthScreen(
viewModel = viewModel,
onSuccess = { }
)
// Permission request launcher
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { isGranted ->
if (isGranted && authState == AuthState.Authenticated) {
MessageStreamService.start(this@MainActivity)
}
}
)
LaunchedEffect(authState) {
when (authState) {
AuthState.Authenticated -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
MessageStreamService.start(this@MainActivity)
chatViewModel.loadAccessibleChannels()
}
AuthState.Unauthenticated -> MessageStreamService.stop(this@MainActivity)
AuthState.AwaitingTotp -> {}
}
}
LaunchedEffect(Unit) {
chatViewModel.onUnauthorized = {
authViewModel.logout()
chatViewModel.clearChat()
}
}
LaunchedEffect(Unit) {
intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let {
chatViewModel.switchChannel(it.toLong())
}
}
if (authState == AuthState.Authenticated) {
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
// Only show bottom bar if we are NOT inside a specific chat channel
if (selectedChannelId == null) {
BottomDock(
currentScreen = currentScreen,
onNavigate = { chatViewModel.navigateTo(it) }
)
}
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
when (currentScreen) {
Screen.CHAT -> ChatScreen(
viewModel = chatViewModel,
onNavigateToSettings = { chatViewModel.navigateTo(Screen.SETTINGS) },
onLogout = {
authViewModel.logout()
chatViewModel.clearChat()
}
)
Screen.CONTACTS -> ContactsScreen()
Screen.SETTINGS -> SettingsScreen(
viewModel = settingsViewModel,
onLogout = {
authViewModel.logout()
chatViewModel.clearChat()
}
)
}
}
}
} else {
AuthScreen(viewModel = authViewModel)
}
}
}
}
override fun onResume() {
super.onResume()
AppState.isInForeground = true
}
override fun onPause() {
super.onPause()
AppState.isInForeground = false
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let { channelId ->
MessageStreamService.instance?.activeChannelId = channelId.toLong()
}
}
}
@Composable
fun BottomDock(currentScreen: Screen, onNavigate: (Screen) -> Unit) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.background,
tonalElevation = 0.dp,
modifier = Modifier
.height(80.dp)
.border(
0.5.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f),
RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp)
)
) {
NavigationBarItem(
selected = currentScreen == Screen.CHAT,
onClick = { onNavigate(Screen.CHAT) },
icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Chat") },
label = { Text("chat", style = MaterialTheme.typography.labelSmall) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
indicatorColor = Color.Transparent
)
)
NavigationBarItem(
selected = currentScreen == Screen.CONTACTS,
onClick = { onNavigate(Screen.CONTACTS) },
icon = { Icon(Icons.Outlined.PeopleOutline, contentDescription = "Contacts") },
label = { Text("contacts", style = MaterialTheme.typography.labelSmall) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
indicatorColor = Color.Transparent
)
)
NavigationBarItem(
selected = currentScreen == Screen.SETTINGS,
onClick = { onNavigate(Screen.SETTINGS) },
icon = { Icon(Icons.Outlined.Settings, contentDescription = "Settings") },
label = { Text("settings", style = MaterialTheme.typography.labelSmall) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
indicatorColor = Color.Transparent
)
)
}
}
@@ -0,0 +1,27 @@
package dev.zxq5.chatapp.android
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
import dev.zxq5.chatapp.android.feature.auth.AuthViewModel
import dev.zxq5.chatapp.android.feature.chat.ChatViewModel
import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel
class ViewModelFactory(private val repository: Any) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(AuthViewModel::class.java) -> {
AuthViewModel(repository as AuthRepository) as T
}
modelClass.isAssignableFrom(ChatViewModel::class.java) -> {
ChatViewModel(repository as ChatRepository) as T
}
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> {
SettingsViewModel(repository as SettingsRepository) as T
}
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
}
}
@@ -1,361 +0,0 @@
package dev.zxq5.chatapp.android.api
import android.content.Context
import android.util.Log
import dev.zxq5.chatapp.android.core.data.TokenStore.getScopeFromToken
import dev.zxq5.chatapp.android.model.LoginRequest
import dev.zxq5.chatapp.android.model.LoginResponse
import dev.zxq5.chatapp.android.model.Message
import dev.zxq5.chatapp.android.model.SendMessage
import dev.zxq5.chatapp.android.model.SignupRequest
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.delete
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.http.encodedPath
import dev.zxq5.chatapp.android.core.BASE_URL
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.data.TokenStore.getUserIdFromToken
import dev.zxq5.chatapp.android.core.error.ApiResult
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
@Serializable
data class QrResponse(val qr_code: String)
@Serializable
data class TOTPSixDigitCode(val code: String)
@Serializable(with = TotpStatusSerializer::class)
enum class TotpStatus {
ENABLED, DISABLED;
val isEnabled: Boolean get() = this == ENABLED
}
object TotpStatusSerializer : KSerializer<TotpStatus> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TotpStatus", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: TotpStatus) = encoder.encodeString(value.name.lowercase())
override fun deserialize(decoder: Decoder): TotpStatus =
TotpStatus.valueOf(decoder.decodeString().uppercase())
}
@Serializable
data class TotpStatusResponse(val status: TotpStatus)
@Serializable
data class PasswordChangeRequest(val old_password: String, val new_password: String)
@Serializable
data class DisplayNameRequest(val display_name: String?)
object ApiClient {
private lateinit var appContext: Context
fun init(context: Context) {
appContext = context.applicationContext
}
fun hasToken(): Boolean {
return TokenStore.get(appContext) != null
}
fun getTokenScope(): String? {
val token = TokenStore.get(appContext) ?: return null
val scope = getScopeFromToken(token)
Log.d("Chat", "Current token scope: $scope")
return scope
}
fun getStoredUserId(): Int? {
return TokenStore.getUserId(appContext)
}
fun is2faEnabledLocal(): Boolean {
return TokenStore.is2faEnabled(appContext)
}
fun set2faEnabledLocal(enabled: Boolean) {
TokenStore.save2faEnabled(appContext, enabled)
}
private var _http: HttpClient? = null
val http: HttpClient
get() = synchronized(this) {
_http ?: createClient().also { _http = it }
}
private fun createClient(): HttpClient {
return HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Auth) {
bearer {
loadTokens {
val token = TokenStore.get(appContext) ?: return@loadTokens null
Log.d("Chat", "Auth plugin loading token: ${getScopeFromToken(token)}")
BearerTokens(token, "")
}
sendWithoutRequest { request ->
val path = request.url.encodedPath
!path.endsWith("/login") && !path.endsWith("/signup")
}
}
}
}
}
private fun resetClient() {
Log.d("Chat", "Resetting HttpClient to refresh tokens")
_http?.close()
_http = null
}
suspend fun login(username: String, password: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest(username, password))
}
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
Log.d("Chat", "Login token scope: ${getScopeFromToken(body.token)}")
TokenStore.save(appContext, body.token)
resetClient()
ApiResult.Success(body)
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid username or password"
403 -> "Account suspended"
429 -> "Too many attempts, please wait"
else -> "Login failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Login network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun signup(username: String, email: String, password: String, token: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/signup") {
contentType(ContentType.Application.Json)
setBody(SignupRequest(username = username, email = email, password = password, access_token = token))
}
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
TokenStore.save(appContext, body.token)
resetClient()
ApiResult.Success(body)
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid access token"
else -> "Signup failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Signup error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
fun logout() {
TokenStore.clear(appContext)
resetClient()
}
suspend fun sendMessage(channelId: Int, text: String) {
val userId = TokenStore.getUserId(appContext) ?: return
http.post("${BASE_URL}/api/chat/$channelId") {
contentType(ContentType.Application.Json)
setBody(SendMessage(
user_id = userId,
text = text,
timestamp = System.currentTimeMillis()
))
}
}
fun messageStream(channelId: Int): Flow<Message> = flow {
http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response ->
val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line(256) ?: break
if (line.startsWith("data:")) {
val json = line.removePrefix("data:").trim()
runCatching { Json.decodeFromString<Message>(json) }
.onSuccess { emit(it) }
}
}
}
}
suspend fun getTotpQr(): QrResponse? {
return try {
http.get("${BASE_URL}/api/totp.jpg").body<QrResponse>()
} catch (e: Exception) {
Log.e("Chat", "Error fetching TOTP QR", e)
null
}
}
suspend fun confirmTotp(code: String): Boolean {
return try {
val response = http.post("${BASE_URL}/api/totp") {
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
// If confirming TOTP returns a new token (e.g. from partial to full), we should save it
// Assuming confirm might return a LoginResponse if it upgrades the session
runCatching {
val body = response.body<LoginResponse>()
TokenStore.save(appContext, body.token)
resetClient()
}
set2faEnabledLocal(true)
true
} else {
false
}
} catch (e: Exception) {
Log.e("Chat", "Error confirming TOTP", e)
false
}
}
suspend fun verifyTotpLogin(code: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/totp/verify") {
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
val tok = body.token;
Log.d("Chat", "UID ${getUserIdFromToken(tok)}");
Log.d("Chat", "Token ${getScopeFromToken(tok)}");
TokenStore.save(appContext, body.token)
resetClient()
ApiResult.Success(body)
} else {
val errorText = try { response.body<String>() } catch (e: Exception) { "Unknown error" }
Log.e("Chat", "TOTP verify failed: ${response.status.value} - $errorText")
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Incorrect code, please try again"
403 -> "Session expired, please log in again"
429 -> "Too many attempts, please wait"
else -> "Verification failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "TOTP verify network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun getTotpStatus(): Boolean {
return try {
val response = http.get("${BASE_URL}/api/totp/status")
if (response.status.isSuccess()) {
val status = response.body<TotpStatus>()
val enabled = status.isEnabled
set2faEnabledLocal(enabled)
enabled
} else {
is2faEnabledLocal()
}
} catch (e: Exception) {
Log.e("Chat", "Error getting TOTP status", e)
is2faEnabledLocal()
}
}
suspend fun disableTotp(): ApiResult<LoginResponse> {
return try {
val response = http.delete("${BASE_URL}/api/totp")
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
TokenStore.save(appContext, body.token)
set2faEnabledLocal(false)
resetClient()
ApiResult.Success(body)
} else {
ApiResult.HttpError(response.status.value, "Failed to disable TOTP")
}
} catch (e: Exception) {
Log.e("Chat", "Error disabling TOTP", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun changePassword(old: String, new: String): ApiResult<Unit> {
return try {
val response = http.post("${BASE_URL}/api/settings/password") {
contentType(ContentType.Application.Json)
setBody(PasswordChangeRequest(old, new))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(
response.status.value,
if (response.status.value == 401) "Old password is wrong" else "Password change failed"
)
}
} catch (e: Exception) {
Log.e("Chat", "Error changing password", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun updateDisplayName(name: String?): Boolean {
return try {
val response = http.post("${BASE_URL}/api/settings/display_name") {
contentType(ContentType.Application.Json)
setBody(DisplayNameRequest(name))
}
response.status.isSuccess()
} catch (e: Exception) {
Log.e("Chat", "Error updating display name", e)
false
}
}
}
@@ -0,0 +1,117 @@
package dev.zxq5.chatapp.android.api
import android.util.Log
import dev.zxq5.chatapp.android.BuildConfig
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
import dev.zxq5.chatapp.android.api.model.LoginRequest
import dev.zxq5.chatapp.android.api.model.LoginResponse
import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode
import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.api.model.SignupRequest
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* Client for unauthenticated and pre-authenticated (2FA) requests.
*/
object AuthClient {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
suspend fun login(username: String, password: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest(username, password))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<LoginResponse>())
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid username or password"
403 -> "Account suspended"
429 -> "Too many attempts, please wait"
else -> "Login failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Login network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun signup(username: String, email: String, password: String, token: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/signup") {
contentType(ContentType.Application.Json)
setBody(
SignupRequest(
username = username,
email = email,
password = password,
access_token = token
)
)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<LoginResponse>())
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid access token"
else -> "Signup failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Signup error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun verifyTotpLogin(partialToken: String, code: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/totp/verify") {
header(HttpHeaders.Authorization, "Bearer $partialToken")
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<LoginResponse>())
} else {
val errorText = try { response.body<String>() } catch (e: Exception) { "Unknown error" }
Log.e("Chat", "TOTP verify failed: ${response.status.value} - $errorText")
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Incorrect code, please try again"
403 -> "Session expired, please log in again"
429 -> "Too many attempts, please wait"
else -> "Verification failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "TOTP verify network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
}
@@ -0,0 +1,69 @@
@file:OptIn(ExperimentalUuidApi::class)
package dev.zxq5.chatapp.android.api
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
import dev.zxq5.chatapp.android.api.model.ChatEvent
import dev.zxq5.chatapp.android.api.model.SendMessage
import dev.zxq5.chatapp.android.api.model.SpaceDto
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.readLine
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.json.Json
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
class ChatClient(private val token: String) {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Auth) {
bearer {
loadTokens { BearerTokens(token, "") }
}
}
}
suspend fun getAccessibleChannels(): List<SpaceDto> = http.get("${BASE_URL}/api/accessible_channels").body()
@OptIn(ExperimentalTime::class)
suspend fun sendMessage(channelId: Long, userId: Int, text: String) {
http.post("${BASE_URL}/api/chat/$channelId") {
contentType(ContentType.Application.Json)
setBody(SendMessage(id = Uuid.random(), user_id = userId, text = text, timestamp = Clock.System.now()))
}
}
fun eventStream(channelId: Long): Flow<ChatEvent> = flow {
http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response ->
val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) {
val line = channel.readLine() ?: break
if (line.startsWith("data:")) {
val json = line.removePrefix("data:").trim()
runCatching { Json.decodeFromString<ChatEvent>(json) }
.onSuccess { emit(it) }
}
}
}
}
}
@@ -0,0 +1,188 @@
package dev.zxq5.chatapp.android.api
import android.util.Log
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
import dev.zxq5.chatapp.android.api.model.AccountDeleteRequest
import dev.zxq5.chatapp.android.api.model.DisplayNameRequest
import dev.zxq5.chatapp.android.api.model.InviteRequest
import dev.zxq5.chatapp.android.api.model.PasswordChangeRequest
import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode
import dev.zxq5.chatapp.android.api.model.TotpStatus
import dev.zxq5.chatapp.android.api.model.UsernameRequest
import dev.zxq5.chatapp.android.api.model.TotpDeleteRequest
import dev.zxq5.chatapp.android.api.model.PasswordRequest
import dev.zxq5.chatapp.android.core.error.ApiResult
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* Client for account settings and TOTP management.
*/
class SettingsClient(private val token: String) {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Auth) {
bearer {
loadTokens { BearerTokens(token, "") }
}
}
}
suspend fun createInvite(request: InviteRequest): ApiResult<String> {
return try {
val response = http.post("${BASE_URL}/api/invite") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<String>())
} else {
ApiResult.HttpError(response.status.value, "Failed to create invite")
}
} catch (e: Exception) {
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun getTotpQr(password: String): ApiResult<QrResponse> {
return try {
val response = http.post("${BASE_URL}/api/totp.jpg") {
contentType(ContentType.Application.Json)
setBody(PasswordRequest(password))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<QrResponse>())
} else {
ApiResult.HttpError(response.status.value, "Failed to get QR code")
}
} catch (e: Exception) {
Log.e("Chat", "Error fetching TOTP QR", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun confirmTotp(code: String): ApiResult<Unit> {
return try {
val response = http.post("${BASE_URL}/api/totp") {
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(response.status.value, "Failed to confirm TOTP")
}
} catch (e: Exception) {
Log.e("Chat", "Error confirming TOTP", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun getTotpStatus(): ApiResult<TotpStatus> {
return try {
val response = http.get("${BASE_URL}/api/totp/status")
if (response.status.isSuccess()) {
ApiResult.Success(response.body<TotpStatus>())
} else {
ApiResult.HttpError(response.status.value, "Failed to get TOTP status")
}
} catch (e: Exception) {
Log.e("Chat", "Error getting TOTP status", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun disableTotp(password: String, totpCode: String): ApiResult<Unit> {
return try {
val response = http.delete("${BASE_URL}/api/totp") {
contentType(ContentType.Application.Json)
setBody(TotpDeleteRequest(password, totpCode))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(response.status.value, "Failed to disable TOTP")
}
} catch (e: Exception) {
Log.e("Chat", "Error disabling TOTP", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun changePassword(old: String, new: String): ApiResult<Unit> {
return try {
val response = http.post("${BASE_URL}/api/settings/password") {
contentType(ContentType.Application.Json)
setBody(PasswordChangeRequest(old, new))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(
response.status.value,
if (response.status.value == 401) "Old password is wrong" else "Password change failed"
)
}
} catch (e: Exception) {
Log.e("Chat", "Error changing password", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun updateDisplayName(name: String?): Boolean {
return try {
val response = http.patch("${BASE_URL}/api/settings/display_name") {
contentType(ContentType.Application.Json)
setBody(DisplayNameRequest(name))
}
response.status.isSuccess()
} catch (e: Exception) {
Log.e("Chat", "Error updating display name", e)
false
}
}
suspend fun updateUsername(username: String): ApiResult<Unit> {
return try {
val response = http.patch("${BASE_URL}/api/settings/username") {
contentType(ContentType.Application.Json)
setBody(UsernameRequest(username))
}
if (response.status.isSuccess()) ApiResult.Success(Unit)
else ApiResult.HttpError(response.status.value, "Failed to update username")
} catch (e: Exception) {
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun deleteAccount(password: String, totpCode: String?): ApiResult<Unit> {
return try {
val response = http.delete("${BASE_URL}/api/settings") {
contentType(ContentType.Application.Json)
setBody(AccountDeleteRequest(password, totpCode))
}
if (response.status.isSuccess()) ApiResult.Success(Unit)
else ApiResult.HttpError(response.status.value, "Failed to delete account")
} catch (e: Exception) {
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
}
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class AccountDeleteRequest(val password: String, val totp_code: String? = null)
@@ -0,0 +1,15 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@Serializable
data class Channel @OptIn(ExperimentalTime::class) constructor(
val id: Long,
val name: String,
val description: String? = null,
val space_id: Long,
val created_at: Instant,
val updated_at: Instant
)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class DisplayNameRequest(val display_name: String?)
@@ -0,0 +1,60 @@
@file:OptIn(ExperimentalUuidApi::class, ExperimentalTime::class)
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.json.JsonClassDiscriminator
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import kotlinx.serialization.Serializable
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@JsonClassDiscriminator("type")
sealed class ChatEvent {
@Serializable
@SerialName("SendMessage")
data class SendMessage(
val data: Message
) : ChatEvent()
@Serializable
@SerialName("EditMessage")
data class EditMessage(
val data: EditMessageContent
) : ChatEvent()
@Serializable
@SerialName("MessageAppendContent")
data class MessageAppendContent(
val data: AppendContent
) : ChatEvent()
}
// tuple variants like (i64, ChatMsg) and (i64, String)
// need wrapper classes since kotlinx can't deserialise
// bare JSON arrays into data classes directly
@Serializable
data class EditMessageContent(
val id: Uuid,
val message: Message
)
@Serializable
data class AppendContent (
val id: Uuid,
val content: String
)
@Serializable
data class Message (
val id: Uuid,
val user_id: Int,
val display_name: String,
val text: String,
val timestamp: Instant
)
@@ -0,0 +1,13 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@Serializable
data class InviteRequest @OptIn(ExperimentalTime::class) constructor(
val name: String,
val max_uses: Int,
val expiry_date: Instant,
val start_date: Instant
)
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@@ -6,6 +6,4 @@ import kotlinx.serialization.Serializable
data class LoginRequest(
val username: String,
val password: String
)
)
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model
package dev.zxq5.chatapp.android.api.model
sealed class LoginState {
object Idle : LoginState()
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class PasswordChangeRequest(val old_password: String, val new_password: String)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class PasswordRequest(val password: String)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class QrResponse(val qr_code: String)
@@ -0,0 +1,15 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Serializable
data class SendMessage @OptIn(ExperimentalTime::class, ExperimentalUuidApi::class) constructor(
val id: Uuid,
val user_id: Int,
val text: String,
val timestamp: Instant
)
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -11,4 +11,4 @@ data class SignupRequest(
@SerialName("access_token")
val access_token: String
)
)
@@ -0,0 +1,28 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@Serializable
data class Space @OptIn(ExperimentalTime::class) constructor(
val id: Long,
val name: String,
val description: String? = null,
val owner_id: Long,
val created_at: Instant,
val updated_at: Instant
)
@Serializable
data class SpaceDto @OptIn(ExperimentalTime::class) constructor(
val channels: List<Channel>,
val id: Long,
val name: String,
val description: String? = null,
val owner_id: Long,
val created_at: Instant,
val updated_at: Instant
)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class TOTPSixDigitCode(val code: String)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class TotpDeleteRequest(val password: String, val totp_code: String)
@@ -0,0 +1,24 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
@Serializable(with = TotpStatus.TotpStatusSerializer::class)
enum class TotpStatus {
ENABLED, DISABLED;
val isEnabled: Boolean get() = this == ENABLED
companion object TotpStatusSerializer : KSerializer<TotpStatus> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TotpStatus", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: TotpStatus) = encoder.encodeString(value.name.lowercase())
override fun deserialize(decoder: Decoder): TotpStatus =
TotpStatus.valueOf(decoder.decodeString().uppercase())
}
}
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class UsernameRequest(val username: String)
@@ -1,3 +1,3 @@
package dev.zxq5.chatapp.android.core
const val BASE_URL = "http://zxq5-x1:8000"
//const val BASE_URL = "http://zxq5-x1:8000"
@@ -3,17 +3,21 @@ package dev.zxq5.chatapp.android.core.data
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import android.util.Log
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONObject
import java.time.Instant
// In your ApiClient or a dedicated TokenStore
object TokenStore {
private const val KEY = "auth_token"
private const val TWOFA_KEY = "twofa_enabled"
private const val KEY = "auth_token"
private const val TWOFA_KEY = "twofa_enabled"
private fun prefs(context: Context): SharedPreferences {
// In your ChatClient.kt or a dedicated TokenStore
class TokenStore(appContext: Context) {
private val context = appContext.applicationContext;
private fun prefs(): SharedPreferences {
return EncryptedSharedPreferences.create(
context,
"secure_prefs",
@@ -25,54 +29,80 @@ object TokenStore {
)
}
fun save(context: Context, token: String) =
prefs(context).edit { putString(KEY, token) }
fun save(token: String) {
Log.d("TokenStore", "Saving token: $token")
fun get(context: Context): String? =
prefs(context).getString(KEY, null)
fun save2faEnabled(context: Context, enabled: Boolean) =
prefs(context).edit { putBoolean(TWOFA_KEY, enabled) }
fun is2faEnabled(context: Context): Boolean =
prefs(context).getBoolean(TWOFA_KEY, false)
fun clear(context: Context) =
prefs(context).edit { remove(KEY).remove(TWOFA_KEY) }
fun getUserId(context: Context): Int? {
val token = get(context) ?: return null
return getUserIdFromToken(token)
prefs().edit { putString(KEY, token) }
}
fun getUserIdFromToken(token: String): Int? {
fun get(): String? {
val ret = prefs().getString(KEY, null)
Log.d("TokenStore", "Retrieved token: $ret")
return ret
}
fun isExpired(): Boolean {
val token = get() ?: return true
return try {
val payload = token.split(".")[1]
// base64url needs padding restored
val padded = payload + "==".take((4 - payload.length % 4) % 4)
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
val json = JSONObject(jsonString)
// Handle both standard 'sub' and custom 'user_id'
when {
json.has("sub") -> json.getInt("sub")
json.has("user_id") -> json.getInt("user_id")
else -> null
if (json.has("exp")) {
val exp = json.getLong("exp")
val now = Instant.now().epochSecond
now >= exp
} else {
false // If no exp claim, assume not expired or handle differently
}
} catch (e: Exception) {
null
true // If we can't parse it, treat it as expired
}
}
fun getScopeFromToken(token: String): String? {
return try {
val payload = token.split(".")[1]
val padded = payload + "==".take((4 - payload.length % 4) % 4)
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
val json = JSONObject(jsonString)
if (json.has("scope")) json.getString("scope") else null
} catch (e: Exception) {
null
fun save2faEnabled( enabled: Boolean) =
prefs().edit { putBoolean(TWOFA_KEY, enabled) }
fun is2faEnabled(): Boolean =
prefs().getBoolean(TWOFA_KEY, false)
fun clear() =
prefs().edit { remove(KEY).remove(TWOFA_KEY) }
fun getUserId(): Int? {
val token = get() ?: return null
return getUserIdFromToken(token)
}
}
fun getUserIdFromToken(token: String): Int? {
return try {
val payload = token.split(".")[1]
// base64url needs padding restored
val padded = payload + "==".take((4 - payload.length % 4) % 4)
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
val json = JSONObject(jsonString)
// Handle both standard 'sub' and custom 'user_id'
when {
json.has("sub") -> json.getInt("sub")
json.has("user_id") -> json.getInt("user_id")
else -> null
}
} catch (e: Exception) {
null
}
}
fun getScopeFromToken(token: String): String? {
return try {
val payload = token.split(".")[1]
val padded = payload + "==".take((4 - payload.length % 4) % 4)
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
val json = JSONObject(jsonString)
if (json.has("scope")) json.getString("scope") else null
} catch (e: Exception) {
null
}
}
@@ -0,0 +1,92 @@
package dev.zxq5.chatapp.android.core.service
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.util.Log
import dev.zxq5.chatapp.android.ChatApplication
import dev.zxq5.chatapp.android.api.model.ChatEvent
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
class MessageStreamService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var notificationService: NotificationService
private lateinit var chatRepository: ChatRepository
var activeChannelId: Long? = null
set(value) {
field = value
Log.d("Service", "activeChannelId set to $value")
if (value != null) {
currentStreamJob?.cancel()
observeMessages()
}
}
private var currentStreamJob: kotlinx.coroutines.Job? = null
companion object {
var instance: MessageStreamService? = null
fun start(context: Context) {
val intent = Intent(context, MessageStreamService::class.java)
context.startService(intent)
}
fun stop(context: Context) {
context.stopService(Intent(context, MessageStreamService::class.java))
}
}
override fun onCreate() {
super.onCreate()
instance = this
notificationService = NotificationService(this)
chatRepository = (application as ChatApplication).chatRepository
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
observeMessages()
return START_STICKY
}
private fun observeMessages() {
val channelId = activeChannelId ?: chatRepository.getLastActiveChannel()
if (channelId == null) return
currentStreamJob = serviceScope.launch {
chatRepository.eventStream(channelId)
.catch { e -> Log.e("Service", "Stream error", e) }
.collect { event ->
// Only show notification when an event (new message) is received
// and the app is not in the foreground on this channel.
if (!ChatApplication.AppState.isInForeground || activeChannelId != channelId) {
when (event) {
is ChatEvent.SendMessage -> notificationService.showMessageNotification(
conversationId = channelId.toString(),
senderName = event.data.display_name,
messagePreview = event.data.text
)
else -> {}
}
}
}
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
instance = null
serviceScope.cancel()
}
}
@@ -0,0 +1,84 @@
package dev.zxq5.chatapp.android.core.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import dev.zxq5.chatapp.android.MainActivity
import dev.zxq5.chatapp.android.R
class NotificationService(private val context: Context) {
companion object {
const val CHANNEL_ID = "messages"
const val SERVICE_CHANNEL_ID = "service"
const val FOREGROUND_NOTIFICATION_ID = 1
}
private val manager = context.getSystemService(NotificationManager::class.java)
fun createForegroundNotification(): Notification {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(context, SERVICE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Chat App")
.setContentText("Connecting to message stream...")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
fun showMessageNotification(
conversationId: String,
senderName: String,
messagePreview: String,
notificationId: Int = conversationId.hashCode()
) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("conversation_id", conversationId)
}
val pendingIntent = PendingIntent.getActivity(
context,
notificationId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(senderName)
.setContentText(messagePreview)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
manager.notify(notificationId, notification)
}
fun dismissNotification(conversationId: String) {
manager.cancel(conversationId.hashCode())
}
fun dismissAll() {
manager.cancelAll()
}
}
@@ -1,35 +1,87 @@
package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.ApiClient
import dev.zxq5.chatapp.android.api.AuthClient
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.data.getScopeFromToken
import dev.zxq5.chatapp.android.core.error.ApiResult
//
//class AuthRepository(
// private val apiClient: ApiClient,
// private val tokenStore: TokenStore,
//) {
//
// suspend fun login(username: String, password: String): LoginResult {
//// return when(val result = apiClient.login(username, password)) {
//// is ApiResult.Success -> {
//// tokenStore.save(context = context, result.data.token);
//// }
//// }
// }
//}
import dev.zxq5.chatapp.android.feature.auth.TokenScope
class AuthRepository(
private val tokenStore: TokenStore,
) {
suspend fun signup(username: String, email: String, password: String, accessToken: String): SignupResult {
return when(val result = AuthClient.signup(username, email, password, accessToken)) {
is ApiResult.HttpError -> SignupResult.Error(result.message)
is ApiResult.NetworkError -> SignupResult.Error("Network error: ${result.message}")
is ApiResult.Success -> {
tokenStore.save(result.data.token)
SignupResult.Success
}
}
}
suspend fun verifyTotpLogin(code: String): LoginResult {
val partialToken = tokenStore.get() ?: return LoginResult.Error("Session expired")
return when(val result = AuthClient.verifyTotpLogin(partialToken, code)) {
is ApiResult.HttpError -> LoginResult.TotpError(result.message)
is ApiResult.NetworkError -> LoginResult.TotpError("Network error: ${result.message}")
is ApiResult.Success -> {
tokenStore.save(result.data.token)
LoginResult.Success
}
}
}
suspend fun login(username: String, password: String): LoginResult {
return when(val result = AuthClient.login(username, password)) {
is ApiResult.HttpError -> LoginResult.Error(result.message)
is ApiResult.NetworkError -> LoginResult.Error("Network error: ${result.message}")
is ApiResult.Success -> {
tokenStore.save(result.data.token)
when (val scope = getScopeFromToken(result.data.token)) {
TokenScope.TOTP_PENDING -> LoginResult.TotpRequired
TokenScope.FULL -> LoginResult.Success
else -> LoginResult.Error("Unexpected token scope: $scope")
}
}
}
}
fun logout() {
tokenStore.clear()
}
fun getUserId() = tokenStore.getUserId()
fun getAuthState(): AuthState {
val token = tokenStore.get() ?: return AuthState.Unauthenticated
if (tokenStore.isExpired()) {
tokenStore.clear()
return AuthState.Unauthenticated
}
return when (getScopeFromToken(token)) {
TokenScope.FULL -> AuthState.Authenticated
TokenScope.TOTP_PENDING -> AuthState.AwaitingTotp
else -> AuthState.Unauthenticated
}
}
}
sealed class SignupResult {
object Success : SignupResult()
data class Error(val message: String) : SignupResult()
}
sealed class LoginResult {
object Success : LoginResult()
object TotpRequired : LoginResult() // step 1 outcome → go to totp screen
data class TotpError(val message: String) : LoginResult() // step 2 failure → stay on totp screen, show error
data class Error(val message: String) : LoginResult() // general failure → show on login form
object TotpRequired : LoginResult()
data class TotpError(val message: String) : LoginResult()
data class Error(val message: String) : LoginResult()
}
sealed class AuthState {
object Authenticated : AuthState()
object AwaitingTotp : AuthState()
object Unauthenticated : AuthState()
}
}
@@ -0,0 +1,51 @@
package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.ChatClient
import dev.zxq5.chatapp.android.api.model.ChatEvent
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.api.model.SpaceDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
class ChatRepository(private val tokenStore: TokenStore) {
private var _chatClient: ChatClient? = null
private var _lastToken: String? = null
private var _lastActiveChannel: Long? = null
private fun getChatClient(): ChatClient? {
val token = tokenStore.get() ?: return null
if (_chatClient == null || token != _lastToken) {
_chatClient = ChatClient(token)
_lastToken = token
}
return _chatClient
}
fun resetClient() {
_chatClient = null
_lastToken = null
}
fun getLastActiveChannel(): Long? {
return _lastActiveChannel
}
fun getUserId() = tokenStore.getUserId()
suspend fun getAccessibleChannels(): List<SpaceDto> {
return getChatClient()?.getAccessibleChannels() ?: emptyList()
}
suspend fun sendMessage(channelId: Long, text: String) {
val userId = tokenStore.getUserId() ?: return
getChatClient()?.sendMessage(channelId, userId, text)
}
fun eventStream(channelId: Long): Flow<ChatEvent> {
_lastActiveChannel = channelId
return getChatClient()?.eventStream(channelId) ?: emptyFlow()
}
}
@@ -0,0 +1,72 @@
package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.api.SettingsClient
import dev.zxq5.chatapp.android.api.model.InviteRequest
import dev.zxq5.chatapp.android.api.model.TotpStatus
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.error.ApiResult
class SettingsRepository(private val tokenStore: TokenStore) {
private var _settingsClient: SettingsClient? = null
private var _lastToken: String? = null
private fun getSettingsClient(): SettingsClient? {
val token = tokenStore.get() ?: return null
if (_settingsClient == null || token != _lastToken) {
_settingsClient = SettingsClient(token)
_lastToken = token
}
return _settingsClient
}
fun resetClient() {
_settingsClient = null
_lastToken = null
}
suspend fun createInvite(request: InviteRequest): ApiResult<String> {
return getSettingsClient()?.createInvite(request) ?: ApiResult.NetworkError("Not authenticated")
}
suspend fun getTotpQr(password: String): ApiResult<QrResponse?> {
val settingsClient = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
return settingsClient.getTotpQr(password)
}
suspend fun confirmTotp(code: String): ApiResult<Unit> {
val client = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
return client.confirmTotp(code)
}
suspend fun getTotpStatus(): ApiResult<TotpStatus> {
return getSettingsClient()?.getTotpStatus() ?: ApiResult.NetworkError("Not authenticated")
}
suspend fun disableTotp(password: String, totpCode: String): ApiResult<Unit> {
val client = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
return client.disableTotp(password, totpCode)
}
suspend fun changePassword(old: String, new: String): ApiResult<Unit> {
return getSettingsClient()?.changePassword(old, new) ?: ApiResult.NetworkError("Not authenticated")
}
suspend fun updateDisplayName(name: String?): Boolean {
return getSettingsClient()?.updateDisplayName(name) ?: false
}
suspend fun updateUsername(username: String): ApiResult<Unit> {
return getSettingsClient()?.updateUsername(username) ?: ApiResult.NetworkError("Not authenticated")
}
suspend fun deleteAccount(password: String, totpCode: String?): ApiResult<Unit> {
return getSettingsClient()?.deleteAccount(password, totpCode) ?: ApiResult.NetworkError("Not authenticated")
}
fun logout() {
tokenStore.clear()
resetClient()
}
}
@@ -0,0 +1,5 @@
package dev.zxq5.chatapp.android.feature.auth
enum class AuthMode {
LOGIN, SIGNUP
}
@@ -0,0 +1,39 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import dev.zxq5.chatapp.android.api.model.LoginState
@Composable
fun AuthScreen(viewModel: AuthViewModel) {
val loginState by viewModel.loginState.collectAsState()
val authMode by viewModel.authMode.collectAsState()
val totpError by viewModel.totpError.collectAsState()
if (loginState is LoginState.TwoFactorRequired ||
(loginState is LoginState.Loading && totpError != null)) {
TwoFactorLoginScreen(
onVerify = { viewModel.verifyTotpLogin(it) },
onBack = {
viewModel.clearTotpError()
viewModel.setAuthMode(AuthMode.LOGIN)
},
isLoading = loginState is LoginState.Loading,
error = totpError
)
return
}
if (authMode == AuthMode.SIGNUP) {
SignupScreen(
viewModel = viewModel,
onSwitchToLogin = { viewModel.setAuthMode(AuthMode.LOGIN) }
)
} else {
LoginScreen(
viewModel = viewModel,
onSwitchToSignup = { viewModel.setAuthMode(AuthMode.SIGNUP) }
)
}
}
@@ -0,0 +1,108 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.core.service.MessageStreamService
import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.LoginResult
import dev.zxq5.chatapp.android.data.repository.SignupResult
import dev.zxq5.chatapp.android.data.repository.AuthState
import dev.zxq5.chatapp.android.api.model.LoginState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class AuthViewModel(private val authRepository: AuthRepository) : ViewModel() {
private val _loginState = MutableStateFlow<LoginState>(LoginState.Idle)
val loginState: StateFlow<LoginState> = _loginState
private val _authMode = MutableStateFlow(AuthMode.LOGIN)
val authMode: StateFlow<AuthMode> = _authMode
private val _authState = MutableStateFlow(authRepository.getAuthState())
val authState: StateFlow<AuthState> = _authState
private val _totpError = MutableStateFlow<String?>(null)
val totpError: StateFlow<String?> = _totpError
fun setAuthMode(mode: AuthMode) {
_authMode.value = mode
if (_loginState.value is LoginState.Error) {
_loginState.value = LoginState.Idle
}
}
fun signup(username: String, email: String, password: String, accessToken: String) {
viewModelScope.launch {
_loginState.value = LoginState.Loading
when (val result = authRepository.signup(username, email, password, accessToken)) {
is SignupResult.Success -> {
updateAuthState()
_loginState.value = LoginState.Success
}
is SignupResult.Error -> {
_loginState.value = LoginState.Error(result.message)
}
}
}
}
fun login(username: String, password: String) {
viewModelScope.launch {
_loginState.value = LoginState.Loading
when (val result = authRepository.login(username, password)) {
is LoginResult.Success -> {
updateAuthState()
_loginState.value = LoginState.Success
}
is LoginResult.TotpRequired -> {
updateAuthState()
_loginState.value = LoginState.TwoFactorRequired
}
is LoginResult.Error -> {
_loginState.value = LoginState.Error(result.message)
}
is LoginResult.TotpError -> {
_loginState.value = LoginState.Error(result.message)
}
}
}
}
fun verifyTotpLogin(code: String) {
viewModelScope.launch {
_loginState.value = LoginState.Loading
when (val result = authRepository.verifyTotpLogin(code)) {
is LoginResult.Success -> {
updateAuthState()
_loginState.value = LoginState.Success
}
is LoginResult.TotpError -> {
_totpError.value = result.message
_loginState.value = LoginState.TwoFactorRequired
}
is LoginResult.Error -> {
_loginState.value = LoginState.Error(result.message)
}
is LoginResult.TotpRequired -> {
_loginState.value = LoginState.TwoFactorRequired
}
}
}
}
fun logout() {
authRepository.logout()
updateAuthState()
_loginState.value = LoginState.Idle
}
private fun updateAuthState() {
_authState.value = authRepository.getAuthState()
}
fun clearTotpError() {
_totpError.value = null
}
}
@@ -0,0 +1,133 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.api.model.LoginState
import dev.zxq5.chatapp.android.ui.components.TextField
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
onSwitchToSignup: () -> Unit
) {
val loginState by viewModel.loginState.collectAsState()
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(40.dp))
Text(
text = "messenger",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "welcome back",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 48.dp)
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TextField(
value = username,
onValueChange = { username = it },
label = "username"
)
TextField(
value = password,
onValueChange = { password = it },
label = "password",
isPassword = true
)
}
Spacer(Modifier.height(32.dp))
Button(
onClick = {
localError = null
if (username.isBlank() || password.isBlank()) {
localError = "fill all fields"
return@Button
}
viewModel.login(username, password)
},
enabled = loginState !is LoginState.Loading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.secondary
)
) {
if (loginState is LoginState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("login", style = MaterialTheme.typography.bodyLarge)
}
}
val displayError = localError ?: (loginState as? LoginState.Error)?.message
if (displayError != null) {
Text(
text = displayError.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = onSwitchToSignup) {
Text(
"no account? sign up",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -0,0 +1,160 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.api.model.LoginState
import dev.zxq5.chatapp.android.ui.components.TextField
@Composable
fun SignupScreen(
viewModel: AuthViewModel,
onSwitchToLogin: () -> Unit
) {
val loginState by viewModel.loginState.collectAsState()
var username by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var accessToken by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(40.dp))
Text(
text = "messenger",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "create account",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 48.dp)
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TextField(
value = username,
onValueChange = { username = it },
label = "username"
)
TextField(
value = email,
onValueChange = { email = it },
label = "email"
)
TextField(
value = password,
onValueChange = { password = it },
label = "password",
isPassword = true
)
TextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "confirm password",
isPassword = true
)
TextField(
value = accessToken,
onValueChange = { accessToken = it },
label = "access token"
)
}
Spacer(Modifier.height(32.dp))
Button(
onClick = {
localError = null
if (username.isBlank() || email.isBlank() || password.isBlank() || accessToken.isBlank()) {
localError = "fill all fields"
return@Button
}
if (password != confirmPassword) {
localError = "passwords mismatch"
return@Button
}
viewModel.signup(username, email, password, accessToken)
},
enabled = loginState !is LoginState.Loading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.secondary
)
) {
if (loginState is LoginState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("sign up", style = MaterialTheme.typography.bodyLarge)
}
}
val displayError = localError ?: (loginState as? LoginState.Error)?.message
if (displayError != null) {
Text(
text = displayError.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = onSwitchToLogin) {
Text(
"have account? login",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.feature.auth
object TokenScope {
const val FULL = "full"
const val TOTP_PENDING = "totp_pending"
}
@@ -0,0 +1,138 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.foundation.background
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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.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.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun TwoFactorLoginScreen(
onVerify: (String) -> Unit,
onBack: () -> Unit,
isLoading: Boolean,
error: String?
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
"security verification",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(80.dp))
Text(
"two-factor auth",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
"enter the 6-digit code from your app",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 8.dp, bottom = 48.dp)
)
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
modifier = Modifier.width(200.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(
textAlign = TextAlign.Center,
letterSpacing = 8.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f)
),
singleLine = true
)
if (error != null) {
Text(
text = error.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 12.dp)
)
} else {
Spacer(Modifier.height(12.dp))
}
Spacer(Modifier.height(36.dp))
Button(
onClick = { if (code.length == 6) onVerify(code) },
enabled = code.length == 6 && !isLoading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("verify", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
@@ -0,0 +1,175 @@
@file:OptIn(ExperimentalUuidApi::class)
package dev.zxq5.chatapp.android.feature.chat
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.api.model.ChatEvent
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.api.model.SpaceDto
import dev.zxq5.chatapp.android.core.service.MessageStreamService
import io.ktor.client.plugins.ResponseException
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
import kotlin.uuid.ExperimentalUuidApi
class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages
private val _channelId = MutableStateFlow<Long?>(null)
val channelId: StateFlow<Long?> = _channelId
private val _currentScreen = MutableStateFlow(Screen.CHAT)
val currentScreen: StateFlow<Screen> = _currentScreen
private val _currentUserId = MutableStateFlow<Int?>(null)
val currentUserId: StateFlow<Int?> = _currentUserId
private val _spaces = MutableStateFlow<List<SpaceDto>>(emptyList())
val spaces: StateFlow<List<SpaceDto>> = _spaces
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
private val _channelError = MutableStateFlow<String?>(null)
val channelError: StateFlow<String?> = _channelError
private var streamJob: Job? = null
var onUnauthorized: (() -> Unit)? = null
init {
_currentUserId.value = chatRepository.getUserId()
observeChannel()
loadAccessibleChannels()
}
fun loadAccessibleChannels() {
_error.value = null
_currentUserId.value = chatRepository.getUserId()
viewModelScope.launch {
runCatching {
chatRepository.getAccessibleChannels()
}.onSuccess { data ->
_spaces.value = data
}.onFailure { e ->
Log.e("Chat", "Failed to load spaces", e)
if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
onUnauthorized?.invoke()
} else {
_error.value = "Failed to load channels: ${e.message}"
}
}
}
}
@OptIn(ExperimentalTime::class)
private fun observeChannel() {
viewModelScope.launch {
_channelId.collect { id ->
streamJob?.cancel()
_messages.value = emptyList()
_channelError.value = null
if (id != null) {
streamJob = launch {
chatRepository.eventStream(id)
.catch { e ->
Log.e("Chat", "Stream error", e)
if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
onUnauthorized?.invoke()
} else {
_channelError.value = "Connection lost: ${e.message}"
}
}
.collect { event ->
when (event) {
is ChatEvent.SendMessage -> {
_messages.update { it + event.data }
}
is ChatEvent.EditMessage -> {
_messages.update { messages ->
messages.map {
if (it.id == event.data.id) event.data.message
else it
}
}
}
is ChatEvent.MessageAppendContent -> {
_messages.update { messages ->
messages.map { msg ->
if (msg.id == event.data.id) {
msg.copy(text = msg.text + event.data.content)
} else {
msg
}
}
}
}
}
}
}
}
}
}
}
fun navigateTo(screen: Screen) {
_currentScreen.value = screen
}
fun switchChannel(id: Long?) {
_channelId.value = id
MessageStreamService.instance?.activeChannelId = id
if (id != null) {
// Refresh user ID just in case it wasn't available at init
_currentUserId.value = chatRepository.getUserId()
}
}
fun sendMessage(text: String) {
val currentId = _channelId.value ?: return
viewModelScope.launch {
runCatching {
chatRepository.sendMessage(
channelId = currentId,
text = text
)
}.onFailure { e ->
Log.e("Chat", "Send message error", e)
if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
onUnauthorized?.invoke()
} else {
_channelError.value = "Failed to send message"
}
}
}
}
fun clearChat() {
_messages.value = emptyList()
_channelId.value = null
_currentUserId.value = null
_spaces.value = emptyList()
_error.value = null
_channelError.value = null
streamJob?.cancel()
chatRepository.resetClient()
MessageStreamService.instance?.activeChannelId = null
}
fun clearChannelError() {
_channelError.value = null
}
}
@@ -0,0 +1,5 @@
package dev.zxq5.chatapp.android.feature.chat
enum class Screen {
CHAT, CONTACTS, SETTINGS
}
@@ -1,5 +1,8 @@
package dev.zxq5.chatapp.android.ui.components
@file:OptIn(ExperimentalUuidApi::class)
package dev.zxq5.chatapp.android.feature.chat
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -26,21 +29,18 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
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.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -57,16 +57,24 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.MainScreen
import dev.zxq5.chatapp.android.model.Message
import dev.zxq5.chatapp.android.api.model.Channel
import dev.zxq5.chatapp.android.api.model.Message
import java.text.DateFormat
import java.util.Date
import kotlin.time.ExperimentalTime
import kotlin.uuid.ExperimentalUuidApi
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
fun ChatScreen(
viewModel: ChatViewModel,
onNavigateToSettings: () -> Unit,
onLogout: () -> Unit
) {
val selectedChannelId by viewModel.channelId.collectAsState()
if (selectedChannelId == null) {
@@ -85,18 +93,17 @@ fun ChatScreen(viewModel: ChatViewModel) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit) {
fun ChannelListScreen(
viewModel: ChatViewModel,
onChannelSelect: (Long) -> Unit
) {
val spaces by viewModel.spaces.collectAsState()
val error by viewModel.error.collectAsState()
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
Column {
Spacer(Modifier.height(8.dp))
Text(
"contacts",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(horizontal = 20.dp)
)
TopAppBar(
title = {
Text(
@@ -108,103 +115,69 @@ fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit)
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
),
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
)
Text(
"5 channels · end-to-end encrypted",
"Public channels - dms coming soon.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 2.dp)
)
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f))
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
Spacer(Modifier.width(10.dp))
Text(
"global · walkie talkie",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Surface(
color = Color.Transparent,
border = border(0.5.dp, MaterialTheme.colorScheme.outlineVariant),
shape = RoundedCornerShape(4.dp)
) {
Text(
"hold to talk",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
) { padding ->
if (error != null) {
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = error!!,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
)
Button(onClick = { viewModel.loadAccessibleChannels() }) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Retry")
}
}
},
bottomBar = { BottomDock(viewModel) }
) { padding ->
LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) {
items(10) { i ->
val id = i + 1
ChannelItem(id = id, onClick = { onChannelSelect(id) })
HorizontalDivider(
modifier = Modifier.padding(horizontal = 20.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)
)
} else {
LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) {
spaces.forEach { spaceDto ->
item {
Text(
text = spaceDto.name.lowercase(),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
items(spaceDto.channels) { channel ->
ChannelItem(channel = channel, onClick = { onChannelSelect(channel.id) })
HorizontalDivider(
modifier = Modifier.padding(horizontal = 20.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)
)
}
item {
Spacer(Modifier.height(16.dp))
}
}
}
}
}
}
@Composable
fun BottomDock(viewModel: ChatViewModel) {
val currentScreen by viewModel.currentScreen.collectAsState()
NavigationBar(
containerColor = MaterialTheme.colorScheme.background,
tonalElevation = 0.dp,
modifier = Modifier.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp))
) {
NavigationBarItem(
selected = currentScreen == MainScreen.CHAT,
onClick = { viewModel.navigateTo(MainScreen.CHAT) },
icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Chat") },
label = { Text("chat", style = MaterialTheme.typography.labelSmall) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
indicatorColor = Color.Transparent
)
)
NavigationBarItem(
selected = currentScreen == MainScreen.SETTINGS,
onClick = { viewModel.navigateTo(MainScreen.SETTINGS) },
icon = { Icon(Icons.Outlined.Settings, contentDescription = "Settings") },
label = { Text("settings", style = MaterialTheme.typography.labelSmall) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
indicatorColor = Color.Transparent
)
)
}
}
@Composable
fun ChannelItem(id: Int, onClick: () -> Unit) {
fun ChannelItem(channel: Channel, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -220,7 +193,7 @@ fun ChannelItem(id: Int, onClick: () -> Unit) {
contentAlignment = Alignment.Center
) {
Text(
"C$id",
channel.name.take(1).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -228,31 +201,30 @@ fun ChannelItem(id: Int, onClick: () -> Unit) {
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "channel $id",
text = channel.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "tap to join",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
if (channel.description != null) {
Text(
text = channel.description,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
}
Text(
"14:22",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit) {
fun MessageScreen(channelId: Long, viewModel: ChatViewModel, onBack: () -> Unit) {
val messages by viewModel.messages.collectAsState()
val currentUserId by viewModel.currentUserId.collectAsState()
val channelError by viewModel.channelError.collectAsState()
var input by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
@@ -260,8 +232,16 @@ fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit)
}
}
LaunchedEffect(channelError) {
channelError?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearChannelError()
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
@@ -287,6 +267,7 @@ fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit)
)
}
},
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
@@ -299,7 +280,7 @@ fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit)
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(messages) { message ->
items(messages, key = { it.id }) { message ->
MessageBubble(message, currentUserId)
}
item { Spacer(Modifier.height(10.dp)) }
@@ -384,10 +365,13 @@ fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit)
}
}
@OptIn(ExperimentalTime::class)
@Composable
fun MessageBubble(message: Message, currentUserId: Int?) {
val time = remember(message.timestamp) {
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(message.timestamp)).lowercase()
DateFormat.getTimeInstance(DateFormat.SHORT)
.format(Date(message.timestamp.toEpochMilliseconds()))
.lowercase()
}
val isMe = currentUserId != null && message.user_id == currentUserId
@@ -397,7 +381,7 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
) {
Surface(
color = if (isMe) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f),
color = if (isMe) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f),
shape = RoundedCornerShape(
topStart = 14.dp,
topEnd = 14.dp,
@@ -407,14 +391,7 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
border = border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
) {
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp)) {
if (!isMe) {
Text(
message.display_name.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 2.dp)
)
}
Text(
text = message.text,
style = MaterialTheme.typography.bodyLarge,
@@ -422,14 +399,15 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
)
}
}
Text(
text = time,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
text = if (!isMe) message.display_name.lowercase() + " . " + time else time,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
private fun border(width: androidx.compose.ui.unit.Dp, color: Color) =
androidx.compose.foundation.BorderStroke(width, color)
private fun border(width: Dp, color: Color) =
BorderStroke(width, color)
@@ -0,0 +1,52 @@
package dev.zxq5.chatapp.android.feature.contacts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactsScreen() {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = {
Text(
"contacts",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Contacts coming soon",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -0,0 +1,175 @@
package dev.zxq5.chatapp.android.feature.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.api.model.InviteRequest
import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class SettingsViewModel(private val settingsRepository: SettingsRepository) : ViewModel() {
private val _is2faEnabled = MutableStateFlow(false)
val is2faEnabled: StateFlow<Boolean> = _is2faEnabled
private val _totpQr = MutableStateFlow<QrResponse?>(null)
val totpQr: StateFlow<QrResponse?> = _totpQr
private val _totpError = MutableStateFlow<String?>(null)
val totpError: StateFlow<String?> = _totpError
private val _settingsError = MutableStateFlow<String?>(null)
val settingsError: StateFlow<String?> = _settingsError
private val _isSuccessState = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val isSuccessState: StateFlow<Map<String, Boolean>> = _isSuccessState
private val _lastInviteCode = MutableStateFlow<String?>(null)
val lastInviteCode: StateFlow<String?> = _lastInviteCode
fun clearMessages() {
_settingsError.value = null
_totpError.value = null
}
private fun triggerSuccess(key: String) {
viewModelScope.launch {
_isSuccessState.value = _isSuccessState.value + (key to true)
delay(5000)
_isSuccessState.value = _isSuccessState.value + (key to false)
}
}
fun createInvite(request: InviteRequest) {
viewModelScope.launch {
_settingsError.value = null
when (val result = settingsRepository.createInvite(request)) {
is ApiResult.Success -> {
_lastInviteCode.value = result.data
triggerSuccess("invite")
}
is ApiResult.HttpError -> _settingsError.value = result.message
is ApiResult.NetworkError -> _settingsError.value = result.message
}
}
}
fun fetchTotpStatus() {
viewModelScope.launch {
when (val result = settingsRepository.getTotpStatus()) {
is ApiResult.Success -> _is2faEnabled.value = result.data.isEnabled
else -> {}
}
}
}
fun fetchTotpQr(password: String) {
viewModelScope.launch {
_totpError.value = null
when (val result = settingsRepository.getTotpQr(password)) {
is ApiResult.Success -> {
_totpQr.value = result.data
}
is ApiResult.HttpError -> {
_totpError.value = result.message
}
is ApiResult.NetworkError -> {
_totpError.value = "Network error: ${result.message}"
}
}
}
}
fun confirmTotp(code: String) {
viewModelScope.launch {
_totpError.value = null
when (val result = settingsRepository.confirmTotp(code)) {
is ApiResult.Success -> {
_is2faEnabled.value = true
_totpQr.value = null
triggerSuccess("2fa")
}
is ApiResult.HttpError -> {
_totpError.value = result.message
}
is ApiResult.NetworkError -> {
_totpError.value = "Network error: ${result.message}"
}
}
}
}
fun disableTotp(password: String, totpCode: String) {
viewModelScope.launch {
_totpError.value = null
when (val result = settingsRepository.disableTotp(password, totpCode)) {
is ApiResult.Success -> {
_is2faEnabled.value = false
triggerSuccess("2fa")
}
is ApiResult.HttpError -> _totpError.value = result.message
is ApiResult.NetworkError -> _totpError.value = "Network error: ${result.message}"
}
}
}
fun changePassword(old: String, new: String) {
viewModelScope.launch {
clearMessages()
when (val result = settingsRepository.changePassword(old, new)) {
is ApiResult.Success -> {
triggerSuccess("password")
}
is ApiResult.HttpError -> _settingsError.value = result.message
is ApiResult.NetworkError -> _settingsError.value = "Network error: ${result.message}"
}
}
}
fun updateDisplayName(name: String?) {
viewModelScope.launch {
clearMessages()
if (settingsRepository.updateDisplayName(name)) {
triggerSuccess("display_name")
} else {
_settingsError.value = "Failed to update display name"
}
}
}
fun updateUsername(username: String) {
viewModelScope.launch {
clearMessages()
when (val result = settingsRepository.updateUsername(username)) {
is ApiResult.Success -> {
triggerSuccess("username")
}
is ApiResult.HttpError -> _settingsError.value = result.message
is ApiResult.NetworkError -> _settingsError.value = "Network error: ${result.message}"
}
}
}
fun deleteAccount(password: String, totpCode: String?, onLogout: () -> Unit) {
viewModelScope.launch {
clearMessages()
when (val result = settingsRepository.deleteAccount(password, totpCode)) {
is ApiResult.Success -> {
_isSuccessState.value = _isSuccessState.value + ("delete" to true)
delay(3000)
onLogout()
}
is ApiResult.HttpError -> _settingsError.value = result.message
is ApiResult.NetworkError -> _settingsError.value = "Network error: ${result.message}"
}
}
}
fun logout() {
settingsRepository.logout()
}
}
@@ -0,0 +1,638 @@
package dev.zxq5.chatapp.android.feature.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.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.ContentCopy
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
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.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.material3.rememberDatePickerState
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.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.util.Base64
import android.graphics.BitmapFactory
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import dev.zxq5.chatapp.android.api.model.InviteRequest
import kotlin.time.Duration.Companion.days
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class)
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel,
onLogout: () -> Unit
) {
val is2faEnabled by viewModel.is2faEnabled.collectAsState()
val totpQr by viewModel.totpQr.collectAsState()
val settingsError by viewModel.settingsError.collectAsState()
val isSuccessState by viewModel.isSuccessState.collectAsState()
val totpError by viewModel.totpError.collectAsState()
val lastInviteCode by viewModel.lastInviteCode.collectAsState()
LaunchedEffect(Unit) {
viewModel.clearMessages()
viewModel.fetchTotpStatus()
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = {
Text(
"settings",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
SettingsSection(title = "profile") {
var displayName by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
SettingsField(
label = "display name",
value = displayName,
onValueChange = { displayName = it },
buttonLabel = "update",
isSuccess = isSuccessState["display_name"] == true,
onClick = { viewModel.updateDisplayName(displayName.ifBlank { null }) }
)
Spacer(Modifier.height(16.dp))
SettingsField(
label = "username",
value = username,
onValueChange = { username = it },
buttonLabel = "update",
isSuccess = isSuccessState["username"] == true,
onClick = { if (username.isNotBlank()) viewModel.updateUsername(username) }
)
}
SettingsSection(title = "security") {
var oldPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
Text("change password", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp))
OutlinedTextField(
value = oldPassword,
onValueChange = { oldPassword = it },
label = { Text("old password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = { Text("new password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(12.dp))
SuccessButton(
onClick = {
viewModel.changePassword(oldPassword, newPassword)
oldPassword = ""
newPassword = ""
},
label = "update password",
isSuccess = isSuccessState["password"] == true,
enabled = oldPassword.isNotEmpty() && newPassword.isNotEmpty(),
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
var show2faSetup by remember { mutableStateOf(false) }
var setupPassword by remember { mutableStateOf("") }
var show2faDisable by remember { mutableStateOf(false) }
var disablePassword by remember { mutableStateOf("") }
var disableCode by remember { mutableStateOf("") }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("two-factor authentication", style = MaterialTheme.typography.bodyLarge)
Text(
if (is2faEnabled) "enabled" else "disabled",
style = MaterialTheme.typography.labelSmall,
color = if (is2faEnabled) Color.Green.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
if (!is2faEnabled) {
Button(
onClick = {
show2faSetup = !show2faSetup
if (!show2faSetup) setupPassword = ""
},
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), contentColor = MaterialTheme.colorScheme.onSurface)
) {
Text(if (show2faSetup) "cancel" else "setup", style = MaterialTheme.typography.labelSmall)
}
} else {
SuccessButton(
onClick = {
show2faDisable = !show2faDisable
if (!show2faDisable) {
disablePassword = ""
disableCode = ""
}
},
label = if (show2faDisable) "cancel" else "disable",
isSuccess = isSuccessState["2fa"] == true,
baseColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
contentColor = Color.Red,
successColor = Color.Red
)
}
}
if (show2faSetup && !is2faEnabled) {
Spacer(Modifier.height(16.dp))
if (totpQr == null) {
OutlinedTextField(
value = setupPassword,
onValueChange = { setupPassword = it },
label = { Text("confirm password to setup") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
Button(
onClick = { viewModel.fetchTotpQr(setupPassword) },
enabled = setupPassword.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Text("get qr code")
}
} else {
TwoFactorSetup(
qrCodeBase64 = totpQr?.qr_code,
error = totpError,
onConfirm = { viewModel.confirmTotp(it) }
)
}
}
if (show2faDisable && is2faEnabled) {
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = disablePassword,
onValueChange = { disablePassword = it },
label = { Text("password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = disableCode,
onValueChange = { if (it.length <= 6) disableCode = it },
label = { Text("2fa code") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer(Modifier.height(8.dp))
SuccessButton(
onClick = { viewModel.disableTotp(disablePassword, disableCode) },
label = "confirm disable",
isSuccess = isSuccessState["2fa"] == true,
baseColor = Color.Red,
enabled = disablePassword.isNotBlank() && disableCode.length == 6,
modifier = Modifier.fillMaxWidth()
)
}
if (totpError != null && !show2faSetup && !show2faDisable) {
Text(totpError!!.lowercase(), color = Color.Red, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp))
}
}
SettingsSection(title = "invite") {
var inviteName by remember { mutableStateOf("") }
var maxUses by remember { mutableStateOf("1") }
val clipboardManager = LocalClipboardManager.current
var showDatePicker by remember { mutableStateOf(false) }
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = System.currentTimeMillis() + 7.days.inWholeMilliseconds
)
Text("create invite token", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp))
OutlinedTextField(
value = inviteName,
onValueChange = { inviteName = it },
label = { Text("name") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = maxUses,
onValueChange = { if (it.all { c -> c.isDigit() }) maxUses = it },
label = { Text("max uses") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = datePickerState.selectedDateMillis?.let { Instant.fromEpochMilliseconds(it).toString().substringBefore("T") } ?: "",
onValueChange = {},
label = { Text("expiry date") },
readOnly = true,
trailingIcon = {
IconButton(onClick = { showDatePicker = true }) {
Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Select Date")
}
},
modifier = Modifier.fillMaxWidth().clickable { showDatePicker = true },
shape = RoundedCornerShape(8.dp)
)
if (showDatePicker) {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = { showDatePicker = false }) {
Text("ok")
}
}
) {
DatePicker(state = datePickerState)
}
}
Spacer(Modifier.height(12.dp))
SuccessButton(
onClick = {
val nowMs = System.currentTimeMillis()
val expiryMs = datePickerState.selectedDateMillis ?: (nowMs + 7.days.inWholeMilliseconds)
viewModel.createInvite(
InviteRequest(
name = inviteName,
max_uses = maxUses.toIntOrNull() ?: 1,
start_date = Instant.fromEpochMilliseconds(nowMs),
expiry_date = Instant.fromEpochMilliseconds(expiryMs)
)
)
},
label = "generate invite",
isSuccess = isSuccessState["invite"] == true,
enabled = inviteName.isNotBlank(),
modifier = Modifier.fillMaxWidth()
)
if (lastInviteCode != null) {
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = lastInviteCode!!,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(lastInviteCode!!))
}) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(20.dp))
}
}
}
}
SettingsSection(title = "session") {
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black)
) {
Text("logout")
}
}
SettingsSection(title = "danger zone", color = Color.Red.copy(alpha = 0.7f)) {
var deletePassword by remember { mutableStateOf("") }
var deleteTotp by remember { mutableStateOf("") }
var showDeleteConfirm by remember { mutableStateOf(false) }
if (!showDeleteConfirm) {
Button(
onClick = { showDeleteConfirm = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.1f), contentColor = Color.Red)
) {
Text("delete account")
}
} else {
Text("confirm account deletion", color = Color.Red, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = deletePassword,
onValueChange = { deletePassword = it },
label = { Text("password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
if (is2faEnabled) {
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = deleteTotp,
onValueChange = { if (it.length <= 6) deleteTotp = it },
label = { Text("2fa code") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { showDeleteConfirm = false },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text("cancel")
}
SuccessButton(
onClick = { viewModel.deleteAccount(
deletePassword, deleteTotp.ifBlank { null },
onLogout
) },
label = "delete forever",
isSuccess = isSuccessState["delete"] == true,
baseColor = Color.Red.copy(alpha = 0.1f),
contentColor = Color.Red,
successColor = Color.Red,
modifier = Modifier.weight(1f),
enabled = deletePassword.isNotEmpty() && (!is2faEnabled || deleteTotp.length == 6)
)
}
}
}
if (settingsError != null) {
Text(settingsError!!, color = Color.Red, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp))
}
}
}
}
@Composable
fun SettingsSection(
title: String,
color: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
content: @Composable () -> Unit
) {
var expanded by remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(title.lowercase(), style = MaterialTheme.typography.labelSmall, color = color)
Icon(
if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = color,
modifier = Modifier.size(16.dp)
)
}
AnimatedVisibility(visible = expanded) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).padding(bottom = 8.dp)) {
content()
}
}
}
}
@Composable
fun SettingsField(
label: String,
value: String,
onValueChange: (String) -> Unit,
buttonLabel: String,
isSuccess: Boolean,
onClick: () -> Unit
) {
Column {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = if (isSuccess) Color.Green else MaterialTheme.colorScheme.primary
)
)
Spacer(Modifier.height(8.dp))
SuccessButton(
onClick = onClick,
label = buttonLabel,
isSuccess = isSuccess,
enabled = value.isNotBlank(),
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
fun SuccessButton(
onClick: () -> Unit,
label: String,
isSuccess: Boolean,
modifier: Modifier = Modifier,
enabled: Boolean = true,
baseColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
successColor: Color = Color.Green.copy(alpha = 0.8f)
) {
val backgroundColor by animateColorAsState(
targetValue = if (isSuccess) successColor else baseColor,
animationSpec = tween(500)
)
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = backgroundColor,
contentColor = if (isSuccess) Color.White else contentColor
)
) {
Text(if (isSuccess) "success" else label)
}
}
@OptIn(ExperimentalTime::class)
@Composable
fun TwoFactorSetup(
qrCodeBase64: String?,
error: String?,
onConfirm: (String) -> Unit
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (qrCodeBase64 != null) {
val bitmap = remember(qrCodeBase64) {
val base64Data = qrCodeBase64.substringAfter("base64,")
val decodedString = Base64.decode(base64Data, Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size)
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "QR Code",
modifier = Modifier
.size(180.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.padding(8.dp)
)
}
} else {
CircularProgressIndicator(modifier = Modifier.size(40.dp))
}
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000") },
modifier = Modifier.width(150.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(textAlign = TextAlign.Center),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp)
)
if (error != null) {
Text(error.lowercase(), color = Color.Red, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp))
}
Spacer(Modifier.height(16.dp))
SuccessButton(
onClick = { onConfirm(code) },
label = "verify and enable",
isSuccess = false, // Managed by parent
enabled = code.length == 6,
modifier = Modifier.fillMaxWidth()
)
}
}
@@ -1,325 +0,0 @@
package dev.zxq5.chatapp.android.model
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.api.ApiClient
import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.api.QrResponse
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
enum class AuthMode {
LOGIN, SIGNUP
}
enum class MainScreen {
CHAT, SETTINGS
}
class ChatViewModel : ViewModel() {
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages
private val _channelId = MutableStateFlow<Int?>(null)
val channelId: StateFlow<Int?> = _channelId
private val _currentUserId = MutableStateFlow<Int?>(null)
val currentUserId: StateFlow<Int?> = _currentUserId
private val _currentScreen = MutableStateFlow(MainScreen.CHAT)
val currentScreen: StateFlow<MainScreen> = _currentScreen
val loginState = MutableStateFlow<LoginState>(LoginState.Idle)
// Tracks whether the user is viewing the Login or Signup screen
val authMode = MutableStateFlow(AuthMode.LOGIN)
// 2FA state
private val _totpQr = MutableStateFlow<QrResponse?>(null)
val totpQr: StateFlow<QrResponse?> = _totpQr
private val _is2faEnabled = MutableStateFlow(false)
val is2faEnabled: StateFlow<Boolean> = _is2faEnabled
private val _totpError = MutableStateFlow<String?>(null)
val totpError: StateFlow<String?> = _totpError
// Settings state
private val _settingsError = MutableStateFlow<String?>(null)
val settingsError: StateFlow<String?> = _settingsError
private val _settingsSuccess = MutableStateFlow<String?>(null)
val settingsSuccess: StateFlow<String?> = _settingsSuccess
fun clearSettingsMessages() {
_settingsError.value = null
_settingsSuccess.value = null
}
fun clearTotpError() {
_totpError.value = null
}
private var streamJob: Job? = null
init {
initAuth(ApiClient.hasToken())
}
fun initAuth(hasToken: Boolean) {
if (hasToken) {
val scope = ApiClient.getTokenScope()
if (scope == TokenScope.TOTP_PENDING) {
loginState.value = LoginState.TwoFactorRequired
} else if (scope == TokenScope.FULL) {
loginState.value = LoginState.Success
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
fetchTotpStatus()
observeChannel()
} else {
loginState.value = LoginState.Error("Unknown token scope: $scope")
}
} else {
loginState.value = LoginState.Idle
}
}
private fun observeChannel() {
// restart stream whenever channel changes
viewModelScope.launch {
_channelId.filterNotNull().collect { id ->
streamJob?.cancel()
_messages.value = emptyList()
streamJob = launch {
ApiClient.messageStream(id)
.catch { e ->
Log.e("Chat", "Stream error", e)
}
.collect { message ->
_messages.update { it + message }
}
}
}
}
}
fun navigateTo(screen: MainScreen) {
_currentScreen.value = screen
if (screen == MainScreen.SETTINGS) {
fetchTotpStatus()
}
}
fun switchChannel(id: Int?) {
_channelId.value = id
}
fun setAuthMode(mode: AuthMode) {
authMode.value = mode
// Clear errors when switching modes
if (loginState.value is LoginState.Error || loginState.value is LoginState.TwoFactorRequired) {
loginState.value = LoginState.Idle
}
}
fun sendMessage(text: String) {
val currentId = _channelId.value ?: return
viewModelScope.launch {
runCatching {
ApiClient.sendMessage(
channelId = currentId,
text = text
)
}.onFailure { e ->
Log.e("Chat", "Send message error", e)
}
}
}
fun login(username: String, password: String) {
viewModelScope.launch {
loginState.value = LoginState.Loading
when (val result = ApiClient.login(username, password)) {
is ApiResult.Success -> {
when (val scope = ApiClient.getTokenScope()) {
TokenScope.FULL -> {
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
loginState.value = LoginState.Success
fetchTotpStatus()
observeChannel()
}
TokenScope.TOTP_PENDING -> {
loginState.value = LoginState.TwoFactorRequired
}
else -> {
loginState.value = LoginState.Error("Unknown token scope: $scope")
}
}
}
is ApiResult.HttpError -> {
loginState.value = LoginState.Error(result.message)
}
is ApiResult.NetworkError -> {
loginState.value = LoginState.Error("Could not reach server: ${result.message}")
}
}
}
}
fun verifyLogin2fa(code: String) {
viewModelScope.launch {
loginState.value = LoginState.Loading
when (val result = ApiClient.verifyTotpLogin(code)) {
is ApiResult.Success -> {
val scope = ApiClient.getTokenScope()
if (scope == TokenScope.FULL) {
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
loginState.value = LoginState.Success
fetchTotpStatus()
observeChannel()
} else {
// token came back but scope is wrong — shouldn't happen
loginState.value = LoginState.Error("Unexpected token scope after verification")
}
}
is ApiResult.HttpError -> {
// stay on TOTP screen but show the error
loginState.value = LoginState.TwoFactorRequired
// use a separate error signal so we don't lose the TOTP state
_totpError.value = result.message
}
is ApiResult.NetworkError -> {
loginState.value = LoginState.TwoFactorRequired
_totpError.value = "Could not reach server: ${result.message}"
}
}
}
}
fun signup(username: String, email: String, password: String, accessToken: String) {
viewModelScope.launch {
loginState.value = LoginState.Loading
try {
when (val result = ApiClient.signup(username, email, password, accessToken)) {
is ApiResult.Success -> {
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
loginState.value = LoginState.Success
observeChannel()
}
is ApiResult.HttpError -> {
loginState.value = LoginState.Error(result.message)
}
is ApiResult.NetworkError -> {
loginState.value = LoginState.Error("Could not reach server: ${result.message}")
}
}
} catch (e: Exception) {
Log.e("Chat", "Signup error", e)
loginState.value = LoginState.Error("Signup failed: ${e.localizedMessage}")
}
}
}
fun logout() {
viewModelScope.launch {
ApiClient.logout()
_currentUserId.value = null
_is2faEnabled.value = false
loginState.value = LoginState.Idle
_messages.value = emptyList()
_channelId.value = null
_currentScreen.value = MainScreen.CHAT
streamJob?.cancel()
}
}
fun fetchTotpQr() {
viewModelScope.launch {
_totpQr.value = ApiClient.getTotpQr()
}
}
fun confirmTotp(code: String) {
viewModelScope.launch {
val success = ApiClient.confirmTotp(code)
if (success) {
_is2faEnabled.value = true
ApiClient.set2faEnabledLocal(true)
_totpQr.value = null
} else {
_totpError.value = "Invalid verification code"
}
}
}
fun fetchTotpStatus() {
viewModelScope.launch {
_is2faEnabled.value = ApiClient.getTotpStatus()
}
}
fun disableTotp() {
viewModelScope.launch {
when (val result = ApiClient.disableTotp()) {
is ApiResult.Success -> {
_is2faEnabled.value = false
_settingsSuccess.value = "2FA disabled successfully"
}
is ApiResult.HttpError -> {
_settingsError.value = result.message
}
is ApiResult.NetworkError -> {
_settingsError.value = "Network error: ${result.message}"
}
}
}
}
fun changePassword(old: String, new: String) {
viewModelScope.launch {
_settingsError.value = null
_settingsSuccess.value = null
when (val result = ApiClient.changePassword(old, new)) {
is ApiResult.Success -> {
_settingsSuccess.value = "Password updated"
}
is ApiResult.HttpError -> {
_settingsError.value = result.message
}
is ApiResult.NetworkError -> {
_settingsError.value = "Network error: ${result.message}"
}
}
}
}
fun updateDisplayName(name: String?) {
viewModelScope.launch {
_settingsError.value = null
_settingsSuccess.value = null
val success = ApiClient.updateDisplayName(name)
if (success) {
_settingsSuccess.value = "Display name updated"
} else {
_settingsError.value = "Failed to update display name"
}
}
}
}
object TokenScope {
const val FULL = "full";
const val TOTP_PENDING = "totp_pending";
}
@@ -1,11 +0,0 @@
package dev.zxq5.chatapp.android.model
import kotlinx.serialization.Serializable
@Serializable
data class Message(
val user_id: Int,
val display_name: String,
val text: String,
val timestamp: Long
)
@@ -1,10 +0,0 @@
package dev.zxq5.chatapp.android.model
import kotlinx.serialization.Serializable
@Serializable
data class SendMessage(
val user_id: Int,
val text: String,
val timestamp: Long
)
@@ -1,318 +0,0 @@
package dev.zxq5.chatapp.android.ui.components
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.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.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.remember
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.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.zxq5.chatapp.android.model.AuthMode
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.LoginState
@Composable
fun AuthScreen(viewModel: ChatViewModel, onSuccess: () -> Unit) {
val loginState by viewModel.loginState.collectAsState()
val authMode by viewModel.authMode.collectAsState()
val totpError by viewModel.totpError.collectAsState()
var username by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var accessToken by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(loginState) {
if (loginState is LoginState.Success) onSuccess()
}
LaunchedEffect(authMode) {
localError = null
}
if (loginState is LoginState.TwoFactorRequired ||
(loginState is LoginState.Loading && totpError != null)) {
TwoFactorLoginScreen(
onVerify = { code -> viewModel.verifyLogin2fa(code) },
onBack = {
viewModel.clearTotpError()
viewModel.setAuthMode(AuthMode.LOGIN)
},
isLoading = loginState is LoginState.Loading,
error = totpError
)
return
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(40.dp))
Text(
text = "messenger",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = if (authMode == AuthMode.LOGIN) "welcome back" else "create account",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 48.dp)
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TextField(
value = username,
onValueChange = { username = it },
label = "username"
)
if (authMode == AuthMode.SIGNUP) {
TextField(
value = email,
onValueChange = { email = it },
label = "email"
)
}
TextField(
value = password,
onValueChange = { password = it },
label = "password",
isPassword = true
)
if (authMode == AuthMode.SIGNUP) {
TextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "confirm password",
isPassword = true
)
TextField(
value = accessToken,
onValueChange = { accessToken = it },
label = "access token"
)
}
}
Spacer(Modifier.height(32.dp))
Button(
onClick = {
localError = null
if (authMode == AuthMode.LOGIN) {
if (username.isBlank() || password.isBlank()) {
localError = "fill all fields"
return@Button
}
viewModel.login(username, password)
} else {
if (username.isBlank() || email.isBlank() || password.isBlank() || accessToken.isBlank()) {
localError = "fill all fields"
return@Button
}
if (password != confirmPassword) {
localError = "passwords mismatch"
return@Button
}
viewModel.signup(username, email, password, accessToken)
}
},
enabled = loginState !is LoginState.Loading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.secondary
)
) {
if (loginState is LoginState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text(
if (authMode == AuthMode.LOGIN) "login" else "sign up",
style = MaterialTheme.typography.bodyLarge
)
}
}
val displayError = localError ?: (loginState as? LoginState.Error)?.message
if (displayError != null) {
Text(
text = displayError.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(Modifier.height(16.dp))
TextButton(
onClick = {
viewModel.setAuthMode(if (authMode == AuthMode.LOGIN) AuthMode.SIGNUP else AuthMode.LOGIN)
}
) {
Text(
if (authMode == AuthMode.LOGIN) "no account? sign up"
else "have account? login",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
fun TwoFactorLoginScreen(
onVerify: (String) -> Unit,
onBack: () -> Unit,
isLoading: Boolean,
error: String?
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
"security verification",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(80.dp))
Text(
"two-factor auth",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
"enter the 6-digit code from your app",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 8.dp, bottom = 48.dp)
)
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
modifier = Modifier.width(200.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(
textAlign = TextAlign.Center,
letterSpacing = 8.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f)
),
singleLine = true
)
if (error != null) {
Text(
text = error.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 12.dp)
)
} else {
Spacer(Modifier.height(12.dp)) // keep layout stable when no error
}
Spacer(Modifier.height(36.dp))
Button(
onClick = { if (code.length == 6) onVerify(code) },
enabled = code.length == 6 && !isLoading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("verify", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
@@ -1,368 +0,0 @@
package dev.zxq5.chatapp.android.ui.components
import androidx.compose.foundation.Image
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.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.Check
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.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.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.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.MainScreen
import android.util.Base64
import android.graphics.BitmapFactory
import androidx.compose.runtime.LaunchedEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel: ChatViewModel) {
val is2faEnabled by viewModel.is2faEnabled.collectAsState()
val totpQr by viewModel.totpQr.collectAsState()
val settingsError by viewModel.settingsError.collectAsState()
val settingsSuccess by viewModel.settingsSuccess.collectAsState()
var show2faSetup by remember { mutableStateOf(false) }
var displayName by remember { mutableStateOf("") }
var oldPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.clearSettingsMessages()
viewModel.fetchTotpStatus()
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = {
Text(
"settings",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
navigationIcon = {
IconButton(onClick = { viewModel.navigateTo(MainScreen.CHAT) }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
if (settingsError != null) {
Text(settingsError!!, color = Color.Red, style = MaterialTheme.typography.bodySmall)
}
if (settingsSuccess != null) {
Text(settingsSuccess!!, color = Color.Green, style = MaterialTheme.typography.bodySmall)
}
// Profile Section
Column {
Text(
"profile",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 12.dp)
)
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("display name") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
trailingIcon = {
IconButton(onClick = { viewModel.updateDisplayName(displayName.ifBlank { null }) }) {
Icon(Icons.Default.Check, "Save")
}
}
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
// Security Section
Column {
Text(
"account security",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 12.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"two-factor authentication",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
if (is2faEnabled) "enabled" else "disabled",
style = MaterialTheme.typography.labelSmall,
color = if (is2faEnabled) Color.Green.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
if (!is2faEnabled) {
Button(
onClick = {
show2faSetup = true
viewModel.fetchTotpQr()
},
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.height(32.dp)
) {
Text("setup", style = MaterialTheme.typography.labelSmall)
}
} else {
Button(
onClick = { viewModel.disableTotp() },
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Red.copy(alpha = 0.1f),
contentColor = Color.Red
),
modifier = Modifier.height(32.dp)
) {
Text("disable", style = MaterialTheme.typography.labelSmall)
}
}
}
if (show2faSetup && !is2faEnabled) {
Spacer(Modifier.height(16.dp))
TwoFactorSetup(
qrCodeBase64 = totpQr?.qr_code,
onConfirm = { code ->
viewModel.confirmTotp(code)
},
onCancel = { show2faSetup = false }
)
}
Spacer(Modifier.height(16.dp))
Text("change password", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp))
OutlinedTextField(
value = oldPassword,
onValueChange = { oldPassword = it },
label = { Text("old password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = { Text("new password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(12.dp))
Button(
onClick = {
viewModel.changePassword(oldPassword, newPassword)
oldPassword = ""
newPassword = ""
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
enabled = oldPassword.isNotEmpty() && newPassword.isNotEmpty()
) {
Text("update password")
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
// Application Section
Column {
Text(
"application",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 12.dp)
)
Button(
onClick = { viewModel.logout() },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Red.copy(alpha = 0.1f),
contentColor = Color.Red
)
) {
Text("logout", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
}
@Composable
fun TwoFactorSetup(
qrCodeBase64: String?,
onConfirm: (String) -> Unit,
onCancel: () -> Unit
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"scan qr code",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(16.dp))
if (qrCodeBase64 != null) {
val bitmap = remember(qrCodeBase64) {
val base64Data = qrCodeBase64.substringAfter("base64,")
val decodedString = Base64.decode(base64Data, Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size)
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "QR Code",
modifier = Modifier
.size(180.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White) // QR codes usually need white background
.padding(8.dp)
)
}
} else {
Box(
modifier = Modifier.size(180.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
modifier = Modifier.width(150.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
letterSpacing = 4.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant
)
)
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onCancel,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurfaceVariant)
) {
Text("cancel", style = MaterialTheme.typography.labelSmall)
}
Button(
onClick = { if (code.length == 6) onConfirm(code) },
enabled = code.length == 6,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp)
) {
Text("confirm", style = MaterialTheme.typography.labelSmall)
}
}
}
}
@@ -2,12 +2,14 @@ package dev.zxq5.chatapp.android.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
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.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@@ -31,6 +33,11 @@ fun TextField(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge,
keyboardOptions = if (isPassword) {
KeyboardOptions(keyboardType = KeyboardType.Password)
} else {
KeyboardOptions.Default
},
visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
@@ -40,6 +47,6 @@ fun TextField(
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
)
),
)
}
@@ -2,7 +2,7 @@ package dev.zxq5.chatapp.android.ui.theme
import androidx.compose.ui.graphics.Color
val Black = Color(0xFF0A0A0A)
val Black = Color(0xFF000000)
val DarkGrey = Color(0xFF0D0D0D)
val Grey = Color(0xFF141414)
val LightGrey = Color(0xFF1E1E1E)
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM18,14L6,14v-2h12v2zM18,11L6,11L6,9h12v2zM18,8L6,8L6,6h12v2z"/>
</vector>
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="uk.co.ben_gibson.git.link.SettingsState">
<option name="host" value="e0f86390-1091-4871-8aeb-f534fbc99cf0" />
</component>
</project>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="chatapp_dev@100.118.108.58" uuid="b14acf5d-6750-469b-8aea-59c8343eb11c">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://100.118.108.58:5432/chatapp_dev</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$PROJECT_DIR$/sql/test.sql" value="b14acf5d-6750-469b-8aea-59c8343eb11c" />
<file url="file://$PROJECT_DIR$/src/repo/user_repo.rs" value="b14acf5d-6750-469b-8aea-59c8343eb11c" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
</modules>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/migrations/20260412200102_message_id_to_uuid.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/sql/test.sql" dialect="PostgreSQL" />
<file url="PROJECT" dialect="PostgreSQL" />
</component>
</project>
+6
View 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>
+7 -4
View File
@@ -10,9 +10,9 @@ dotenv = "0.15.0"
futures-util = "0.3.31"
image = "0.25.8"
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
rand = "0.9.2"
rand = "0.8"
redis = { version = "0.25.4", features = ["tokio-comp"] }
reqwest = { version = "0.12.23", features = ["json"] }
reqwest = { version = "0.12.23", features = ["json", "stream"] }
rocket = { version = "0.5.1", features = ["json", "secrets"] }
rocket_cors = "0.6.0"
rocket_db_pools = { version = "0.2.0", features = ["deadpool_redis", "sqlx_macros", "sqlx_postgres"] }
@@ -20,8 +20,11 @@ rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
sha2 = "0.10.9"
sqlx = { version = "0.7.4", features = ["macros", "time"] }
sqlx = { version = "0.7.4", features = ["chrono", "macros", "postgres", "time", "uuid"] }
tokio = { version = "1.47.1", features = ["full"] }
totp-rs = { version = "5.7.0", features = ["gen_secret", "qr", "rand"] }
tracing = "0.1.44"
uuid = { version = "1.18.1", features = ["v4"] }
uuid = { version = "1.18.1", features = ["serde", "v4"] }
thiserror = "1.0.69"
utoipa = { version = "5.4.0", features = ["rocket_extras", "chrono"] }
clap = { version = "4.5", features = ["derive"] }
-4
View File
@@ -12,8 +12,6 @@ COPY cdn cdn
COPY src src
COPY Cargo.toml Cargo.toml
COPY Rocket.toml Rocket.toml
COPY static static
COPY templates templates
RUN apt-get update && apt-get install -y libssl-dev pkg-config
@@ -37,9 +35,7 @@ COPY --from=build /build/main ./
## copy runtime assets which may or may not exist
COPY --from=build /build/Rocket.toml ./Rocket.toml
COPY --from=build /build/static ./static
COPY --from=build /build/cdn ./cdn
COPY --from=build /build/template[s] ./templates
## ensure the container listens globally on port 8000
ENV ROCKET_ADDRESS=0.0.0.0
+1 -1
View File
@@ -1,7 +1,7 @@
[debug]
secret_key = "yYhvCGnRh/TrcHtB8sZqCFifrVmJxoKFLBYw/WWBZeU="
address = "0.0.0.0"
port = 8000
port = 8080
[debug.databases.postgres_db]
url = "postgresql://chatapp:chatapp@100.118.108.58:5432/chatapp_dev"
@@ -1,17 +1,18 @@
services:
backend:
container_name: chatapp_backend
build:
context: ./backend
context: .
args:
- DATABASE_URL=${DATABASE_URL}
ports:
- "8000:8000"
depends_on:
- redis
environment:
- ROCKET_SECRET_KEY=${ROCKET_SECRET_KEY}
- DATABASE_URL=${DATABASE_URL}
env_file:
- .env
redis:
container_name: chatapp_redis
image: docker.io/library/redis:alpine
ports:
- "6379:6379"
@@ -1,14 +1,16 @@
services:
backend:
container_name: chatapp_backend
image: git.zxq5.dev/zxq5/chatapp-backend:latest
image: git.zxq5.dev/zxq5/chatapp-backend:v0.4.1
ports:
- "8080:8000"
- "8000:8000"
depends_on:
- redis
env_file:
- .env
environment:
- ROCKET_SECRET_KEY=${ROCKET_SECRET_KEY}
- DATABASE_URL=${DATABASE_URL}
- RELEASE_MODE=1
redis:
container_name: chatapp_redis
image: docker.io/library/redis:alpine
@@ -1,49 +0,0 @@
-- Add migration script here
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(50) NOT NULL,
display_name VARCHAR(50),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE channels (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE messages (
id SERIAL PRIMARY KEY,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_edited BOOLEAN DEFAULT FALSE
);
create table attachments (
id SERIAL PRIMARY KEY,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
path TEXT NOT NULL
);
CREATE INDEX idx_messages_channel_id ON messages (channel_id, id DESC);
CREATE INDEX idx_new_messages ON messages(created_at DESC);
-- Create a function to update the updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create trigger for messages table
CREATE TRIGGER update_messages_updated_at
BEFORE UPDATE ON messages
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
@@ -1,20 +0,0 @@
-- Add migration script here
CREATE TABLE sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
token TEXT NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '7 days'
);
CREATE OR REPLACE FUNCTION cleanup_expired_sessions()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM sessions WHERE expires_at < NOW();
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_cleanup_sessions
AFTER INSERT ON sessions
EXECUTE FUNCTION cleanup_expired_sessions();
@@ -1,5 +0,0 @@
-- Add migration script here
ALTER TABLE users ADD COLUMN email VARCHAR(100);
ALTER TABLE users ADD COLUMN twofa_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64);
ALTER TABLE users ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
@@ -1,2 +0,0 @@
-- Add migration script here
ALTER TABLE users ALTER COLUMN twofa_enabled SET NOT NULL;
@@ -1,18 +0,0 @@
-- Add migration script here
CREATE TABLE access_codes (
-- identifiers
id SERIAL PRIMARY KEY,
creator_id INTEGER NOT NULL REFERENCES users(id),
-- code data
code VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
-- uses
uses INTEGER NOT NULL DEFAULT 0,
max_uses INTEGER NOT NULL DEFAULT 1,
-- time data
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '1 day'
);
@@ -1,10 +0,0 @@
-- Add migration script here
ALTER TABLE access_codes
ALTER COLUMN created_at
TYPE TIMESTAMP WITH TIME ZONE
USING created_at AT TIME ZONE 'UTC';
ALTER TABLE access_codes
ALTER COLUMN expires_at
TYPE TIMESTAMP WITH TIME ZONE
USING expires_at AT TIME ZONE 'UTC';
@@ -1,6 +0,0 @@
-- Add migration script here
TRUNCATE TABLE users CASCADE;
ALTER TABLE users DROP COLUMN password;
ALTER TABLE users ADD COLUMN pass_hash VARCHAR(255) NOT NULL;
ALTER TABLE users ADD CONSTRAINT users_username_unique UNIQUE (username);
@@ -1,13 +0,0 @@
-- Add migration script here
CREATE TYPE status AS ENUM ('pending', 'accepted', 'blocked');
CREATE TABLE relationships (
id SERIAL PRIMARY KEY,
from_user INTEGER NOT NULL REFERENCES users(id),
to_user INTEGER NOT NULL REFERENCES users(id),
status status NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT no_self_relationship CHECK (from_user != to_user),
CONSTRAINT unique_relationship UNIQUE (from_user, to_user)
);
@@ -0,0 +1,183 @@
-- Add migration script here
-- Add migration script here
CREATE TYPE user_role AS ENUM ('user', 'admin');
CREATE TYPE totp_status AS ENUM ('disabled', 'pending', 'enabled');
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY NOT NULL,
role user_role NOT NULL DEFAULT 'user',
-- profile
nickname VARCHAR(255),
-- basic auth
username VARCHAR(255) UNIQUE NOT NULL,
passhash VARCHAR(255) NOT NULL,
-- email
email VARCHAR(255),
email_verified BOOLEAN DEFAULT FALSE,
-- 2fa
totp_secret VARCHAR(255),
totp_status totp_status NOT NULL DEFAULT 'disabled',
-- update tracking
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE access_tokens (
id BIGSERIAL PRIMARY KEY NOT NULL,
creator_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
uses INTEGER NOT NULL DEFAULT 0,
max_uses INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY NOT NULL,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days'
);
CREATE TABLE spaces (
id BIGSERIAL PRIMARY KEY NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
owner_id BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE channels (
id BIGSERIAL PRIMARY KEY NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
space_id BIGINT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE space_members (
space_id BIGINT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
role user_role DEFAULT 'user',
PRIMARY KEY (space_id, user_id)
);
CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY NOT NULL,
channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_edited BOOLEAN DEFAULT FALSE
);
CREATE TABLE attachments (
id BIGSERIAL PRIMARY KEY NOT NULL,
message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
content_type VARCHAR(100) NOT NULL, -- mime type e.g. image/png, video/mp4
size_bytes BIGINT NOT NULL,
url TEXT NOT NULL, -- path to file on your CDN/storage
width INTEGER, -- null for non-image/video
height INTEGER, -- null for non-image/video
duration_ms INTEGER, -- null for non-audio/video
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TYPE relationship_status AS ENUM ('pending', 'accepted', 'blocked');
CREATE TABLE relationships (
id BIGSERIAL PRIMARY KEY NOT NULL,
from_user BIGINT NOT NULL REFERENCES users(id),
to_user BIGINT NOT NULL REFERENCES users(id),
status relationship_status NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT no_self_relationship CHECK (from_user != to_user),
CONSTRAINT unique_relationship UNIQUE (from_user, to_user)
);
CREATE INDEX idx_messages_channel_id ON messages(channel_id, created_at DESC);
CREATE INDEX idx_messages_user_id ON messages(user_id);
CREATE INDEX idx_attachments_message ON attachments(message_id);
CREATE INDEX idx_channels_space_id ON channels(space_id);
CREATE INDEX idx_space_members_user ON space_members(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_relationships_from ON relationships(from_user, to_user);
CREATE INDEX idx_relationships_to ON relationships(to_user);
CREATE INDEX idx_access_tokens_code ON access_tokens(code);
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER spaces_updated_at
BEFORE UPDATE ON spaces
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER channels_updated_at
BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER messages_updated_at
BEFORE UPDATE ON messages
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER relationships_updated_at
BEFORE UPDATE ON relationships
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER access_tokens_updated_at
BEFORE UPDATE ON access_tokens
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE OR REPLACE FUNCTION add_owner_to_space()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO space_members (space_id, user_id, role, joined_at)
VALUES (NEW.id, NEW.owner_id, 'admin', NOW());
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER space_owner_becomes_member
AFTER INSERT ON spaces
FOR EACH ROW EXECUTE FUNCTION add_owner_to_space();
@@ -0,0 +1,9 @@
ALTER TABLE attachments DROP CONSTRAINT attachments_message_id_fkey;
ALTER TABLE messages ALTER COLUMN id DROP DEFAULT;
ALTER TABLE messages ALTER COLUMN id TYPE uuid USING gen_random_uuid();
ALTER TABLE messages ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE attachments ALTER COLUMN message_id TYPE uuid USING gen_random_uuid();
ALTER TABLE attachments ADD CONSTRAINT attachments_message_id_fkey
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE;
+17
View File
@@ -0,0 +1,17 @@
WITH space1 AS (
INSERT INTO spaces (name, description, owner_id)
VALUES ('general', 'Boring chat idk', 1)
RETURNING id
),
space2 AS (
INSERT INTO spaces (name, description, owner_id)
VALUES ('Gaming', 'we lose games', 1)
RETURNING id
)
INSERT INTO channels (name, description, space_id)
SELECT 'General', 'General chat', id FROM space1 UNION ALL
SELECT 'Coding', 'Coding stuff', id FROM space1 UNION ALL
SELECT 'AI', '"/ask" here pls :)', id FROM space1 UNION ALL
SELECT 'The Game', '(You lost)', id FROM space2 UNION ALL
SELECT 'Backrooms', 'Beware of Smilers', id FROM space2 UNION ALL
SELECT 'SE', 'Space/Software engineering.', id FROM space2;
+193
View File
@@ -0,0 +1,193 @@
use crate::error::ApiResult;
use crate::model::auth::{AccessTokenForm, AuthResponse, LoginCredentials, SignupCredentials};
use crate::svc::access_token_svc::AccessTokenService;
use crate::svc::auth_svc::AuthService;
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome};
use rocket::serde::json::Json;
use rocket::serde::{Deserialize, Serialize};
use rocket::{Request, State};
use std::sync::LazyLock;
use std::time::{SystemTime, UNIX_EPOCH};
#[post("/signup", data = "<cred>")]
pub async fn signup(
cred: Json<SignupCredentials>,
svc: &State<AuthService>,
) -> ApiResult<Json<AuthResponse>> {
let response = svc
.signup(
&cred.email,
&cred.username,
&cred.password,
&cred.access_token,
)
.await?;
Ok(Json(response))
}
#[post("/login", data = "<cred>")]
pub async fn login(
cred: Json<LoginCredentials>,
svc: &State<AuthService>,
) -> ApiResult<Json<AuthResponse>> {
Ok(Json(svc.login(&cred.username, &cred.password).await?))
}
#[post("/invite", data = "<form>")]
pub async fn generate_invite(
session: AdminSession,
form: Json<AccessTokenForm>,
svc: &State<AccessTokenService>,
) -> ApiResult<String> {
svc.create(
session.uid,
&form.name,
form.max_uses,
form.start_date,
form.expiry_date,
)
.await
}
static JWT_SECRET: LazyLock<String> = LazyLock::new(|| std::env::var("JWT_SECRET").unwrap());
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum TokenScope {
Full,
TotpPending,
}
pub struct Session {
pub uid: i64,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Session {
type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match Claims::from_request(req).await {
Outcome::Success(user) if user.scope == TokenScope::Full => Outcome::Success(Session {
uid: user.sub as i64,
}),
Outcome::Success(_) => {
eprintln!("warning: user with scope other than Full attempted to access session");
Outcome::Error((Status::Forbidden, ()))
}
Outcome::Error(err) => {
eprintln!("Session request guard failed: {:?}", err);
Outcome::Error(err)
}
_ => unreachable!("forward should never be called"),
}
}
}
pub struct AdminSession {
pub uid: i64,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminSession {
type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
// First verify the session is valid
match Claims::from_request(req).await {
Outcome::Success(user) if user.scope == TokenScope::Full => {
let uid = user.sub as i64;
// Get AuthService from Rocket state
let auth_svc = match req.guard::<&State<AuthService>>().await {
Outcome::Success(svc) => svc,
Outcome::Error(err) => {
tracing::error!("AdminSession: Failed to get AuthService from state");
return Outcome::Error(err);
}
_ => unreachable!("forward should never be called"),
};
// Check if user is admin
match auth_svc.is_admin(uid).await {
Ok(true) => Outcome::Success(AdminSession { uid }),
Ok(false) => {
tracing::debug!("non-admin user attempted to access admin session");
Outcome::Error((Status::Forbidden, ()))
}
Err(err) => {
tracing::error!("AdminSession: is_admin check failed: {:?}", err);
Outcome::Error((Status::InternalServerError, ()))
}
}
}
Outcome::Success(_) => {
tracing::debug!("warning: user with scope other than Full attempted to access admin session");
Outcome::Error((Status::Forbidden, ()))
}
Outcome::Error(err) => {
tracing::debug!("AdminSession request guard failed: {:?}", err);
Outcome::Error(err)
}
_ => unreachable!("forward should never be called"),
}
}
}
#[derive(Serialize, Deserialize)]
pub struct Claims {
pub sub: i32,
pub exp: usize,
pub scope: TokenScope,
}
impl Claims {
pub fn new(user_id: usize, scope: TokenScope) -> Self {
Self {
sub: user_id as i32,
exp: (SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Failed to get time")
.as_secs()
+ 60 * 60 * 24 * 7) as usize,
scope,
}
}
pub fn encode(&self) -> String {
encode(
&Header::default(),
self,
&EncodingKey::from_secret(JWT_SECRET.as_bytes()),
)
.expect("unable to encode jwt")
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Claims {
type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let token = req
.headers()
.get_one("Authorization")
.and_then(|v| v.strip_prefix("Bearer "));
match token {
None => Outcome::Error((Status::Unauthorized, ())),
Some(t) => {
match decode::<Claims>(
t,
&DecodingKey::from_secret(JWT_SECRET.as_bytes()),
&Validation::default(),
) {
Ok(data) => Outcome::Success(data.claims),
Err(_) => Outcome::Error((Status::Unauthorized, ())),
}
}
}
}
}
@@ -26,7 +26,7 @@ pub async fn profile_pic(user_id: usize) -> Option<NamedFile> {
Some(image)
} else {
Some(
NamedFile::open("./cdn/profiles/full/default.svg")
NamedFile::open("../../cdn/profiles/full/default.svg")
.await
.ok()?,
)
+63
View File
@@ -0,0 +1,63 @@
use crate::api::auth::Session;
use crate::error::ApiResult;
use crate::svc::chat_svc::ChatService;
use chrono::{DateTime, Utc};
use rocket::response::stream::Event;
use rocket::serde::json::Json;
use rocket::serde::{Deserialize, Serialize};
use rocket::{Shutdown, State, ___internal_EventStream as EventStream};
use sqlx::FromRow;
use tokio::select;
use tokio::sync::broadcast;
use crate::model::event::{ChatEvent, ChatMsg};
#[post("/chat/<channel_id>", format = "json", data = "<msg>")]
pub async fn post_message(
msg: Json<ChatMsg>,
chat: &State<ChatService>,
session: Session,
channel_id: i64,
) -> ApiResult<()> {
chat.send(channel_id, session.uid, &msg.text, Utc::now()).await
}
#[get("/events/<channel_id>")]
pub async fn event_stream(
chat: &State<ChatService>,
s: Session,
mut shutdown: Shutdown,
channel_id: i64,
) -> ApiResult<EventStream![]> {
let messages = chat.fetch_latest_messages_desc(channel_id, 100)
.await?; // if get message returned err, inform user.
let mut rx = chat.subscribe(channel_id).await;
let id = s.uid;
Ok(EventStream! {
for msg in messages.into_iter().rev() {
// tracing::info!("sending: {:?}", serde_json::to_string(&ChatEvent::SendMessage(msg.clone())).unwrap());
yield Event::json(&ChatEvent::SendMessage(msg));
}
loop {
select!{
_ = &mut shutdown => break, // exit early on shutdown
event = rx.recv() => match event {
Ok(event) => {
// tracing::info!("yielding event: {event:?}");
yield Event::json(&event)
},
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("Receiver lagging on channel {channel_id} by {n} events",);
yield Event::comment("RecvError::Lagged");
}
Err(broadcast::error::RecvError::Closed) => {
tracing::info!("Broadcaster hung up on channel {channel_id}!");
break
},
},
}
}
})
}
+7
View File
@@ -0,0 +1,7 @@
pub mod auth;
pub mod chat;
pub mod totp;
pub mod settings;
pub mod cdn;
pub mod profile;
pub mod space;
+13
View File
@@ -0,0 +1,13 @@
use rocket::State;
use crate::api::auth::Session;
use crate::error::ApiResult;
use crate::svc::user_svc::UserService;
#[get("/users/<id>")]
pub async fn display_name(
id: i64,
_ag: Session,
svc: &State<UserService>,
) -> ApiResult<String> {
svc.get_username(id).await
}
+68
View File
@@ -0,0 +1,68 @@
use crate::api::auth::Session;
use crate::error::ApiResult;
use crate::svc::settings_svc::SettingsService;
use rocket::serde::json::Json;
use rocket::serde::{Deserialize, Serialize};
use rocket::State;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasswordForm {
pub old_password: String,
pub new_password: String,
}
#[post("/settings/password", data = "<form>")]
pub async fn change_password(
session: Session,
form: Json<PasswordForm>,
settings: &State<SettingsService>
) -> ApiResult<()> {
settings.change_password(
session.uid, &form.old_password, &form.new_password
).await
}
#[derive(Deserialize, Debug, Clone)]
pub struct DisplayNameForm {
pub display_name: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PasswordAnd2faForm {
pub password: String,
pub totp_code: Option<String>,
}
#[delete("/settings", data = "<data>")]
pub async fn delete_account(
session: Session,
data: Json<PasswordAnd2faForm>,
settings: &State<SettingsService>
) -> ApiResult<()> {
settings.delete_account(
session.uid, &data.password, &data.totp_code
).await
}
#[patch("/settings/display_name", data = "<new>")]
pub async fn change_display_name(
session: Session,
new: Json<DisplayNameForm>,
settings: &State<SettingsService>
) -> ApiResult<()> {
settings.change_display_name(session.uid, new.display_name.clone()).await
}
#[derive(Deserialize)]
pub struct UsernameForm {
pub username: String,
}
#[patch("/settings/username", data = "<new>")]
pub async fn change_username(
session: Session,
new: Json<UsernameForm>,
settings: &State<SettingsService>
) -> ApiResult<()> {
settings.change_username(session.uid, &new.username).await
}
+33
View File
@@ -0,0 +1,33 @@
use crate::api::auth::Session;
use crate::error::ApiResult;
use crate::model::space::Channel;
use crate::model::space::{Space, SpaceDto};
use crate::repo::{ChannelRepo, SpaceRepo};
use crate::svc::chat_svc::ChatService;
use rocket::State;
use rocket::serde::json::Json;
use std::sync::Arc;
#[get("/spaces")]
pub async fn list_spaces(space_repo: &State<Arc<dyn SpaceRepo>>) -> ApiResult<Json<Vec<Space>>> {
let spaces = space_repo.get_all().await?;
Ok(Json(spaces))
}
#[get("/spaces/<space_id>/channels")]
pub async fn list_channels(
space_id: i64,
channel_repo: &State<Arc<dyn ChannelRepo>>,
) -> ApiResult<Json<Vec<Channel>>> {
let channels = channel_repo.get_by_space_id(space_id).await?;
Ok(Json(channels))
}
#[get("/accessible_channels")]
pub async fn get_accessible_channels(
session: Session,
svc: &State<ChatService>,
) -> ApiResult<Json<Vec<SpaceDto>>> {
let space = svc.get_accessible_channels(session.uid).await?;
Ok(Json(space))
}
+120
View File
@@ -0,0 +1,120 @@
use crate::api::auth::{Claims, Session, TokenScope};
use crate::error::{ApiResult, AppError};
use crate::model::auth::AuthResponse;
use crate::svc::auth_svc::AuthService;
use rocket::serde::json::Json;
use rocket::serde::{Deserialize, Serialize};
use rocket::State;
use totp_rs::{Algorithm, TOTP};
#[derive(Debug, Deserialize)]
pub struct TOTPSixDigitCode {
code: String,
}
#[derive(Debug, sqlx::Type, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
#[sqlx(type_name = "totp_status", rename_all = "lowercase")]
pub enum TotpStatus {
Enabled,
Pending,
Disabled,
}
#[derive(Serialize)]
pub struct QrResponse {
qr_code: String,
}
#[derive(Deserialize)]
pub struct TotpVerifyRequest {
pub code: String,
}
#[derive(Deserialize)]
pub struct PasswordConfirmation {
password: String,
}
#[derive(Deserialize)]
pub struct PasswordAnd2fa {
pub password: String,
pub totp_code: String,
}
pub fn totp_gen(user_id: i64, secret: &[u8]) -> ApiResult<TOTP> {
TOTP::new(
Algorithm::SHA1,
6,
1,
30,
secret.to_owned(),
Some("chat.zxq5.dev".to_string()),
format!("{}", user_id),
)
.map_err(|_| AppError::internal("failed to generate totp"))
}
#[post("/totp", data = "<form>")]
pub async fn confirm_totp(
user: Session,
form: Json<TOTPSixDigitCode>,
svc: &State<AuthService>,
) -> ApiResult<()> {
svc.confirm_totp(user.uid, &form.code).await
}
#[post("/totp.jpg", data = "<form>")]
pub async fn get_totp(
user: Session,
form: Json<PasswordConfirmation>,
svc: &State<AuthService>,
) -> ApiResult<Json<QrResponse>> {
let secret = svc.get_or_create_totp_secret(user.uid, &form.password).await?;
let qr_b64 = totp_gen(user.uid, secret.as_bytes())
.map_err(|_| AppError::internal("invalid totp secret"))?
.get_qr_base64()
.map_err(|_| AppError::internal("failed to generate qr code"))?;
Ok(Json(QrResponse {
qr_code: format!("data:image/png;base64,{}", qr_b64),
}))
}
#[get("/totp/status")]
pub async fn get_totp_status(
user: Session,
svc: &State<AuthService>,
) -> ApiResult<Json<TotpStatus>> {
Ok(Json(
svc.get_totp_status(user.uid).await?
.then_some(TotpStatus::Enabled)
.unwrap_or(TotpStatus::Disabled),
))
}
#[delete("/totp", data = "<form>")]
pub async fn disable_totp(
user: Session,
form: Json<PasswordAnd2fa>,
svc: &State<AuthService>,
) -> ApiResult<Json<AuthResponse>> {
let response = svc.disable_totp(user.uid, &form.password, &form.totp_code).await?;
Ok(Json(response))
}
#[post("/totp/verify", data = "<body>")]
pub async fn verify_totp(
claims: Claims,
body: Json<TotpVerifyRequest>,
svc: &State<AuthService>,
) -> ApiResult<Json<AuthResponse>> {
// reject if they somehow got here with a full token
if claims.scope != TokenScope::TotpPending {
return Err(AppError::Forbidden);
}
let response = svc.login_totp(claims.sub as i64, &body.code).await?;
Ok(Json(response))
}
-211
View File
@@ -1,211 +0,0 @@
use argon2::{
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
password_hash::{SaltString, rand_core::OsRng},
};
use jsonwebtoken::{EncodingKey, Header, encode};
use rocket::{
http::{CookieJar, Status},
response::{Redirect, status::BadRequest},
serde::json::Json,
time::OffsetDateTime,
};
use rocket_db_pools::Connection;
use rocket_dyn_templates::{Template, context};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
auth::session::{Claims, Session, TokenScope},
db::Postgres,
user::User,
};
#[derive(Serialize, Deserialize)]
pub struct SignupCredentials {
pub email: String,
pub username: String,
pub password: String,
pub access_token: String,
}
#[derive(Serialize, Deserialize)]
pub struct LoginCredentials {
pub username: String,
pub password: String,
}
#[derive(Serialize, Deserialize)]
pub struct AuthResponse {
pub token: String,
pub totp_required: bool,
}
#[get("/signup")]
pub async fn signup_page() -> Template {
Template::render("signup", context!())
}
#[post("/signup", data = "<cred>")]
pub async fn signup(
cred: Json<SignupCredentials>,
jar: &CookieJar<'_>,
mut db: Connection<Postgres>,
) -> Result<Json<AuthResponse>, Status> {
let token_id = AccessToken::validate(&cred.access_token, &mut db)
.await
.map_err(|_| Status::Unauthorized)?;
let salt = SaltString::generate(&mut OsRng);
let hashed = Argon2::default()
.hash_password(cred.password.as_bytes(), &salt)
.map_err(|_| Status::InternalServerError)?
.to_string();
let result = sqlx::query!(
"INSERT INTO users (email, username, pass_hash) VALUES ($1, $2, $3) RETURNING id",
cred.email,
cred.username,
hashed,
)
.fetch_one(&mut **db)
.await
.map_err(|_| Status::InternalServerError)?;
let jwt = Claims::new(result.id as usize, TokenScope::Full).encode();
token_id
.use_token(&mut db)
.await
.expect("unable to use access code");
Ok(Json(AuthResponse {
token: jwt,
totp_required: false,
}))
}
#[get("/login")]
pub async fn login_page() -> Template {
Template::render("login", context!())
}
#[post("/login", data = "<cred>")]
pub async fn login(
mut db: Connection<Postgres>,
cred: Json<LoginCredentials>,
) -> Result<Json<AuthResponse>, Status> {
println!("e");
let row = sqlx::query!(
"SELECT id, pass_hash, twofa_enabled FROM users WHERE username = $1",
cred.username,
)
.fetch_one(&mut **db)
.await
.map_err(|_| Status::Unauthorized)?;
println!("ok");
// verify password as before
let parsed_hash = PasswordHash::new(&row.pass_hash).map_err(|_| Status::InternalServerError)?;
Argon2::default()
.verify_password(cred.password.as_bytes(), &parsed_hash)
.map_err(|_| Status::Unauthorized)?;
println!("ok2");
let user_id = row.id as usize;
// issue either a partial or full token depending on 2FA status
let (session, totp_required) = if row.twofa_enabled {
(Claims::new(user_id, TokenScope::TotpPending), true)
} else {
(Claims::new(user_id, TokenScope::Full), false)
};
Ok(Json(AuthResponse {
token: session.encode(),
totp_required,
}))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessTokenForm {
pub name: String,
pub max_uses: usize,
pub expiry_date: usize,
pub start_date: usize,
}
#[get("/invite")]
pub async fn invite_page(_s: Session) -> Template {
Template::render("invite", context! {})
}
#[post("/invite", data = "<form>")]
pub async fn generate_invite(
session: Session,
mut db: Connection<Postgres>,
form: Json<AccessTokenForm>,
) -> Result<String, Status> {
if form.start_date > form.expiry_date {
return Err(Status::BadRequest);
}
let code = Uuid::new_v4().to_string();
sqlx::query!(
"INSERT INTO access_codes (name, code, creator_id, max_uses, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
form.name,
code,
session.user_id as i32,
form.max_uses as i32,
OffsetDateTime::from_unix_timestamp_nanos(form.start_date as i128 * 1_000_000).unwrap(),
OffsetDateTime::from_unix_timestamp_nanos(form.expiry_date as i128 * 1_000_000).unwrap()
)
.fetch_one(&mut **db)
.await
.map_err(|_| Status::InternalServerError)?;
Ok(code)
}
pub struct AccessToken {
id: i32,
_code: String,
}
impl AccessToken {
pub async fn validate(
token: &str,
db: &mut Connection<Postgres>,
) -> Result<AccessToken, String> {
match sqlx::query!(
"SELECT id FROM access_codes
WHERE code = $1
AND created_at < NOW()
AND expires_at > NOW()
AND uses < max_uses",
token
)
.fetch_one(&mut ***db)
.await
{
Ok(row) => Ok(AccessToken {
id: row.id,
_code: token.to_string(),
}),
Err(_) => Err(String::from("Invalid or Expired token!")),
}
}
pub async fn use_token(&self, db: &mut Connection<Postgres>) -> Result<(), String> {
sqlx::query!(
"UPDATE access_codes SET uses = uses + 1 WHERE id = $1",
self.id
)
.execute(&mut ***db)
.await
.map_err(|_| String::from("Invalid or Expired token!"))?;
Ok(())
}
}

Some files were not shown because too many files have changed in this diff Show More