diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/ChatApplication.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/ChatApplication.kt index 907df9a..af89aad 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/ChatApplication.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/ChatApplication.kt @@ -1,11 +1,19 @@ package dev.zxq5.chatapp.android import android.app.Application -import dev.zxq5.chatapp.android.api.ApiClient +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() { + + 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) } } diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/MainActivity.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/MainActivity.kt index d8d1512..9d1b6b6 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/MainActivity.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/MainActivity.kt @@ -11,36 +11,66 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier 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.core.data.TokenStore +import dev.zxq5.chatapp.android.data.repository.AuthRepository +import dev.zxq5.chatapp.android.data.repository.AuthState +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.chat.Screen +import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel +import dev.zxq5.chatapp.android.feature.auth.AuthScreen +import dev.zxq5.chatapp.android.feature.chat.ChatScreen +import dev.zxq5.chatapp.android.feature.settings.SettingsScreen 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 tokenStore = app.tokenStore + val authRepository = app.authRepository + val chatRepository = app.chatRepository + val settingsRepository = app.settingsRepository + enableEdgeToEdge() setContent { ChatappTheme { - val viewModel: ChatViewModel = viewModel() - val loginState by viewModel.loginState.collectAsState() - val currentScreen by viewModel.currentScreen.collectAsState() + val authViewModel: AuthViewModel = viewModel(factory = ViewModelFactory(authRepository)) + val chatViewModel: ChatViewModel = viewModel(factory = ViewModelFactory(chatRepository)) + val settingsViewModel: SettingsViewModel = viewModel(factory = ViewModelFactory(settingsRepository)) + + val authState by authViewModel.authState.collectAsState() + val currentScreen by chatViewModel.currentScreen.collectAsState() 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) + when (authState) { + AuthState.Authenticated -> { + when (currentScreen) { + Screen.CHAT -> ChatScreen( + viewModel = chatViewModel, + onNavigateToSettings = { chatViewModel.navigateTo(Screen.SETTINGS) }, + onLogout = { + authViewModel.logout() + chatViewModel.clearChat() + } + ) + Screen.SETTINGS -> SettingsScreen( + viewModel = settingsViewModel, + onBack = { chatViewModel.navigateTo(Screen.CHAT) }, + onLogout = { + authViewModel.logout() + chatViewModel.clearChat() + } + ) + } + } + AuthState.AwaitingTotp, AuthState.Unauthenticated -> { + AuthScreen(viewModel = authViewModel) } - } else { - AuthScreen( - viewModel = viewModel, - onSuccess = { } - ) } } } diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/ViewModelFactory.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/ViewModelFactory.kt new file mode 100644 index 0000000..39af351 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/ViewModelFactory.kt @@ -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 create(modelClass: Class): 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") + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/ApiClient.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/ApiClient.kt deleted file mode 100644 index 7c9e333..0000000 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/api/ApiClient.kt +++ /dev/null @@ -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 { - 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 { - 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() - 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 { - 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() - 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 = 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(json) } - .onSuccess { emit(it) } - } - } - } - } - - suspend fun getTotpQr(): QrResponse? { - return try { - http.get("${BASE_URL}/api/totp.jpg").body() - } 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() - 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 { - 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() - - 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() } 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() - 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 { - return try { - val response = http.delete("${BASE_URL}/api/totp") - if (response.status.isSuccess()) { - val body = response.body() - 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 { - 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 - } - } -} - diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/AuthClient.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/AuthClient.kt new file mode 100644 index 0000000..1354005 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/AuthClient.kt @@ -0,0 +1,116 @@ +package dev.zxq5.chatapp.android.api + +import android.util.Log +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.BASE_URL +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 { + 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()) + } 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 { + 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()) + } 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 { + 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()) + } else { + val errorText = try { response.body() } 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") + } + } +} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/ChatClient.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/ChatClient.kt new file mode 100644 index 0000000..f3b4d6c --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/ChatClient.kt @@ -0,0 +1,57 @@ +package dev.zxq5.chatapp.android.api + +import dev.zxq5.chatapp.android.core.BASE_URL +import dev.zxq5.chatapp.android.api.model.Message +import dev.zxq5.chatapp.android.api.model.SendMessage +import io.ktor.client.HttpClient +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.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.readUTF8Line +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.serialization.json.Json + +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 sendMessage(channelId: Int, userId: Int, text: String) { + 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 = 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(json) } + .onSuccess { emit(it) } + } + } + } + } +} + diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/SettingsClient.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/SettingsClient.kt new file mode 100644 index 0000000..69259df --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/SettingsClient.kt @@ -0,0 +1,171 @@ +package dev.zxq5.chatapp.android.api + +import android.util.Log +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.BASE_URL +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 { + 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()) + } 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 { + 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 { + return try { + val response = http.get("${BASE_URL}/api/totp/status") + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } 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 { + 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 { + 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 { + 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 { + 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") + } + } +} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/AccountDeleteRequest.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/AccountDeleteRequest.kt new file mode 100644 index 0000000..7a85be1 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/AccountDeleteRequest.kt @@ -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) \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/DisplayNameRequest.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/DisplayNameRequest.kt new file mode 100644 index 0000000..5021347 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/DisplayNameRequest.kt @@ -0,0 +1,6 @@ +package dev.zxq5.chatapp.android.api.model + +import kotlinx.serialization.Serializable + +@Serializable +data class DisplayNameRequest(val display_name: String?) \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/model/LoginRequest.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/LoginRequest.kt similarity index 75% rename from android/app/src/main/java/dev/zxq5/chatapp/android/model/LoginRequest.kt rename to android/app/src/main/java/dev/zxq5/chatapp/android/api/model/LoginRequest.kt index ee3730b..2b3083a 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/model/LoginRequest.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/LoginRequest.kt @@ -1,4 +1,4 @@ -package dev.zxq5.chatapp.android.model +package dev.zxq5.chatapp.android.api.model import kotlinx.serialization.Serializable @@ -6,6 +6,4 @@ import kotlinx.serialization.Serializable data class LoginRequest( val username: String, val password: String -) - - +) \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/model/LoginResponse.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/LoginResponse.kt similarity index 70% rename from android/app/src/main/java/dev/zxq5/chatapp/android/model/LoginResponse.kt rename to android/app/src/main/java/dev/zxq5/chatapp/android/api/model/LoginResponse.kt index 3304cdb..c30553b 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/model/LoginResponse.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/LoginResponse.kt @@ -1,4 +1,4 @@ -package dev.zxq5.chatapp.android.model +package dev.zxq5.chatapp.android.api.model import kotlinx.serialization.Serializable diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/model/Message.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/Message.kt similarity index 80% rename from android/app/src/main/java/dev/zxq5/chatapp/android/model/Message.kt rename to android/app/src/main/java/dev/zxq5/chatapp/android/api/model/Message.kt index edd97b8..d4c814b 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/model/Message.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/Message.kt @@ -1,4 +1,4 @@ -package dev.zxq5.chatapp.android.model +package dev.zxq5.chatapp.android.api.model import kotlinx.serialization.Serializable @@ -8,4 +8,4 @@ data class Message( val display_name: String, val text: String, val timestamp: Long -) +) \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/PasswordChangeRequest.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/PasswordChangeRequest.kt new file mode 100644 index 0000000..bd4b2e7 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/PasswordChangeRequest.kt @@ -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) \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/PasswordRequest.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/PasswordRequest.kt new file mode 100644 index 0000000..a4f8ee4 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/PasswordRequest.kt @@ -0,0 +1,6 @@ +package dev.zxq5.chatapp.android.api.model + +import kotlinx.serialization.Serializable + +@Serializable +data class PasswordRequest(val password: String) diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/QrResponse.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/QrResponse.kt new file mode 100644 index 0000000..9e693d1 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/QrResponse.kt @@ -0,0 +1,6 @@ +package dev.zxq5.chatapp.android.api.model + +import kotlinx.serialization.Serializable + +@Serializable +data class QrResponse(val qr_code: String) \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/model/SendMessage.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/SendMessage.kt similarity index 77% rename from android/app/src/main/java/dev/zxq5/chatapp/android/model/SendMessage.kt rename to android/app/src/main/java/dev/zxq5/chatapp/android/api/model/SendMessage.kt index 34254f7..a0ab8e5 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/model/SendMessage.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/SendMessage.kt @@ -1,4 +1,4 @@ -package dev.zxq5.chatapp.android.model +package dev.zxq5.chatapp.android.api.model import kotlinx.serialization.Serializable @@ -7,4 +7,4 @@ data class SendMessage( val user_id: Int, val text: String, val timestamp: Long -) +) \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/model/SignupRequest.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/SignupRequest.kt similarity index 85% rename from android/app/src/main/java/dev/zxq5/chatapp/android/model/SignupRequest.kt rename to android/app/src/main/java/dev/zxq5/chatapp/android/api/model/SignupRequest.kt index 7e2e626..de8c66e 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/model/SignupRequest.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/SignupRequest.kt @@ -1,4 +1,4 @@ -package dev.zxq5.chatapp.android.model +package dev.zxq5.chatapp.android.api.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -11,4 +11,4 @@ data class SignupRequest( @SerialName("access_token") val access_token: String -) +) \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/TOTPSixDigitCode.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/TOTPSixDigitCode.kt new file mode 100644 index 0000000..53bbe79 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/TOTPSixDigitCode.kt @@ -0,0 +1,6 @@ +package dev.zxq5.chatapp.android.api.model + +import kotlinx.serialization.Serializable + +@Serializable +data class TOTPSixDigitCode(val code: String) \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/TotpDeleteRequest.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/TotpDeleteRequest.kt new file mode 100644 index 0000000..b22e495 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/TotpDeleteRequest.kt @@ -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) diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/TotpStatus.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/TotpStatus.kt new file mode 100644 index 0000000..4c9b796 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/TotpStatus.kt @@ -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 { + 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()) + } +} + diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/UsernameRequest.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/UsernameRequest.kt new file mode 100644 index 0000000..c455ce4 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/UsernameRequest.kt @@ -0,0 +1,6 @@ +package dev.zxq5.chatapp.android.api.model + +import kotlinx.serialization.Serializable + +@Serializable +data class UsernameRequest(val username: String) \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/core/data/TokenStore.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/core/data/TokenStore.kt index f25e33c..a7b5a88 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/core/data/TokenStore.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/core/data/TokenStore.kt @@ -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 + // 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 - } +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 } } \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/AuthRepository.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/AuthRepository.kt index 6d5a3b5..7bb6e25 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/AuthRepository.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/AuthRepository.kt @@ -1,35 +1,83 @@ 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 { object Authenticated : AuthState() object AwaitingTotp : AuthState() object Unauthenticated : AuthState() -} \ No newline at end of file +} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/ChatRepository.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/ChatRepository.kt new file mode 100644 index 0000000..a7b932f --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/ChatRepository.kt @@ -0,0 +1,38 @@ +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 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 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 getUserId() = tokenStore.getUserId() + + suspend fun sendMessage(channelId: Int, text: String) { + val userId = tokenStore.getUserId() ?: return + getChatClient()?.sendMessage(channelId, userId, text) + } + + fun messageStream(channelId: Int): Flow { + return getChatClient()?.messageStream(channelId) ?: emptyFlow() + } +} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/SettingsRepository.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/SettingsRepository.kt new file mode 100644 index 0000000..78d0ac5 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/data/repository/SettingsRepository.kt @@ -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 { + val settingsClient = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated") + return settingsClient.getTotpQr(password) + } + + suspend fun confirmTotp(code: String): ApiResult { + val client = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated") + return client.confirmTotp(code) + } + + suspend fun getTotpStatus(): ApiResult { + return getSettingsClient()?.getTotpStatus() ?: ApiResult.NetworkError("Not authenticated") + } + + suspend fun disableTotp(password: String, totpCode: String): ApiResult { + val client = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated") + return client.disableTotp(password, totpCode) + } + + suspend fun changePassword(old: String, new: String): ApiResult { + 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 { + return getSettingsClient()?.updateUsername(username) ?: ApiResult.NetworkError("Not authenticated") + } + + suspend fun deleteAccount(password: String, totpCode: String?): ApiResult { + return getSettingsClient()?.deleteAccount(password, totpCode) ?: ApiResult.NetworkError("Not authenticated") + } + + fun logout() { + tokenStore.clear() + resetClient() + } +} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthMode.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthMode.kt new file mode 100644 index 0000000..06d7548 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthMode.kt @@ -0,0 +1,5 @@ +package dev.zxq5.chatapp.android.feature.auth + +enum class AuthMode { + LOGIN, SIGNUP +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthScreen.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthScreen.kt new file mode 100644 index 0000000..dd3400b --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthScreen.kt @@ -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) } + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthViewModel.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthViewModel.kt new file mode 100644 index 0000000..5ea0b59 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/AuthViewModel.kt @@ -0,0 +1,107 @@ +package dev.zxq5.chatapp.android.feature.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.Idle) + val loginState: StateFlow = _loginState + + private val _authMode = MutableStateFlow(AuthMode.LOGIN) + val authMode: StateFlow = _authMode + + private val _authState = MutableStateFlow(authRepository.getAuthState()) + val authState: StateFlow = _authState + + private val _totpError = MutableStateFlow(null) + val totpError: StateFlow = _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 + } +} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/LoginScreen.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/LoginScreen.kt new file mode 100644 index 0000000..7a113f5 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/LoginScreen.kt @@ -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(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 + ) + } + } +} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/SignupScreen.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/SignupScreen.kt new file mode 100644 index 0000000..a35efb4 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/SignupScreen.kt @@ -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(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 + ) + } + } +} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/TokenScope.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/TokenScope.kt new file mode 100644 index 0000000..2c9f594 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/TokenScope.kt @@ -0,0 +1,6 @@ +package dev.zxq5.chatapp.android.feature.auth + +object TokenScope { + const val FULL = "full" + const val TOTP_PENDING = "totp_pending" +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/TwoFactorLoginScreen.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/TwoFactorLoginScreen.kt new file mode 100644 index 0000000..0968ca1 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/auth/TwoFactorLoginScreen.kt @@ -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) + } + } + } +} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/ChatViewModel.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/ChatViewModel.kt new file mode 100644 index 0000000..ac8061f --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/ChatViewModel.kt @@ -0,0 +1,93 @@ +package dev.zxq5.chatapp.android.feature.chat + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.zxq5.chatapp.android.data.repository.ChatRepository +import dev.zxq5.chatapp.android.api.model.Message +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>(emptyList()) + val messages: StateFlow> = _messages + + private val _channelId = MutableStateFlow(null) + val channelId: StateFlow = _channelId + + private val _currentScreen = MutableStateFlow(Screen.CHAT) + val currentScreen: StateFlow = _currentScreen + + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId + + private var streamJob: Job? = null + + init { + _currentUserId.value = chatRepository.getUserId() + observeChannel() + } + + private fun observeChannel() { + viewModelScope.launch { + _channelId.collect { id -> + streamJob?.cancel() + _messages.value = emptyList() + if (id != null) { + streamJob = launch { + chatRepository.messageStream(id) + .catch { e -> + Log.e("Chat", "Stream error", e) + } + .collect { message -> + _messages.update { it + message } + } + } + } + } + } + } + + fun navigateTo(screen: Screen) { + _currentScreen.value = screen + } + + fun switchChannel(id: Int?) { + _channelId.value = id + if (id != null) { + // Refresh user ID just in case it wasn't available at init + _currentUserId.value = chatRepository.getUserId() + chatRepository.resetClient() + } + } + + 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) + } + } + } + + fun clearChat() { + _messages.value = emptyList() + _channelId.value = null + _currentUserId.value = null + streamJob?.cancel() + chatRepository.resetClient() + } +} + diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/Screen.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/Screen.kt new file mode 100644 index 0000000..d902c3d --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/Screen.kt @@ -0,0 +1,5 @@ +package dev.zxq5.chatapp.android.feature.chat + +enum class Screen { + CHAT, SETTINGS +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/chat.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/chat.kt similarity index 93% rename from android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/chat.kt rename to android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/chat.kt index 9b53965..8e6f451 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/chat.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/chat.kt @@ -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,12 +27,7 @@ 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.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.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -45,6 +41,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -58,21 +57,25 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.ImeAction +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.Message import java.text.DateFormat import java.util.Date @Composable -fun ChatScreen(viewModel: ChatViewModel) { +fun ChatScreen( + viewModel: ChatViewModel, + onNavigateToSettings: () -> Unit, + onLogout: () -> Unit // Note: logout is now part of SettingsScreen in this UI, but we'll keep the param for now +) { val selectedChannelId by viewModel.channelId.collectAsState() if (selectedChannelId == null) { ChannelListScreen( viewModel = viewModel, - onChannelSelect = { viewModel.switchChannel(it) } + onChannelSelect = { viewModel.switchChannel(it) }, + onNavigateToSettings = onNavigateToSettings ) } else { MessageScreen( @@ -85,7 +88,11 @@ fun ChatScreen(viewModel: ChatViewModel) { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit) { +fun ChannelListScreen( + viewModel: ChatViewModel, + onChannelSelect: (Int) -> Unit, + onNavigateToSettings: () -> Unit +) { Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { @@ -153,7 +160,7 @@ fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit) } } }, - bottomBar = { BottomDock(viewModel) } + bottomBar = { BottomDock(viewModel, onNavigateToSettings) } ) { padding -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) { items(10) { i -> @@ -170,7 +177,7 @@ fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit) } @Composable -fun BottomDock(viewModel: ChatViewModel) { +fun BottomDock(viewModel: ChatViewModel, onNavigateToSettings: () -> Unit) { val currentScreen by viewModel.currentScreen.collectAsState() NavigationBar( @@ -179,8 +186,8 @@ fun BottomDock(viewModel: ChatViewModel) { 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) }, + selected = currentScreen == Screen.CHAT, + onClick = { viewModel.navigateTo(Screen.CHAT) }, icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Chat") }, label = { Text("chat", style = MaterialTheme.typography.labelSmall) }, colors = NavigationBarItemDefaults.colors( @@ -190,8 +197,8 @@ fun BottomDock(viewModel: ChatViewModel) { ) ) NavigationBarItem( - selected = currentScreen == MainScreen.SETTINGS, - onClick = { viewModel.navigateTo(MainScreen.SETTINGS) }, + selected = currentScreen == Screen.SETTINGS, + onClick = onNavigateToSettings, icon = { Icon(Icons.Outlined.Settings, contentDescription = "Settings") }, label = { Text("settings", style = MaterialTheme.typography.labelSmall) }, colors = NavigationBarItemDefaults.colors( @@ -409,7 +416,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 +438,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) diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/SettingsViewModel.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/SettingsViewModel.kt new file mode 100644 index 0000000..9549ea8 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/SettingsViewModel.kt @@ -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 = _is2faEnabled + + private val _totpQr = MutableStateFlow(null) + val totpQr: StateFlow = _totpQr + + private val _totpError = MutableStateFlow(null) + val totpError: StateFlow = _totpError + + private val _settingsError = MutableStateFlow(null) + val settingsError: StateFlow = _settingsError + + private val _isSuccessState = MutableStateFlow>(emptyMap()) + val isSuccessState: StateFlow> = _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() + } +} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/settings.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/settings.kt new file mode 100644 index 0000000..d4f9360 --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/settings.kt @@ -0,0 +1,534 @@ +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, + onBack: () -> Unit, + 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 + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + 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(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") + } + } +} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/model/ChatViewModel.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/model/ChatViewModel.kt deleted file mode 100644 index 53ea713..0000000 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/model/ChatViewModel.kt +++ /dev/null @@ -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>(emptyList()) - val messages: StateFlow> = _messages - - private val _channelId = MutableStateFlow(null) - val channelId: StateFlow = _channelId - - private val _currentUserId = MutableStateFlow(null) - val currentUserId: StateFlow = _currentUserId - - private val _currentScreen = MutableStateFlow(MainScreen.CHAT) - val currentScreen: StateFlow = _currentScreen - - val loginState = MutableStateFlow(LoginState.Idle) - - // Tracks whether the user is viewing the Login or Signup screen - val authMode = MutableStateFlow(AuthMode.LOGIN) - - // 2FA state - private val _totpQr = MutableStateFlow(null) - val totpQr: StateFlow = _totpQr - - private val _is2faEnabled = MutableStateFlow(false) - val is2faEnabled: StateFlow = _is2faEnabled - - private val _totpError = MutableStateFlow(null) - val totpError: StateFlow = _totpError - - // Settings state - private val _settingsError = MutableStateFlow(null) - val settingsError: StateFlow = _settingsError - - private val _settingsSuccess = MutableStateFlow(null) - val settingsSuccess: StateFlow = _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"; -} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/login.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/login.kt deleted file mode 100644 index 90e0286..0000000 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/login.kt +++ /dev/null @@ -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(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) - } - } - } -} - diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/settings.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/settings.kt deleted file mode 100644 index bb70398..0000000 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/settings.kt +++ /dev/null @@ -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) - } - } - } -}