6 Commits

128 changed files with 5907 additions and 2926 deletions
+3
View File
@@ -1,6 +1,9 @@
*/target
.env
.log*
Cargo.lock
.cargo/
.sqlx/
docker-compose*
+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/
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<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>
</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" 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>
+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>
+9
View File
@@ -0,0 +1,9 @@
<component name="libraryTable">
<library name="highlight(1)">
<CLASSES>
<root url="jar://$PROJECT_DIR$/backend/static/highlight(1).zip!/" />
</CLASSES>
<JAVADOC />
<SOURCES />
</library>
</component>
+6
View File
@@ -0,0 +1,6 @@
<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>
</project>
+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/chatapp.iml" filepath="$PROJECT_DIR$/.idea/chatapp.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+6
View File
@@ -10,6 +10,12 @@
"command": "clippy" // rust-analyzer.check.command (default: "check")
}
}
},
"nu": {
"binary": {
"path": "/home/fantasypvp/.cargo/bin/nu",
"arguments": ["--lsp"]
}
}
}
}
+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" />
+33 -2
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)
+26
View File
@@ -19,3 +19,29 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-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.**
+10
View File
@@ -3,6 +3,10 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<application
android:name=".ChatApplication"
@@ -15,6 +19,12 @@
android:supportsRtl="true"
android:theme="@style/Theme.Chatapp"
android:usesCleartextTraffic="true">
<service
android:name=".core.service.MessageStreamService"
android:foregroundServiceType="dataSync"
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)
}
}
@@ -4,47 +4,182 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
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))
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)
val authState by authViewModel.authState.collectAsState()
val currentScreen by chatViewModel.currentScreen.collectAsState()
val selectedChannelId by chatViewModel.channelId.collectAsState()
LaunchedEffect(authState) {
when (authState) {
AuthState.Authenticated -> MessageStreamService.start(this@MainActivity)
AuthState.Unauthenticated -> MessageStreamService.stop(this@MainActivity)
AuthState.AwaitingTotp -> {}
}
}
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 = viewModel,
onSuccess = { }
)
}
}
} 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,65 @@
package dev.zxq5.chatapp.android.api
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
import dev.zxq5.chatapp.android.api.model.Message
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
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(user_id = userId, text = text, timestamp = Clock.System.now()))
}
}
fun messageStream(channelId: Long): Flow<Message> = 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<Message>(json) }
.onSuccess { emit(it) }
}
}
}
}
}
@@ -0,0 +1,171 @@
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.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 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?)
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@@ -7,5 +7,3 @@ 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
@@ -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 Message @OptIn(ExperimentalTime::class) constructor(
val user_id: Int,
val display_name: String,
val text: String,
val timestamp: Instant
)
@@ -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,12 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@Serializable
data class SendMessage @OptIn(ExperimentalTime::class) constructor(
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
@@ -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"
@@ -8,12 +8,14 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONObject
// 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 +27,54 @@ object TokenStore {
)
}
fun save(context: Context, token: String) =
prefs(context).edit { putString(KEY, token) }
fun save(token: String) =
prefs().edit { putString(KEY, token) }
fun get(context: Context): String? =
prefs(context).getString(KEY, null)
fun get(): String? =
prefs().getString(KEY, null)
fun save2faEnabled(context: Context, enabled: Boolean) =
prefs(context).edit { putBoolean(TWOFA_KEY, enabled) }
fun save2faEnabled( enabled: Boolean) =
prefs().edit { putBoolean(TWOFA_KEY, enabled) }
fun is2faEnabled(context: Context): Boolean =
prefs(context).getBoolean(TWOFA_KEY, false)
fun is2faEnabled(): Boolean =
prefs().getBoolean(TWOFA_KEY, false)
fun clear(context: Context) =
prefs(context).edit { remove(KEY).remove(TWOFA_KEY) }
fun clear() =
prefs().edit { remove(KEY).remove(TWOFA_KEY) }
fun getUserId(context: Context): Int? {
val token = get(context) ?: return null
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)
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
// 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,104 @@
package dev.zxq5.chatapp.android.core.service
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import dev.zxq5.chatapp.android.ChatApplication
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
// core/service/MessageStreamService.kt
class MessageStreamService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var notificationService: NotificationService
private lateinit var chatRepository: ChatRepository
// which channel the user is currently looking at
// set by the ViewModel when the user opens/closes a channel
var activeChannelId: Long? = null
set(value) {
field = value
Log.d("Service", "activeChannelId set to $value")
if (value != null) {
// restart stream with new channel
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)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
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 {
startForeground(
NotificationService.FOREGROUND_NOTIFICATION_ID,
notificationService.buildForegroundNotification()
)
observeMessages()
return START_STICKY // restart if killed
}
private fun observeMessages() {
val channelId = activeChannelId ?: chatRepository.getLastActiveChannel()
Log.d("Service", "observeMessages called, channelId=$channelId")
if (channelId == null) {
Log.d("Service", "No channel to observe, waiting for switchChannel")
return
}
Log.d("Service", "Starting stream for channel $channelId")
currentStreamJob = serviceScope.launch {
chatRepository.messageStream(channelId)
.catch { e -> Log.e("Service", "Stream error", e) }
.collect { message ->
if (!ChatApplication.AppState.isInForeground) { // no channel focused, always notify
notificationService.showMessageNotification(
conversationId = activeChannelId.toString(),
senderName = message.display_name,
messagePreview = message.text.take(80)
)
}
}
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
instance = null
serviceScope.cancel()
}
}
@@ -0,0 +1,94 @@
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 FOREGROUND_NOTIFICATION_ID = 1 // ← this needs to exist
}
private val manager = context.getSystemService(NotificationManager::class.java)
fun createChannels() {
// channel for new message notifications
val messageChannel = NotificationChannel(
CHANNEL_ID,
"Messages",
NotificationManager.IMPORTANCE_HIGH
).apply {
enableVibration(true)
}
// channel for the persistent foreground service notification
// low importance so it doesn't make noise
val serviceChannel = NotificationChannel(
"service",
"Background connection",
NotificationManager.IMPORTANCE_LOW
)
val mgr = context.getSystemService(NotificationManager::class.java)
mgr.createNotificationChannel(messageChannel)
mgr.createNotificationChannel(serviceChannel)
}
fun buildForegroundNotification(): Notification {
return NotificationCompat.Builder(context, "service")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("chatapp")
.setContentText("Connected")
.setOngoing(true)
.setSilent(true)
.build()
}
fun showMessageNotification(
conversationId: String,
senderName: String,
messagePreview: String, // for E2E this would be "New message" — no plaintext
notificationId: Int = conversationId.hashCode()
) {
// intent that opens the app to the right conversation when tapped
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, "messages")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(senderName)
.setContentText(messagePreview)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setAutoCancel(true) // dismiss on tap
.build()
manager.notify(notificationId, notification)
}
fun dismissNotification(conversationId: String) {
manager.cancel(conversationId.hashCode())
}
fun dismissAll() {
manager.cancelAll()
}
}
@@ -1,31 +1,79 @@
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
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 {
@@ -0,0 +1,50 @@
package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.ChatClient
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 messageStream(channelId: Long): Flow<Message> {
_lastActiveChannel = channelId
return getChatClient()?.messageStream(channelId) ?: emptyFlow()
}
}
@@ -0,0 +1,67 @@
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.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 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.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.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.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.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,130 @@
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.Channel
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.api.model.Space
import dev.zxq5.chatapp.android.api.model.SpaceDto
import dev.zxq5.chatapp.android.core.service.MessageStreamService
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
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
init {
_currentUserId.value = chatRepository.getUserId()
observeChannel()
loadAccessibleChannels()
}
fun loadAccessibleChannels() {
_error.value = null
viewModelScope.launch {
runCatching {
chatRepository.getAccessibleChannels()
}.onSuccess { data ->
_spaces.value = data
}.onFailure { e ->
Log.e("Chat", "Failed to load spaces", e)
_error.value = "Failed to load channels: ${e.message}"
}
}
}
private fun observeChannel() {
viewModelScope.launch {
_channelId.collect { id ->
streamJob?.cancel()
_messages.value = emptyList()
_channelError.value = null
if (id != null) {
streamJob = launch {
chatRepository.messageStream(id)
.catch { e ->
Log.e("Chat", "Stream error", e)
_channelError.value = "Connection lost: ${e.message}"
}
.collect { message ->
_messages.update { it + message }
}
}
}
}
}
}
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)
_channelError.value = "Failed to send message"
}
}
}
fun clearChat() {
_messages.value = emptyList()
_channelId.value = null
_currentUserId.value = null
_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,6 @@
package dev.zxq5.chatapp.android.ui.components
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 +27,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 +55,23 @@ 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
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
fun ChatScreen(
viewModel: ChatViewModel,
onNavigateToSettings: () -> Unit,
onLogout: () -> Unit
) {
val selectedChannelId by viewModel.channelId.collectAsState()
if (selectedChannelId == null) {
@@ -85,18 +90,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 +112,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 +190,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 +198,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 +229,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 +264,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
)
@@ -384,10 +362,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
@@ -409,7 +390,7 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp)) {
if (!isMe) {
Text(
message.display_name.lowercase(),
message.display_name?.lowercase() ?: "unknown",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 2.dp)
@@ -431,5 +412,5 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
}
}
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,157 @@
package dev.zxq5.chatapp.android.feature.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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
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 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,525 @@
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.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.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.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.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.text.style.TextAlign
@OptIn(ExperimentalMaterial3Api::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()
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 = "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)
)
}
}
}
SettingsSection(title = "session") {
Spacer(Modifier.height(16.dp))
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black)
) {
Text("logout")
}
}
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)
}
}
@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(24.dp))
Button(
onClick = { if (code.length == 6) onConfirm(code) },
enabled = code.length == 6,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Text("confirm code")
}
}
}
@@ -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)
}
}
}
}
@@ -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/
+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/schema.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>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/sql/schema.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>
+5 -2
View File
@@ -10,7 +10,7 @@ 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"] }
rocket = { version = "0.5.1", features = ["json", "secrets"] }
@@ -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"] }
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"] }
thiserror = "1.0.69"
utoipa = { version = "5.4.0", features = ["rocket_extras", "chrono"] }
clap = { version = "4.5", features = ["derive"] }
@@ -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();
@@ -1,21 +1,46 @@
use std::{
sync::LazyLock,
time::{SystemTime, UNIX_EPOCH},
};
use crate::error::ApiResult;
use crate::model::auth::{AccessTokenForm, AuthResponse, LoginCredentials, SignupCredentials};
use crate::svc::auth_svc::AuthService;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
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};
use crate::svc::access_token_svc::AccessTokenService;
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use rand::Rng;
use rocket::{
Request,
http::Status,
request::{self, FromRequest, Outcome},
};
use rocket_db_pools::Connection;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256, digest::block_buffer::Lazy};
use sqlx::postgres::PgQueryResult;
#[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))
}
use crate::db::Postgres;
#[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: Session,
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());
@@ -27,7 +52,7 @@ pub enum TokenScope {
}
pub struct Session {
pub user_id: usize,
pub uid: i64,
}
#[rocket::async_trait]
@@ -37,7 +62,7 @@ impl<'r> FromRequest<'r> for Session {
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 {
user_id: user.sub as usize,
uid: user.sub as i64,
}),
Outcome::Success(_) => {
eprintln!("warning: user with scope other than Full attempted to access session");
@@ -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()?,
)
+70
View File
@@ -0,0 +1,70 @@
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;
/// ---------- Rocket routes ----------
#[derive(Debug, Serialize, Deserialize, Clone, FromRow)]
pub struct ChatMsg {
pub display_name: Option<String>,
pub user_id: i64,
pub text: String,
pub timestamp: DateTime<Utc>,
}
#[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.get_messages(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 {
yield Event::json(&msg);
}
loop {
select!{
_ = &mut shutdown => break, // exit early on shutdown
msg = rx.recv() => match msg {
Ok(msg) => {
tracing::info!("yielding message!");
yield Event::json(&msg)
},
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
}
+36
View File
@@ -0,0 +1,36 @@
use crate::error::ApiResult;
use crate::model::space::{Space, SpaceDto};
use crate::model::space::Channel;
use crate::repo::{SpaceRepo, ChannelRepo};
use rocket::serde::json::Json;
use rocket::State;
use std::sync::Arc;
use crate::api::auth::Session;
use crate::svc::chat_svc::ChatService;
#[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?;
println!("{:?}", space);
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(())
}
}
-12
View File
@@ -1,12 +0,0 @@
pub mod account;
pub mod profile;
pub mod session;
pub mod two_factor;
pub use session::Session;
pub use account::{generate_invite, invite_page, login, login_page, signup, signup_page};
pub use profile::{change_display_name, change_password};
pub use two_factor::{
confirm_totp, disable_totp, get_totp, get_totp_status, mfa_page, verify_totp,
};
-84
View File
@@ -1,84 +0,0 @@
use argon2::{
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
password_hash::{SaltString, rand_core::OsRng},
};
use rocket::{http::Status, serde::json::Json};
use rocket_db_pools::Connection;
use serde::{Deserialize, Serialize};
use crate::{auth::Session, db::Postgres, user::User};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasswordForm {
old_password: String,
new_password: String,
}
#[post("/settings/password", data = "<form>")]
pub async fn change_password(
session: Session,
mut db: Connection<Postgres>,
form: Json<PasswordForm>,
) -> Result<(), Status> {
let mut user = User::get_by_id(session.user_id, &mut db)
.await
.ok_or(Status::NotFound)
.inspect_err(|_| {
tracing::error!(
"Valid session does not have a valid user. ID: {}",
session.user_id
)
})?;
let parsed_hash = PasswordHash::new(&user.pass_hash)
.inspect_err(|e| tracing::error!("Failed to parse hash for password! uid:{} {e}", user.id))
.map_err(|_| Status::InternalServerError)?;
Argon2::default()
.verify_password(form.old_password.as_bytes(), &parsed_hash)
.map_err(|_| Status::Unauthorized)?;
// old password is correct, so new one can be set.
let salt = SaltString::generate(&mut OsRng);
let hashed = Argon2::default()
.hash_password(form.new_password.as_bytes(), &salt)
.inspect_err(|e| tracing::error!("failed to hash password! {e}"))
.map_err(|_| Status::InternalServerError)?
.to_string();
user.set_pass_hash(hashed, &mut db)
.await
.inspect_err(|e| tracing::error!("{e}"))
.map_err(|_| Status::InternalServerError)?;
Ok(())
}
#[derive(Deserialize, Debug, Clone)]
pub struct DisplayNameForm {
pub display_name: Option<String>,
}
#[post("/settings/display_name", data = "<new>")]
pub async fn change_display_name(
session: Session,
mut db: Connection<Postgres>,
new: Json<DisplayNameForm>,
) -> Result<(), Status> {
let mut user = User::get_by_id(session.user_id, &mut db)
.await
.ok_or(Status::NotFound)
.inspect_err(|_| {
tracing::error!(
"Valid session does not have a valid user. ID: {}",
session.user_id
)
})?;
user.set_display_name(new.display_name.clone(), &mut db)
.await
.inspect_err(|e| tracing::error!("{e}"))
.map_err(|_| Status::InternalServerError)?;
Ok(())
}
-286
View File
@@ -1,286 +0,0 @@
use rocket::{
Request,
http::Status,
outcome::{Outcome, try_outcome},
request::{self, FromRequest},
response::status::{self},
serde::json::Json,
};
use rocket_db_pools::Connection;
use rocket_dyn_templates::{Template, context};
use serde::{Deserialize, Serialize};
use totp_rs::{Algorithm, Secret, TOTP};
use crate::{
auth::{
account::AuthResponse,
session::{Claims, Session, TokenScope},
},
db::Postgres,
};
// Utility methods
pub fn totp_gen(user_id: usize, secret: &[u8]) -> Result<TOTP, String> {
TOTP::new(
Algorithm::SHA1,
6,
1,
30,
secret.to_owned(),
Some("chat.zxq5.dev".to_string()),
format!("{}", user_id),
)
.map_err(|_| String::from("Invalid Secret"))
}
// pages
#[get("/totp")]
pub async fn mfa_page(_session: Session) -> Template {
Template::render("2fa", context!())
}
#[post("/totp", data = "<form>")]
pub async fn confirm_totp(
mfa: TOTPSecret,
form: Json<TOTPSixDigitCode>,
mut db: Connection<Postgres>,
) -> Result<(), status::Custom<&'static str>> {
if form.code.len() != 6 || form.code.parse::<u32>().is_err() {
return Err(status::Custom(Status::BadRequest, "Invalid 6-digit code"));
}
println!("valid");
let totp = totp_gen(mfa.user_id, mfa.secret.as_bytes())
.map_err(|_| status::Custom(Status::InternalServerError, "TOTP Error"))?;
if !totp.check_current(&form.code).unwrap_or(false) {
return Err(status::Custom(Status::BadRequest, "Incorrect code"));
}
println!("correct");
if sqlx::query!(
"UPDATE users SET twofa_enabled = true WHERE id = $1",
mfa.user_id as i32
)
.execute(&mut **db)
.await
.is_err()
{
return Err(status::Custom(
Status::InternalServerError,
"unable to enable 2fa",
));
};
println!("enabled");
Ok(())
}
#[get("/totp.jpg")]
pub async fn get_totp(mfa: TOTPSecret) -> Option<Json<QrResponse>> {
let qr_b64 = totp_gen(mfa.user_id, mfa.secret.as_bytes())
.expect("Invalid TOTP")
.get_qr_base64()
.unwrap();
Some(Json(QrResponse {
qr_code: format!("data:image/png;base64,{}", qr_b64),
}))
}
#[derive(Debug, Deserialize)]
pub struct TOTPSixDigitCode {
code: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TotpStatus {
Enabled,
Disabled,
}
pub struct TOTPSecret {
user_id: usize,
secret: String,
}
#[derive(Serialize)]
pub struct QrResponse {
qr_code: String,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for TOTPSecret {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let auth_header = request.headers().get_one("Authorization");
println!(
"TOTPSecret guard - Auth header present: {}",
auth_header.is_some()
);
let user = try_outcome!(request.guard::<Claims>().await);
println!(
"TOTPSecret guard - Claims ok, user: {}, scope: {:?}",
user.sub, user.scope
);
// only allow full tokens for TOTP setup
if user.scope != TokenScope::Full {
println!("TOTPSecret guard - rejected, scope is {:?}", user.scope);
return Outcome::Error((Status::Forbidden, ()));
}
let user = try_outcome!(request.guard::<Session>().await);
let mut pool = match request.guard::<Connection<Postgres>>().await {
Outcome::Success(pool) => pool,
_ => return Outcome::Error((Status::Unauthorized, ())),
};
let row = sqlx::query!(
"SELECT twofa_enabled, totp_secret FROM users WHERE id = $1",
user.user_id as i32
)
.fetch_one(&mut **pool)
.await;
let (enabled, mut secret) = match row {
Ok(r) => (r.twofa_enabled, r.totp_secret),
Err(_) => return Outcome::Error((Status::Unauthorized, ())),
};
if secret.is_none() {
let new_secret = Secret::generate_secret().to_encoded().to_string();
sqlx::query!(
"UPDATE users SET totp_secret = $1 WHERE id = $2",
new_secret,
user.user_id as i32
)
.execute(&mut **pool)
.await
.ok();
secret = Some(new_secret);
}
Outcome::Success(TOTPSecret {
user_id: user.user_id,
secret: secret.unwrap(),
})
}
}
impl TOTPSecret {
pub async fn enable(&self, db: &mut Connection<Postgres>) -> Result<(), ()> {
match sqlx::query!(
"UPDATE users SET twofa_enabled = true WHERE id = $1",
self.user_id as i32,
)
.execute(&mut ***db)
.await
{
Ok(_) => Ok(()),
Err(_) => Err(()),
}
}
}
#[derive(Deserialize)]
pub struct TotpVerifyRequest {
pub code: String,
}
#[get("/totp/status")]
pub async fn get_totp_status(
user: Session,
mut db: Connection<Postgres>,
) -> Result<Json<TotpStatus>, Status> {
Ok(Json(
if sqlx::query!(
"SELECT twofa_enabled FROM users WHERE id = $1",
user.user_id as i32,
)
.fetch_one(&mut **db)
.await
.map_err(|_| Status::NotFound)?
.twofa_enabled
{
TotpStatus::Enabled
} else {
TotpStatus::Disabled
},
))
}
#[delete("/totp")]
pub async fn disable_totp(
user: Session,
mut db: Connection<Postgres>,
) -> Result<Json<AuthResponse>, Status> {
sqlx::query!(
"UPDATE users SET twofa_enabled = false, totp_secret = NULL WHERE id = $1",
user.user_id as i32,
)
.execute(&mut **db)
.await
.map_err(|_| Status::NotFound)?;
Ok(Json(AuthResponse {
token: Claims::new(user.user_id, TokenScope::Full).encode(),
totp_required: false,
}))
}
#[post("/totp/verify", data = "<body>")]
pub async fn verify_totp(
user: Claims, // request guard checks token validity
mut db: Connection<Postgres>,
body: Json<TotpVerifyRequest>,
) -> Result<Json<AuthResponse>, Status> {
println!("reached 1");
// reject if they somehow got here with a full token
if user.scope != TokenScope::TotpPending {
return Err(Status::Forbidden);
}
println!("reached 2");
let row = sqlx::query!(
"SELECT totp_secret FROM users WHERE id = $1 AND twofa_enabled = TRUE",
user.sub
)
.fetch_one(&mut **db)
.await
.map_err(|_| Status::Unauthorized)?;
println!("reached 3");
let totp = totp_gen(
user.sub as usize,
row.totp_secret
.expect("user with 2fa enabled has no totp secret")
.as_bytes(),
)
.map_err(|_| Status::InternalServerError)?;
if !totp
.check_current(&body.code)
.map_err(|_| Status::InternalServerError)?
{
return Err(Status::Unauthorized);
}
println!("reached 5");
let claims = Claims::new(user.sub as usize, TokenScope::Full);
Ok(Json(AuthResponse {
token: claims.encode(),
totp_required: false,
}))
}
+96
View File
@@ -0,0 +1,96 @@
use clap::{Parser, Subcommand};
use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
use std::sync::Arc;
use crate::repo::user_repo::UserRepository;
use crate::repo::space_repo::SpaceRepository;
use crate::repo::channel_repo::ChannelRepository;
use crate::repo::{UserRepo, SpaceRepo, ChannelRepo};
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use rand::rngs::OsRng;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
/// First-time setup for the server
Setup {
/// Admin username
#[arg(short, long)]
username: String,
/// Admin password
#[arg(short, long)]
password: String,
/// Default space name
#[arg(short, long, default_value = "Default Space")]
space: String,
/// Default channel name
#[arg(short, long, default_value = "general")]
channel: String,
},
}
pub async fn handle_cli() -> bool {
let cli = Cli::parse();
match cli.command {
Some(Commands::Setup { username, password, space, channel }) => {
if let Err(e) = run_setup(username, password, space, channel).await {
eprintln!("Setup failed: {}", e);
std::process::exit(1);
}
println!("Setup completed successfully!");
true
}
None => false,
}
}
async fn run_setup(username: String, password: String, space_name: String, channel_name: String) -> Result<(), Box<dyn std::error::Error>> {
dotenv::dotenv().ok();
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(1)
.acquire_timeout(Duration::from_secs(5))
.connect(&db_url)
.await?;
let user_repo = UserRepository::new(pool.clone());
let space_repo = SpaceRepository::new(pool.clone());
let channel_repo = ChannelRepository::new(pool.clone());
// 1. Create admin user
println!("Creating admin user: {}...", username);
let argon2 = Argon2::default();
let salt = SaltString::generate(&mut OsRng);
let passhash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| e.to_string())?
.to_string();
let user_id = user_repo.new_user("admin@localhost", &username, &passhash).await?;
user_repo.set_role(user_id, "admin").await?;
// 2. Create default space
println!("Creating default space: {}...", space_name);
let space_id = space_repo.create(&space_name, Some("Default space created during setup"), user_id).await?;
// 3. Create default channel
println!("Creating default channel: {}...", channel_name);
channel_repo.create(&channel_name, Some("Default channel"), space_id).await?;
Ok(())
}
-9
View File
@@ -1,9 +0,0 @@
use rocket_db_pools::{Database, deadpool_redis};
#[derive(Database)]
#[database("postgres_db")]
pub struct Postgres(sqlx::PgPool);
#[derive(Database)]
#[database("redis_cache")]
pub struct Redis(deadpool_redis::Pool);
+127
View File
@@ -0,0 +1,127 @@
// error.rs
use rocket::{http::Status, response::{self, Responder}, Request, Response};
use thiserror::Error;
use rocket_dyn_templates::Template;
use rocket::serde::Serialize;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Not found")]
NotFound,
#[error("Unauthorized")]
Unauthorised(String),
#[error("Forbidden")]
Forbidden,
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Internal error: {0}")]
Internal(String),
}
impl AppError {
pub fn internal(msg: impl Into<String>) -> Self {
Self::Internal(msg.into())
}
pub fn bad_request(msg: impl Into<String>) -> Self {
Self::BadRequest(msg.into())
}
pub fn unauthorised(msg: impl Into<String>) -> Self {
Self::Unauthorised(msg.into())
}
}
impl<'r> Responder<'r, 'static> for AppError {
fn respond_to(self, _req: &'r Request<'_>) -> response::Result<'static> {
let status = match &self {
AppError::NotFound => Status::NotFound,
AppError::Unauthorised(_) => Status::Unauthorized,
AppError::Forbidden => Status::Forbidden,
AppError::BadRequest(_) => Status::BadRequest,
AppError::Database(_) => Status::InternalServerError,
AppError::Internal(_) => Status::InternalServerError,
};
// log internal errors
if status == Status::InternalServerError {
tracing::error!("Internal Server Error: {}", self);
}
Response::build()
.status(status)
.header(rocket::http::ContentType::Plain)
.sized_body(
self.to_string().len(),
std::io::Cursor::new(self.to_string())
)
.ok()
}
}
pub type ApiResult<T> = Result<T, AppError>;
#[derive(Serialize)]
struct ErrorContext {
error_code: u16,
error_message: &'static str,
additional_info: &'static str,
redirect: Option<RedirectContext>,
}
#[derive(Serialize)]
struct RedirectContext {
url: &'static str,
message: &'static str,
}
#[catch(404)]
pub async fn handle_404() -> Template {
Template::render(
"error",
ErrorContext {
error_code: 404,
error_message: "Not Found",
additional_info: "There's nothing here.",
redirect: Some(RedirectContext {
url: "/",
message: "Home",
}),
},
)
}
#[catch(401)]
pub async fn handle_401() -> Template {
Template::render(
"error",
ErrorContext {
error_code: 401,
error_message: "Unauthorised",
additional_info: "You are not authorised to access this resource.",
redirect: Some(RedirectContext {
url: "/login",
message: "Login",
}),
},
)
}
#[catch(default)]
pub async fn handle_default(status: Status, _request: &Request<'_>) -> Template {
Template::render(
"error",
ErrorContext {
error_code: status.code,
error_message: "Unknown Error",
additional_info: "I don't know what to do with this error.",
redirect: None,
},
)
}
-62
View File
@@ -1,62 +0,0 @@
use rocket::{Request, http::Status};
use rocket_dyn_templates::Template;
use serde::Serialize;
#[derive(Serialize)]
struct ErrorContext {
error_code: u16,
error_message: &'static str,
additional_info: &'static str,
redirect: Option<RedirectContext>,
}
#[derive(Serialize)]
struct RedirectContext {
url: &'static str,
message: &'static str,
}
#[catch(404)]
pub async fn handle_404() -> Template {
Template::render(
"error",
ErrorContext {
error_code: 404,
error_message: "Not Found",
additional_info: "There's nothing here.",
redirect: Some(RedirectContext {
url: "/",
message: "Home",
}),
},
)
}
#[catch(401)]
pub async fn handle_401() -> Template {
Template::render(
"error",
ErrorContext {
error_code: 401,
error_message: "Unauthorised",
additional_info: "You are not authorised to access this resource.",
redirect: Some(RedirectContext {
url: "/login",
message: "Login",
}),
},
)
}
#[catch(default)]
pub async fn handle_default(status: Status, _request: &Request<'_>) -> Template {
Template::render(
"error",
ErrorContext {
error_code: status.code,
error_message: "Unknown Error",
additional_info: "I don't know what to do with this error.",
redirect: None,
},
)
}
+149
View File
@@ -0,0 +1,149 @@
#![deny(clippy::unwrap_used)]
#![warn(clippy::all, clippy::nursery, clippy::cargo, clippy::pedantic)]
#[macro_use]
extern crate rocket;
pub mod messenger;
pub mod api;
pub mod repo;
pub mod error;
pub mod svc;
pub mod model;
pub mod cli;
use crate::repo::{access_token_repo::AccessTokenRepo, Repo};
use crate::repo::message_repo::MessageRepository;
use crate::repo::user_repo::UserRepository;
use crate::repo::space_repo::SpaceRepository;
use crate::repo::channel_repo::ChannelRepository;
use crate::svc::auth_svc::AuthService;
use crate::svc::chat_svc::ChatService;
use crate::svc::settings_svc::SettingsService;
use crate::svc::user_svc::UserService;
use rocket::fs::{FileServer, NamedFile};
use rocket::http::Method;
use rocket_cors::{AllowedOrigins, CorsOptions};
use rocket_dyn_templates::Template;
use sqlx::postgres::PgPoolOptions;
use std::env;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use api::cdn;
use crate::svc::access_token_svc::AccessTokenService;
use crate::svc::llm_service::LlmService;
pub fn rocket() -> rocket::Rocket<rocket::Build> {
if std::env::var("RELEASE_MODE").unwrap_or_default() != "1" {
dotenv::dotenv().ok();
}
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(25)
.min_connections(5)
.acquire_timeout(Duration::from_secs(5))
.connect_lazy(&db_url)
.expect("Failed to create database pool");
let user_repo = Arc::new(UserRepository::new(pool.clone()));
let message_repo = MessageRepository::new(pool.clone());
let token_repo = Arc::new(AccessTokenRepo::new(pool.clone()));
let space_repo: Arc<dyn repo::SpaceRepo> = Arc::new(SpaceRepository::new(pool.clone()));
let channel_repo: Arc<dyn repo::ChannelRepo> = Arc::new(ChannelRepository::new(pool.clone()));
let llm_service = LlmService::new();
let chat_service = ChatService::new(32, llm_service.clone(), message_repo.clone(), user_repo.clone(), channel_repo.clone(), space_repo.clone());
rocket_builder(user_repo, token_repo, space_repo, channel_repo, chat_service)
}
pub fn rocket_builder(
user_repo: Arc<dyn repo::UserRepo>,
token_repo: Arc<dyn repo::AccessTokenRepoTrait>,
space_repo: Arc<dyn repo::SpaceRepo>,
channel_repo: Arc<dyn repo::ChannelRepo>,
chat_service: ChatService
) -> rocket::Rocket<rocket::Build> {
let cors = CorsOptions::default()
.allowed_origins(AllowedOrigins::all())
.allowed_methods(
vec![Method::Get, Method::Post, Method::Patch]
.into_iter()
.map(From::from)
.collect(),
)
.allow_credentials(true);
let access_token_svc = AccessTokenService::new(token_repo.clone());
let auth_service = AuthService::new(user_repo.clone(), access_token_svc.clone());
let settings_service = SettingsService::new(auth_service.clone(), user_repo.clone());
let user_service = UserService::new(user_repo.clone());
rocket::build()
.manage(chat_service)
.manage(auth_service)
.manage(settings_service)
.manage(user_service)
.manage(space_repo)
.manage(channel_repo)
.attach(cors.to_cors().unwrap())
.attach(Template::fairing())
.mount("/static", FileServer::from("static"))
.mount("/cdn", cdn::routes())
.mount(
"/",
routes![
favicon,
],
)
.mount(
"/api",
routes![
cdn::upload_profile_pic,
api::profile::display_name,
// basic auth
api::auth::login,
api::auth::signup,
// 2fa
api::totp::confirm_totp,
api::totp::disable_totp,
api::totp::get_totp,
api::totp::get_totp_status,
api::totp::verify_totp,
// chat
api::chat::event_stream,
api::chat::post_message,
// user settings
api::settings::change_display_name,
api::settings::change_password,
api::settings::change_username,
api::settings::delete_account,
// spaces
api::space::list_spaces,
api::space::list_channels,
api::space::get_accessible_channels
],
)
.register(
"/",
catchers![
error::handle_401,
error::handle_404,
error::handle_default,
],
)
}
#[get("/favicon.ico")]
pub async fn favicon() -> NamedFile {
NamedFile::open("static/favicon.ico").await.unwrap()
}

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