From d1208f7e39d4947390787ce2b21572b23b2fd763 Mon Sep 17 00:00:00 2001 From: zxq5 Date: Sat, 11 Apr 2026 00:09:47 +0100 Subject: [PATCH] frontend v0.4.1-2 - added invite section to UI and some general bug fixes --- android/app/src/main/AndroidManifest.xml | 4 - .../dev/zxq5/chatapp/android/MainActivity.kt | 12 +- .../chatapp/android/api/SettingsClient.kt | 17 ++ .../android/api/model/InviteRequest.kt | 13 ++ .../android/{ => api}/model/LoginState.kt | 2 +- .../chatapp/android/core/data/TokenStore.kt | 36 +++- .../core/service/MessageStreamService.kt | 36 ++-- .../core/service/NotificationService.kt | 52 +++--- .../android/data/repository/AuthRepository.kt | 4 + .../data/repository/SettingsRepository.kt | 5 + .../android/feature/auth/AuthScreen.kt | 2 +- .../android/feature/auth/AuthViewModel.kt | 2 +- .../android/feature/auth/LoginScreen.kt | 2 +- .../android/feature/auth/SignupScreen.kt | 2 +- .../android/feature/chat/ChatViewModel.kt | 24 ++- .../feature/settings/SettingsViewModel.kt | 18 ++ .../android/feature/settings/settings.kt | 157 +++++++++++++++--- .../android/ui/components/textfield.kt | 9 +- 18 files changed, 301 insertions(+), 96 deletions(-) create mode 100644 android/app/src/main/java/dev/zxq5/chatapp/android/api/model/InviteRequest.kt rename android/app/src/main/java/dev/zxq5/chatapp/android/{ => api}/model/LoginState.kt (84%) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 27dd53f..ef655b7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,9 +4,6 @@ - - - MessageStreamService.start(this@MainActivity) + AuthState.Authenticated -> { + MessageStreamService.start(this@MainActivity) + chatViewModel.loadAccessibleChannels() + } AuthState.Unauthenticated -> MessageStreamService.stop(this@MainActivity) AuthState.AwaitingTotp -> {} } } + LaunchedEffect(Unit) { + chatViewModel.onUnauthorized = { + authViewModel.logout() + chatViewModel.clearChat() + } + } + LaunchedEffect(Unit) { intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let { chatViewModel.switchChannel(it.toLong()) 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 index 2265ca2..03d7eb7 100644 --- 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 @@ -4,6 +4,7 @@ import android.util.Log import dev.zxq5.chatapp.android.BuildConfig.BASE_URL import dev.zxq5.chatapp.android.api.model.AccountDeleteRequest import dev.zxq5.chatapp.android.api.model.DisplayNameRequest +import dev.zxq5.chatapp.android.api.model.InviteRequest import dev.zxq5.chatapp.android.api.model.PasswordChangeRequest import dev.zxq5.chatapp.android.api.model.QrResponse import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode @@ -45,6 +46,22 @@ class SettingsClient(private val token: String) { } } + suspend fun createInvite(request: InviteRequest): ApiResult { + return try { + val response = http.post("${BASE_URL}/api/invite") { + contentType(ContentType.Application.Json) + setBody(request) + } + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.HttpError(response.status.value, "Failed to create invite") + } + } catch (e: Exception) { + ApiResult.NetworkError(e.localizedMessage ?: "Network error") + } + } + suspend fun getTotpQr(password: String): ApiResult { return try { val response = http.post("${BASE_URL}/api/totp.jpg") { diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/InviteRequest.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/InviteRequest.kt new file mode 100644 index 0000000..0fbc66e --- /dev/null +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/InviteRequest.kt @@ -0,0 +1,13 @@ +package dev.zxq5.chatapp.android.api.model + +import kotlinx.serialization.Serializable +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Serializable +data class InviteRequest @OptIn(ExperimentalTime::class) constructor( + val name: String, + val max_uses: Int, + val expiry_date: Instant, + val start_date: Instant +) diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/model/LoginState.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/LoginState.kt similarity index 84% rename from android/app/src/main/java/dev/zxq5/chatapp/android/model/LoginState.kt rename to android/app/src/main/java/dev/zxq5/chatapp/android/api/model/LoginState.kt index 32f53d6..87ff30a 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/model/LoginState.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/api/model/LoginState.kt @@ -1,4 +1,4 @@ -package dev.zxq5.chatapp.android.model +package dev.zxq5.chatapp.android.api.model sealed class LoginState { object Idle : LoginState() 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 a7b5a88..78dab1c 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 @@ -3,10 +3,12 @@ package dev.zxq5.chatapp.android.core.data import android.content.Context import android.content.SharedPreferences import android.util.Base64 +import android.util.Log import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import org.json.JSONObject +import java.time.Instant private const val KEY = "auth_token" private const val TWOFA_KEY = "twofa_enabled" @@ -27,11 +29,37 @@ class TokenStore(appContext: Context) { ) } - fun save(token: String) = - prefs().edit { putString(KEY, token) } + fun save(token: String) { + Log.d("TokenStore", "Saving token: $token") + + prefs().edit { putString(KEY, token) } + } + + fun get(): String? { + val ret = prefs().getString(KEY, null) + Log.d("TokenStore", "Retrieved token: $ret") + return ret + } + + fun isExpired(): Boolean { + val token = get() ?: return true + return try { + val payload = token.split(".")[1] + 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("exp")) { + val exp = json.getLong("exp") + val now = Instant.now().epochSecond + now >= exp + } else { + false // If no exp claim, assume not expired or handle differently + } + } catch (e: Exception) { + true // If we can't parse it, treat it as expired + } + } - fun get(): String? = - prefs().getString(KEY, null) fun save2faEnabled( enabled: Boolean) = prefs().edit { putBoolean(TWOFA_KEY, enabled) } diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/MessageStreamService.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/MessageStreamService.kt index 49b41e8..829057e 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/MessageStreamService.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/MessageStreamService.kt @@ -3,7 +3,6 @@ package dev.zxq5.chatapp.android.core.service import android.app.Service import android.content.Context import android.content.Intent -import android.os.Build import android.os.IBinder import android.util.Log import dev.zxq5.chatapp.android.ChatApplication @@ -15,21 +14,17 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch -// core/service/MessageStreamService.kt class MessageStreamService : Service() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private lateinit var notificationService: NotificationService private lateinit var chatRepository: ChatRepository - // which channel the user is currently looking at - // set by the ViewModel when the user opens/closes a channel var activeChannelId: Long? = null set(value) { field = value Log.d("Service", "activeChannelId set to $value") if (value != null) { - // restart stream with new channel currentStreamJob?.cancel() observeMessages() } @@ -42,11 +37,9 @@ class MessageStreamService : Service() { fun start(context: Context) { val intent = Intent(context, MessageStreamService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) - } + // Use startService to avoid the requirement for a persistent notification. + // This also prevents ForegroundServiceDidNotStartInTimeException. + context.startService(intent) } fun stop(context: Context) { @@ -62,32 +55,25 @@ class MessageStreamService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - startForeground( - NotificationService.FOREGROUND_NOTIFICATION_ID, - notificationService.buildForegroundNotification() - ) observeMessages() - return START_STICKY // restart if killed + return START_STICKY } private fun observeMessages() { val channelId = activeChannelId ?: chatRepository.getLastActiveChannel() - Log.d("Service", "observeMessages called, channelId=$channelId") - if (channelId == null) { - Log.d("Service", "No channel to observe, waiting for switchChannel") - return - } + if (channelId == null) return - Log.d("Service", "Starting stream for channel $channelId") currentStreamJob = serviceScope.launch { chatRepository.messageStream(channelId) .catch { e -> Log.e("Service", "Stream error", e) } .collect { message -> - if (!ChatApplication.AppState.isInForeground) { // no channel focused, always notify + // Only show notification when an event (new message) is received + // and the app is not in the foreground on this channel. + if (!ChatApplication.AppState.isInForeground || activeChannelId != channelId) { notificationService.showMessageNotification( - conversationId = activeChannelId.toString(), + conversationId = channelId.toString(), senderName = message.display_name, - messagePreview = message.text.take(80) + messagePreview = message.text ) } } @@ -101,4 +87,4 @@ class MessageStreamService : Service() { instance = null serviceScope.cancel() } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/NotificationService.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/NotificationService.kt index db0ac8b..6c83a46 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/NotificationService.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/core/service/NotificationService.kt @@ -15,51 +15,41 @@ class NotificationService(private val context: Context) { companion object { const val CHANNEL_ID = "messages" - const val FOREGROUND_NOTIFICATION_ID = 1 // ← this needs to exist + const val SERVICE_CHANNEL_ID = "service" + const val FOREGROUND_NOTIFICATION_ID = 1 } private val manager = context.getSystemService(NotificationManager::class.java) - fun createChannels() { - // channel for new message notifications - val messageChannel = NotificationChannel( - CHANNEL_ID, - "Messages", - NotificationManager.IMPORTANCE_HIGH - ).apply { - enableVibration(true) + fun createForegroundNotification(): Notification { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } - - // channel for the persistent foreground service notification - // low importance so it doesn't make noise - val serviceChannel = NotificationChannel( - "service", - "Background connection", - NotificationManager.IMPORTANCE_LOW + + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE ) - val mgr = context.getSystemService(NotificationManager::class.java) - mgr.createNotificationChannel(messageChannel) - mgr.createNotificationChannel(serviceChannel) - } - - fun buildForegroundNotification(): Notification { - return NotificationCompat.Builder(context, "service") + return NotificationCompat.Builder(context, SERVICE_CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) - .setContentTitle("chatapp") - .setContentText("Connected") + .setContentTitle("Chat App") + .setContentText("Connecting to message stream...") + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setContentIntent(pendingIntent) .setOngoing(true) - .setSilent(true) .build() } fun showMessageNotification( conversationId: String, senderName: String, - messagePreview: String, // for E2E this would be "New message" — no plaintext + messagePreview: String, notificationId: Int = conversationId.hashCode() ) { - // intent that opens the app to the right conversation when tapped val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP putExtra("conversation_id", conversationId) @@ -72,13 +62,13 @@ class NotificationService(private val context: Context) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notification = NotificationCompat.Builder(context, "messages") + val notification = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(senderName) .setContentText(messagePreview) .setPriority(NotificationCompat.PRIORITY_HIGH) .setContentIntent(pendingIntent) - .setAutoCancel(true) // dismiss on tap + .setAutoCancel(true) .build() manager.notify(notificationId, notification) @@ -91,4 +81,4 @@ class NotificationService(private val context: Context) { fun dismissAll() { manager.cancelAll() } -} \ 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 7bb6e25..9bd209b 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 @@ -56,6 +56,10 @@ class AuthRepository( fun getAuthState(): AuthState { val token = tokenStore.get() ?: return AuthState.Unauthenticated + if (tokenStore.isExpired()) { + tokenStore.clear() + return AuthState.Unauthenticated + } return when (getScopeFromToken(token)) { TokenScope.FULL -> AuthState.Authenticated TokenScope.TOTP_PENDING -> AuthState.AwaitingTotp 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 index 78d0ac5..725f1e9 100644 --- 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 @@ -2,6 +2,7 @@ package dev.zxq5.chatapp.android.data.repository import dev.zxq5.chatapp.android.api.model.QrResponse import dev.zxq5.chatapp.android.api.SettingsClient +import dev.zxq5.chatapp.android.api.model.InviteRequest import dev.zxq5.chatapp.android.api.model.TotpStatus import dev.zxq5.chatapp.android.core.data.TokenStore import dev.zxq5.chatapp.android.core.error.ApiResult @@ -25,6 +26,10 @@ class SettingsRepository(private val tokenStore: TokenStore) { _lastToken = null } + suspend fun createInvite(request: InviteRequest): ApiResult { + return getSettingsClient()?.createInvite(request) ?: ApiResult.NetworkError("Not authenticated") + } + suspend fun getTotpQr(password: String): ApiResult { val settingsClient = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated") return settingsClient.getTotpQr(password) 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 index dd3400b..91c905a 100644 --- 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 @@ -3,7 +3,7 @@ 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 +import dev.zxq5.chatapp.android.api.model.LoginState @Composable fun AuthScreen(viewModel: AuthViewModel) { 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 index a3cd4d0..7989331 100644 --- 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 @@ -7,7 +7,7 @@ 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 dev.zxq5.chatapp.android.api.model.LoginState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch 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 index 7a113f5..ffe2d4d 100644 --- 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 @@ -28,7 +28,7 @@ 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.api.model.LoginState import dev.zxq5.chatapp.android.ui.components.TextField @Composable 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 index a35efb4..da780a9 100644 --- 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 @@ -28,7 +28,7 @@ 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.api.model.LoginState import dev.zxq5.chatapp.android.ui.components.TextField @Composable 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 index 9c20c01..779f94a 100644 --- 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 @@ -9,6 +9,8 @@ import dev.zxq5.chatapp.android.api.model.Message import dev.zxq5.chatapp.android.api.model.Space import dev.zxq5.chatapp.android.api.model.SpaceDto import dev.zxq5.chatapp.android.core.service.MessageStreamService +import io.ktor.client.plugins.ResponseException +import io.ktor.http.HttpStatusCode import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -40,6 +42,8 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() { val channelError: StateFlow = _channelError private var streamJob: Job? = null + + var onUnauthorized: (() -> Unit)? = null init { _currentUserId.value = chatRepository.getUserId() @@ -49,6 +53,7 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() { fun loadAccessibleChannels() { _error.value = null + _currentUserId.value = chatRepository.getUserId() viewModelScope.launch { runCatching { chatRepository.getAccessibleChannels() @@ -56,7 +61,11 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() { _spaces.value = data }.onFailure { e -> Log.e("Chat", "Failed to load spaces", e) - _error.value = "Failed to load channels: ${e.message}" + if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) { + onUnauthorized?.invoke() + } else { + _error.value = "Failed to load channels: ${e.message}" + } } } } @@ -72,7 +81,11 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() { chatRepository.messageStream(id) .catch { e -> Log.e("Chat", "Stream error", e) - _channelError.value = "Connection lost: ${e.message}" + if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) { + onUnauthorized?.invoke() + } else { + _channelError.value = "Connection lost: ${e.message}" + } } .collect { message -> _messages.update { it + message } @@ -108,7 +121,11 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() { ) }.onFailure { e -> Log.e("Chat", "Send message error", e) - _channelError.value = "Failed to send message" + if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) { + onUnauthorized?.invoke() + } else { + _channelError.value = "Failed to send message" + } } } } @@ -117,6 +134,7 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() { _messages.value = emptyList() _channelId.value = null _currentUserId.value = null + _spaces.value = emptyList() _error.value = null _channelError.value = null streamJob?.cancel() 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 index 9549ea8..29a465d 100644 --- 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 @@ -2,6 +2,7 @@ package dev.zxq5.chatapp.android.feature.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dev.zxq5.chatapp.android.api.model.InviteRequest import dev.zxq5.chatapp.android.api.model.QrResponse import dev.zxq5.chatapp.android.core.error.ApiResult import dev.zxq5.chatapp.android.data.repository.SettingsRepository @@ -27,6 +28,9 @@ class SettingsViewModel(private val settingsRepository: SettingsRepository) : Vi private val _isSuccessState = MutableStateFlow>(emptyMap()) val isSuccessState: StateFlow> = _isSuccessState + private val _lastInviteCode = MutableStateFlow(null) + val lastInviteCode: StateFlow = _lastInviteCode + fun clearMessages() { _settingsError.value = null _totpError.value = null @@ -40,6 +44,20 @@ class SettingsViewModel(private val settingsRepository: SettingsRepository) : Vi } } + fun createInvite(request: InviteRequest) { + viewModelScope.launch { + _settingsError.value = null + when (val result = settingsRepository.createInvite(request)) { + is ApiResult.Success -> { + _lastInviteCode.value = result.data + triggerSuccess("invite") + } + is ApiResult.HttpError -> _settingsError.value = result.message + is ApiResult.NetworkError -> _settingsError.value = result.message + } + } + } + fun fetchTotpStatus() { viewModelScope.launch { when (val result = settingsRepository.getTotpStatus()) { 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 index 50f0280..707afbc 100644 --- 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 @@ -23,11 +23,14 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -37,8 +40,10 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -57,9 +62,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import android.util.Base64 import android.graphics.BitmapFactory +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign +import dev.zxq5.chatapp.android.api.model.InviteRequest +import kotlin.time.Duration.Companion.days +import kotlin.time.ExperimentalTime +import kotlin.time.Instant -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class) @Composable fun SettingsScreen( viewModel: SettingsViewModel, @@ -70,6 +81,7 @@ fun SettingsScreen( val settingsError by viewModel.settingsError.collectAsState() val isSuccessState by viewModel.isSuccessState.collectAsState() val totpError by viewModel.totpError.collectAsState() + val lastInviteCode by viewModel.lastInviteCode.collectAsState() LaunchedEffect(Unit) { viewModel.clearMessages() @@ -274,6 +286,120 @@ fun SettingsScreen( } } + SettingsSection(title = "invite") { + var inviteName by remember { mutableStateOf("") } + var maxUses by remember { mutableStateOf("1") } + val clipboardManager = LocalClipboardManager.current + + var showDatePicker by remember { mutableStateOf(false) } + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = System.currentTimeMillis() + 7.days.inWholeMilliseconds + ) + + Text("create invite token", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp)) + + OutlinedTextField( + value = inviteName, + onValueChange = { inviteName = it }, + label = { Text("name") }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = maxUses, + onValueChange = { if (it.all { c -> c.isDigit() }) maxUses = it }, + label = { Text("max uses") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = datePickerState.selectedDateMillis?.let { Instant.fromEpochMilliseconds(it).toString().substringBefore("T") } ?: "", + onValueChange = {}, + label = { Text("expiry date") }, + readOnly = true, + trailingIcon = { + IconButton(onClick = { showDatePicker = true }) { + Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Select Date") + } + }, + modifier = Modifier.fillMaxWidth().clickable { showDatePicker = true }, + shape = RoundedCornerShape(8.dp) + ) + + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { showDatePicker = false }) { + Text("ok") + } + } + ) { + DatePicker(state = datePickerState) + } + } + + Spacer(Modifier.height(12.dp)) + + SuccessButton( + onClick = { + val nowMs = System.currentTimeMillis() + val expiryMs = datePickerState.selectedDateMillis ?: (nowMs + 7.days.inWholeMilliseconds) + viewModel.createInvite( + InviteRequest( + name = inviteName, + max_uses = maxUses.toIntOrNull() ?: 1, + start_date = Instant.fromEpochMilliseconds(nowMs), + expiry_date = Instant.fromEpochMilliseconds(expiryMs) + ) + ) + }, + label = "generate invite", + isSuccess = isSuccessState["invite"] == true, + enabled = inviteName.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) + + if (lastInviteCode != null) { + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(8.dp)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = lastInviteCode!!, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { + clipboardManager.setText(AnnotatedString(lastInviteCode!!)) + }) { + Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(20.dp)) + } + } + } + } + + SettingsSection(title = "session") { + Button( + onClick = onLogout, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black) + ) { + Text("logout") + } + } + SettingsSection(title = "danger zone", color = Color.Red.copy(alpha = 0.7f)) { var deletePassword by remember { mutableStateOf("") } var deleteTotp by remember { mutableStateOf("") } @@ -337,18 +463,6 @@ fun SettingsScreen( } } - 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)) } @@ -457,6 +571,7 @@ fun SuccessButton( } } +@OptIn(ExperimentalTime::class) @Composable fun TwoFactorSetup( qrCodeBase64: String?, @@ -511,15 +626,13 @@ fun TwoFactorSetup( 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) }, + Spacer(Modifier.height(16.dp)) + SuccessButton( + onClick = { onConfirm(code) }, + label = "verify and enable", + isSuccess = false, // Managed by parent enabled = code.length == 6, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp) - ) { - Text("confirm code") - } + modifier = Modifier.fillMaxWidth() + ) } } diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/textfield.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/textfield.kt index 349cfa3..d318224 100644 --- a/android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/textfield.kt +++ b/android/app/src/main/java/dev/zxq5/chatapp/android/ui/components/textfield.kt @@ -2,12 +2,14 @@ package dev.zxq5.chatapp.android.ui.components import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp @@ -31,6 +33,11 @@ fun TextField( modifier = Modifier.fillMaxWidth(), singleLine = true, textStyle = MaterialTheme.typography.bodyLarge, + keyboardOptions = if (isPassword) { + KeyboardOptions(keyboardType = KeyboardType.Password) + } else { + KeyboardOptions.Default + }, visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None, shape = RoundedCornerShape(8.dp), colors = OutlinedTextFieldDefaults.colors( @@ -40,6 +47,6 @@ fun TextField( unfocusedBorderColor = MaterialTheme.colorScheme.outline, focusedTextColor = MaterialTheme.colorScheme.onSurface, unfocusedTextColor = MaterialTheme.colorScheme.onSurface - ) + ), ) }