From d30988f8b458494fae00fbd30f275b3d171872f9 Mon Sep 17 00:00:00 2001 From: Garfie Date: Thu, 26 Mar 2026 17:33:41 -0500 Subject: [PATCH] Sync upstream to v0.8.4 Breaking changes: - 2FA enable changed from GET to POST with OTP verification body - Backup code regeneration now requires OTP verification - Account deletion requires OTP when 2FA is enabled - New shared OtpVerificationDialog component in core/ui - Fix OTP digit box layout (weight-based instead of fixed size) Additive changes: - Add effort (AnthropicEffort) and thinkingLevel (Google) fields to Conversation, Preset, and ChatRequest models - Add tool_options to Agent model and create/update requests - Add artifacts field to EphemeralAgent - Add remoteAgents to InterfaceConfig - Add programmatic_tools and deferred_tools agent capabilities - Add thinkingLevel parameter to EndpointParameterRegistry SSE hardening: - Implement mapSyncEvent() for resume state-snapshot protocol - Preserve partial stream content on disconnect (don't wipe visible text) - Persist conversation to Room on stream creation (not just on Final) - Auto-reconnect SSE stream when network recovers via ConnectivityObserver - Network-aware retries in SseClient (wait for connectivity, don't burn attempts) - Add StreamEvent.Retrying for retry progress UI feedback - Lazy connectivity observation (only when network error occurs) - Clear draft/streaming state on SSE stream expiry - Add isNetworkError flag to StreamEvent.Error for typed detection Code quality: - Remove [Comparison] debug Timber logs (perf overhead on every SSE event) - Replace Log.d with Timber.d across feature modules - Extract hardcoded OTP dialog strings to strings.xml for localization - Parameterize OtpVerificationDialog labels for localizability - Add auto-submit guard with reset on failure for OTP input - Replace fake QR code generator with copyable otpauth:// URI - Replace fragile OTP string matching with HTTP status code checks - Consolidate identical request classes into OtpVerificationRequest - Share isHttpStatus helper across settings module - Remove duplicate verifyTempToken method (identical to verifyTwoFactor) - Remove duplicate 2FA dialog composables from AccountSettingsScreen - Extract shared resume stream logic to reduce duplication - Guard no-op retryInfo state updates - Move ApiException to core:common for cross-module access - Document SseEventMapper single-stream threading constraint Bump SUPPORTED_BACKEND_VERSION to 0.8.4, advance upstream submodule. --- .../android/core/common/BackendVersion.kt | 2 +- .../android/core/common/Constants.kt | 2 + .../core/common/result/ApiException.kt | 11 + .../core/data/repository/AuthRepository.kt | 5 +- .../data/repository/AuthRepositoryImpl.kt | 19 +- .../data/repository/ChatPayloadBuilder.kt | 5 - .../data/repository/ChatRepositoryImpl.kt | 10 +- .../data/repository/ConfigRepositoryImpl.kt | 2 +- .../core/data/repository/UserRepository.kt | 2 +- .../data/repository/UserRepositoryImpl.kt | 4 +- .../com/librechat/android/core/model/Agent.kt | 1 + .../android/core/model/Conversation.kt | 2 + .../librechat/android/core/model/Preset.kt | 2 + .../android/core/model/StartupConfig.kt | 1 + .../android/core/model/StreamEvent.kt | 6 + .../android/core/model/request/ChatRequest.kt | 2 + .../core/model/request/CreateAgentRequest.kt | 1 + .../core/model/request/EphemeralAgent.kt | 1 + .../model/request/OtpVerificationRequest.kt | 13 + .../core/model/request/UpdateAgentRequest.kt | 1 + .../android/core/network/api/AuthApi.kt | 13 +- .../android/core/network/api/UserApi.kt | 6 +- .../core/network/client/ApiException.kt | 10 +- .../android/core/network/sse/SseClient.kt | 28 +- .../core/network/sse/SseEventMapper.kt | 36 ++- .../components/EndpointParameterRegistry.kt | 8 + .../ui/components/OtpVerificationDialog.kt | 226 ++++++++++++++ .../agents/viewmodel/AgentEditorViewModel.kt | 2 + .../feature/auth/screen/TwoFactorScreen.kt | 2 +- .../feature/chat/viewmodel/ChatUiState.kt | 8 + .../feature/chat/viewmodel/ChatViewModel.kt | 208 ++++++++++--- .../delegate/ModelSelectionDelegate.kt | 5 +- feature/settings/build.gradle.kts | 1 + .../settings/screen/AccountSettingsScreen.kt | 291 ++++-------------- .../feature/settings/screen/SettingsScreen.kt | 55 ++++ .../screen/sections/SecuritySection.kt | 82 +---- .../settings/viewmodel/SettingsViewModel.kt | 30 +- .../delegate/TwoFactorSecurityDelegate.kt | 83 +++-- .../settings/src/main/res/values/strings.xml | 13 +- 39 files changed, 761 insertions(+), 438 deletions(-) create mode 100644 core/common/src/main/kotlin/com/librechat/android/core/common/result/ApiException.kt create mode 100644 core/model/src/main/kotlin/com/librechat/android/core/model/request/OtpVerificationRequest.kt create mode 100644 core/ui/src/main/kotlin/com/librechat/android/core/ui/components/OtpVerificationDialog.kt diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/BackendVersion.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/BackendVersion.kt index cf719b8..4de9880 100644 --- a/core/common/src/main/kotlin/com/librechat/android/core/common/BackendVersion.kt +++ b/core/common/src/main/kotlin/com/librechat/android/core/common/BackendVersion.kt @@ -18,7 +18,7 @@ object BackendVersion { * Matches the VERSION constant from the official LibreChat repo's * `packages/data-provider/src/config.ts` and `package.json`. */ - const val SUPPORTED_BACKEND_VERSION = "0.8.2" + const val SUPPORTED_BACKEND_VERSION = "0.8.4" /** * Represents a parsed semantic version (major.minor.patch). diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/Constants.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/Constants.kt index a124591..7d05bfa 100644 --- a/core/common/src/main/kotlin/com/librechat/android/core/common/Constants.kt +++ b/core/common/src/main/kotlin/com/librechat/android/core/common/Constants.kt @@ -9,6 +9,8 @@ object ToolConstants { const val CODE_INTERPRETER = "code_interpreter" const val FILE_SEARCH = "file_search" const val EXECUTE_CODE = "execute_code" + const val PROGRAMMATIC_TOOLS = "programmatic_tools" + const val DEFERRED_TOOLS = "deferred_tools" } object ChatLayoutConstants { diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/result/ApiException.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/result/ApiException.kt new file mode 100644 index 0000000..c9e30f9 --- /dev/null +++ b/core/common/src/main/kotlin/com/librechat/android/core/common/result/ApiException.kt @@ -0,0 +1,11 @@ +package com.librechat.android.core.common.result + +/** + * Exception thrown when the server returns a non-2xx HTTP response. + * The [message] field contains a user-friendly error extracted from the response body. + */ +class ApiException( + val statusCode: Int, + override val message: String, + val isBanned: Boolean = false, +) : Exception(message) diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepository.kt index e52b684..7954220 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepository.kt +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepository.kt @@ -9,14 +9,13 @@ interface AuthRepository { suspend fun login(email: String, password: String): Result suspend fun loginWithOAuthToken(refreshToken: String): Result suspend fun verifyTwoFactor(tempToken: String, code: String): Result - suspend fun verifyTempToken(tempToken: String, code: String): Result suspend fun register(name: String, email: String, username: String, password: String): Result suspend fun logout(): Result suspend fun isLoggedIn(): Boolean - suspend fun enableTwoFactor(): Result + suspend fun enableTwoFactor(token: String? = null, backupCode: String? = null): Result suspend fun confirmTwoFactor(code: String): Result suspend fun disableTwoFactor(code: String): Result - suspend fun regenerateBackupCodes(): Result + suspend fun regenerateBackupCodes(token: String? = null, backupCode: String? = null): Result suspend fun requestPasswordReset(email: String): Result suspend fun resetPassword(userId: String, token: String, password: String, confirmPassword: String): Result } diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt index 66a4cb9..a213c84 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt @@ -75,17 +75,6 @@ class AuthRepositoryImpl @Inject constructor( } } - override suspend fun verifyTempToken(tempToken: String, code: String): Result { - return safeApiCall { - val result = authApi.verifyTempToken(tempToken = tempToken, totpCode = code) - tokenManager.setTokens( - accessToken = result.response.token ?: "", - refreshToken = result.refreshToken ?: "", - ) - result.response.user ?: throw IllegalStateException("No user in 2FA temp response") - } - } - override suspend fun register( name: String, email: String, @@ -129,9 +118,9 @@ class AuthRepositoryImpl @Inject constructor( return tokenManager.getAccessToken() != null } - override suspend fun enableTwoFactor(): Result { + override suspend fun enableTwoFactor(token: String?, backupCode: String?): Result { return safeApiCall { - authApi.enableTwoFactor() + authApi.enableTwoFactor(token = token, backupCode = backupCode) } } @@ -147,9 +136,9 @@ class AuthRepositoryImpl @Inject constructor( } } - override suspend fun regenerateBackupCodes(): Result { + override suspend fun regenerateBackupCodes(token: String?, backupCode: String?): Result { return safeApiCall { - authApi.regenerateBackupCodes() + authApi.regenerateBackupCodes(token = token, backupCode = backupCode) } } diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatPayloadBuilder.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatPayloadBuilder.kt index 6cd8daf..fa6ce0d 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatPayloadBuilder.kt +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatPayloadBuilder.kt @@ -6,7 +6,6 @@ import com.librechat.android.core.model.request.AddedConversation import com.librechat.android.core.model.request.ChatRequest import com.librechat.android.core.model.request.EphemeralAgent import com.librechat.android.core.model.request.NO_PARENT -import timber.log.Timber object ChatPayloadBuilder { @@ -35,10 +34,6 @@ object ChatPayloadBuilder { val resolvedParentMessageId = parentMessageId ?: NO_PARENT - Timber.d("[Comparison] ChatPayloadBuilder.build: addedConvo=%s, endpoint=%s, model=%s, agentId=%s", - if (addedConvo != null) "present(endpoint=${addedConvo.endpoint}, model=${addedConvo.model}, agentId=${addedConvo.agentId})" else "null", - resolvedEndpoint, model, agentId) - return ChatRequest( text = text, conversationId = conversationId, diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepositoryImpl.kt index 563a160..9ef7009 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.librechat.android.core.data.repository +import com.librechat.android.core.common.network.ConnectivityObserver import com.librechat.android.core.common.result.Result import com.librechat.android.core.common.result.safeApiCall import com.librechat.android.core.model.FileReference @@ -16,7 +17,6 @@ import io.ktor.client.HttpClient import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow -import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -25,6 +25,7 @@ class ChatRepositoryImpl @Inject constructor( private val chatApi: ChatApi, private val sseClient: SseClient, @StreamingClient private val sseHttpClient: HttpClient, + private val connectivityObserver: ConnectivityObserver, ) : ChatRepository { override fun startChat( @@ -62,9 +63,6 @@ class ChatRepositoryImpl @Inject constructor( addedConvo = addedConvo, ephemeralAgent = ephemeralAgent, ) - - Timber.d("[Comparison] startChat: POST addedConvo=%s, endpoint=%s", if (addedConvo != null) "present" else "null", endpoint) - val startResponse = chatApi.startChat(endpoint, request) val streamId = startResponse.conversationId @@ -78,7 +76,7 @@ class ChatRepositoryImpl @Inject constructor( // Phase 2: GET the SSE stream using the streamId val streamUrl = "api/agents/chat/stream/$streamId" - emitAll(sseClient.connect(sseHttpClient, streamUrl)) + emitAll(sseClient.connect(sseHttpClient, streamUrl, connectivityFlow = connectivityObserver.isConnected)) } override suspend fun abortChat(streamId: String): Result = safeApiCall { @@ -91,6 +89,6 @@ class ChatRepositoryImpl @Inject constructor( override fun resumeStream(conversationId: String): Flow = flow { val streamUrl = "api/agents/chat/stream/$conversationId" - emitAll(sseClient.connect(sseHttpClient, streamUrl, resume = true)) + emitAll(sseClient.connect(sseHttpClient, streamUrl, resume = true, connectivityFlow = connectivityObserver.isConnected)) } } diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt index b1ba4b8..0a2c854 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt @@ -7,7 +7,7 @@ import com.librechat.android.core.data.datastore.ConfigCacheDataStore import com.librechat.android.core.model.EndpointConfig import com.librechat.android.core.model.StartupConfig import com.librechat.android.core.network.api.ConfigApi -import com.librechat.android.core.network.client.ApiException +import com.librechat.android.core.common.result.ApiException import io.ktor.client.plugins.HttpRequestTimeoutException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepository.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepository.kt index 5628bb6..2324d11 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepository.kt +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepository.kt @@ -7,7 +7,7 @@ import com.librechat.android.core.model.response.TermsResponse interface UserRepository { suspend fun getUser(): Result - suspend fun deleteUser(): Result + suspend fun deleteUser(token: String? = null, backupCode: String? = null): Result suspend fun uploadAvatar(imageBytes: ByteArray): Result suspend fun verifyEmail(token: String): Result suspend fun resendVerification(email: String): Result diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepositoryImpl.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepositoryImpl.kt index 148d78f..b2c63df 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepositoryImpl.kt @@ -20,8 +20,8 @@ class UserRepositoryImpl @Inject constructor( userApi.getUser() } - override suspend fun deleteUser(): Result = safeApiCall { - userApi.deleteUser() + override suspend fun deleteUser(token: String?, backupCode: String?): Result = safeApiCall { + userApi.deleteUser(token = token, backupCode = backupCode) } override suspend fun uploadAvatar(imageBytes: ByteArray): Result = safeApiCall { diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Agent.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Agent.kt index db44cd7..d96dd64 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/Agent.kt +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Agent.kt @@ -40,6 +40,7 @@ data class Agent( val updatedAt: String? = null, val createdAt: String? = null, @SerialName("support_contact") val supportContact: JsonElement? = null, + @SerialName("tool_options") val toolOptions: JsonObject? = null, val mcpServerNames: List? = null, ) { val avatarUrl: String? diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Conversation.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Conversation.kt index 748f501..57e48f0 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/Conversation.kt +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Conversation.kt @@ -28,6 +28,8 @@ data class Conversation( val maxTokens: Int? = null, val system: String? = null, @SerialName("reasoning_effort") val reasoningEffort: String? = null, + val effort: String? = null, + @SerialName("thinkingLevel") val thinkingLevel: String? = null, val stop: List? = null, val iconURL: String? = null, val greeting: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Preset.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/Preset.kt index 25416d2..14c9f0f 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/Preset.kt +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/Preset.kt @@ -24,6 +24,8 @@ data class Preset( val iconURL: String? = null, val greeting: String? = null, val stop: List? = null, + val effort: String? = null, + @SerialName("thinkingLevel") val thinkingLevel: String? = null, @SerialName("web_search") val webSearch: Boolean? = null, val createdAt: String? = null, val updatedAt: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/StartupConfig.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/StartupConfig.kt index 70f9cd9..fee368d 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/StartupConfig.kt +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/StartupConfig.kt @@ -91,6 +91,7 @@ data class InterfaceConfig( val fileSearch: Boolean = true, val fileCitations: Boolean = true, val customWelcome: String? = null, + val remoteAgents: JsonElement? = null, ) @Serializable diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/StreamEvent.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/StreamEvent.kt index baca871..47f2820 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/StreamEvent.kt +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/StreamEvent.kt @@ -57,6 +57,12 @@ sealed interface StreamEvent { data class Error( val message: String, val code: String? = null, + val isNetworkError: Boolean = false, + ) : StreamEvent + + data class Retrying( + val attempt: Int, + val maxAttempts: Int, ) : StreamEvent data class Step( diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatRequest.kt index 55a378f..cf88635 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatRequest.kt +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatRequest.kt @@ -29,6 +29,8 @@ data class ChatRequest( val maxContextTokens: Int? = null, val system: String? = null, @SerialName("reasoning_effort") val reasoningEffort: String? = null, + val effort: String? = null, + @SerialName("thinkingLevel") val thinkingLevel: String? = null, val stop: List? = null, val tools: List? = null, val iconURL: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateAgentRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateAgentRequest.kt index 5a4f898..08b9697 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateAgentRequest.kt +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateAgentRequest.kt @@ -24,4 +24,5 @@ data class CreateAgentRequest( val isCollaborative: Boolean? = null, @SerialName("projectIds") val projectIds: List? = null, @SerialName("support_contact") val supportContact: SupportContact? = null, + @SerialName("tool_options") val toolOptions: JsonObject? = null, ) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/EphemeralAgent.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/EphemeralAgent.kt index 8863511..bcd85b2 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/request/EphemeralAgent.kt +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/EphemeralAgent.kt @@ -9,4 +9,5 @@ data class EphemeralAgent( @SerialName("web_search") val webSearch: Boolean? = null, @SerialName("file_search") val fileSearch: Boolean? = null, @SerialName("execute_code") val executeCode: Boolean? = null, + val artifacts: String? = null, ) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/OtpVerificationRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/OtpVerificationRequest.kt new file mode 100644 index 0000000..c8a4c3c --- /dev/null +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/OtpVerificationRequest.kt @@ -0,0 +1,13 @@ +package com.librechat.android.core.model.request + +import kotlinx.serialization.Serializable + +/** + * Shared OTP verification body used by endpoints that optionally require a TOTP + * token or backup code (e.g. 2FA enable, backup-code regeneration, account deletion). + */ +@Serializable +data class OtpVerificationRequest( + val token: String? = null, + val backupCode: String? = null, +) diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateAgentRequest.kt b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateAgentRequest.kt index dcf5821..0b98b46 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateAgentRequest.kt +++ b/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateAgentRequest.kt @@ -24,4 +24,5 @@ data class UpdateAgentRequest( val isCollaborative: Boolean? = null, @SerialName("projectIds") val projectIds: List? = null, @SerialName("support_contact") val supportContact: SupportContact? = null, + @SerialName("tool_options") val toolOptions: JsonObject? = null, ) diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/AuthApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/AuthApi.kt index 98d92fc..a794036 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/api/AuthApi.kt +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/AuthApi.kt @@ -5,6 +5,7 @@ import com.librechat.android.core.model.request.PasswordResetRequest import com.librechat.android.core.model.request.RegisterRequest import com.librechat.android.core.model.request.ResetPasswordRequest import com.librechat.android.core.model.request.TwoFactorDisableRequest +import com.librechat.android.core.model.request.OtpVerificationRequest import com.librechat.android.core.model.request.TwoFactorVerifyRequest import com.librechat.android.core.model.request.TwoFactorVerifyTempRequest import com.librechat.android.core.model.response.LoginResponse @@ -120,9 +121,12 @@ class AuthApi @Inject constructor( return LoginResult(body, refreshToken) } - suspend fun enableTwoFactor(): TwoFactorSetupResponse = - client.get { + suspend fun enableTwoFactor(token: String? = null, backupCode: String? = null): TwoFactorSetupResponse = + client.post { url { path("api/auth/2fa/enable") } + if (token != null || backupCode != null) { + setBody(OtpVerificationRequest(token = token, backupCode = backupCode)) + } }.body() suspend fun confirmTwoFactor(code: String): TwoFactorSetupResponse = @@ -151,9 +155,12 @@ class AuthApi @Inject constructor( return LoginResult(body, refreshToken) } - suspend fun regenerateBackupCodes(): TwoFactorSetupResponse = + suspend fun regenerateBackupCodes(token: String? = null, backupCode: String? = null): TwoFactorSetupResponse = client.post { url { path("api/auth/2fa/backup/regenerate") } + if (token != null || backupCode != null) { + setBody(OtpVerificationRequest(token = token, backupCode = backupCode)) + } }.body() suspend fun disableTwoFactor(code: String) { diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/UserApi.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/api/UserApi.kt index 7378697..5c930a8 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/api/UserApi.kt +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/api/UserApi.kt @@ -2,6 +2,7 @@ package com.librechat.android.core.network.api import com.librechat.android.core.model.User import com.librechat.android.core.model.UserFavorite +import com.librechat.android.core.model.request.OtpVerificationRequest import com.librechat.android.core.model.request.ResendVerificationRequest import com.librechat.android.core.model.request.VerifyEmailRequest import com.librechat.android.core.model.response.TermsResponse @@ -49,9 +50,12 @@ class UserApi @Inject constructor( setBody(update) }.body() - suspend fun deleteUser() { + suspend fun deleteUser(token: String? = null, backupCode: String? = null) { client.delete { url { path("api/user/delete") } + if (token != null || backupCode != null) { + setBody(OtpVerificationRequest(token = token, backupCode = backupCode)) + } } } diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/ApiException.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/client/ApiException.kt index 59ad9a8..983e3c2 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/client/ApiException.kt +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/client/ApiException.kt @@ -1,11 +1,3 @@ package com.librechat.android.core.network.client -/** - * Exception thrown when the server returns a non-2xx HTTP response. - * The [message] field contains a user-friendly error extracted from the response body. - */ -class ApiException( - val statusCode: Int, - override val message: String, - val isBanned: Boolean = false, -) : Exception(message) +typealias ApiException = com.librechat.android.core.common.result.ApiException diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseClient.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseClient.kt index 1c55c57..14d4d66 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseClient.kt +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseClient.kt @@ -12,6 +12,7 @@ import io.ktor.http.isSuccess import io.ktor.http.path import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.serialization.json.Json import timber.log.Timber @@ -24,10 +25,16 @@ class SseClient @Inject constructor( private val mapper = SseEventMapper(json) private val lineParser = SseLineParser() + /** + * @param connectivityFlow Optional flow of network connectivity state. When provided, + * retries will wait for the network to become available before attempting reconnection, + * avoiding wasted retry attempts while offline. + */ fun connect( client: HttpClient, streamPath: String, resume: Boolean = false, + connectivityFlow: Flow? = null, ): Flow = flow { mapper.resetState() var attempt = 0 @@ -88,7 +95,7 @@ class SseClient @Inject constructor( Timber.w(e, "SSE I/O error (attempt $attempt)") attempt++ if (attempt > maxRetries) { - emit(StreamEvent.Error(message = "Connection lost. Please check your network and try again.")) + emit(StreamEvent.Error(message = "Connection lost. Please check your network and try again.", isNetworkError = true)) done = true } } catch (e: Exception) { @@ -101,6 +108,25 @@ class SseClient @Inject constructor( } if (!done) { + emit(StreamEvent.Retrying(attempt = attempt, maxAttempts = maxRetries)) + + // If we have connectivity info and network is down, wait for it + // to come back instead of burning through retry delays blindly. + // Note: Each .first() call creates a new ConnectivityManager callback registration. + // Consider using a shared StateFlow if retry frequency becomes a concern. + if (connectivityFlow != null) { + try { + val isConnected = connectivityFlow.first() + if (!isConnected) { + Timber.d("SSE: network is down, waiting for connectivity before retry $attempt") + connectivityFlow.first { it } + Timber.d("SSE: network restored, proceeding with retry $attempt") + } + } catch (e: Exception) { + Timber.w(e, "SSE: error checking connectivity, falling back to delay") + } + } + val delayMs = min(initialDelayMs * (1L shl (attempt - 1)), maxDelayMs) delay(delayMs) shouldResume = true diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt b/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt index bc395fc..fa285bc 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt +++ b/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt @@ -32,6 +32,9 @@ import timber.log.Timber * {"created":{"message":{...}}} * {"sync":true,"resumeState":{...}} * ``` + * + * NOTE: This class holds mutable state that is reset per-connection via [resetState]. + * It is NOT thread-safe. Only use from a single coroutine (as done in [SseClient.connect]). */ class SseEventMapper(private val json: Json) { @@ -195,8 +198,35 @@ class SseEventMapper(private val json: Json) { } private fun mapSyncEvent(root: JsonObject): StreamEvent? { - // TODO: Parse resumeState.aggregatedContent for full sync support - return null + val resumeState = root["resumeState"]?.jsonObject ?: return null + + // Replay run steps as individual events first — the caller will handle them + // The web client replays these as on_run_step events; for our mapper we + // focus on the aggregatedContent snapshot that replaces current message state. + val runSteps = resumeState["runSteps"]?.jsonArray + + // Parse aggregatedContent — this is the full content array that replaces + // whatever the client currently has for this response message. + val aggregatedContent = resumeState["aggregatedContent"]?.jsonArray + if (aggregatedContent == null && runSteps == null) return null + + val contentParts = if (aggregatedContent != null) { + aggregatedContent.mapNotNull { element -> + try { + json.decodeFromJsonElement( + com.librechat.android.core.model.MessageContentPart.serializer(), + element, + ) + } catch (e: Exception) { + Timber.w(e, "Failed to parse sync aggregatedContent part") + null + } + } + } else { + emptyList() + } + + return StreamEvent.Sync(aggregatedContent = contentParts) } // --- LangGraph events --- @@ -222,8 +252,6 @@ class SseEventMapper(private val json: Json) { val agentId = eventAgentId ?: activeAgentId val groupId = eventGroupId ?: activeGroupId - Timber.d("[Comparison] mapLangGraphEvent: type=%s, agentId=%s, groupId=%s", eventType, agentId, groupId) - return when (eventType) { "on_message_delta" -> mapMessageDelta(data, agentId, groupId) "on_reasoning_delta" -> mapReasoningDelta(data, agentId, groupId) diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointParameterRegistry.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointParameterRegistry.kt index b6fed43..d39be7f 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointParameterRegistry.kt +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointParameterRegistry.kt @@ -332,6 +332,14 @@ object EndpointParameterRegistry { default = "", description = "Max tokens for thinking (-1 = dynamic, up to 32000).", ), + ParameterDefinition( + key = "thinkingLevel", + label = "Thinking Level", + type = ParameterType.DROPDOWN, + options = listOf("", "none", "low", "medium", "high"), + default = "", + description = "Controls how much thinking the model performs.", + ), ParameterDefinition( key = "web_search", label = "Grounding with Google Search", diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/OtpVerificationDialog.kt b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/OtpVerificationDialog.kt new file mode 100644 index 0000000..c12ccb0 --- /dev/null +++ b/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/OtpVerificationDialog.kt @@ -0,0 +1,226 @@ +package com.librechat.android.core.ui.components + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * Reusable OTP verification dialog for 2FA-protected operations. + * + * Shows a 6-digit OTP input with visual digit boxes (paste-friendly, auto-submit on 6th digit). + * Includes a "Use a backup code instead" toggle that switches to a single text field + * for alphanumeric backup code input with an explicit Verify button. + * + * @param title Dialog title (e.g. "Verify Identity", "Enter OTP") + * @param description Optional description text shown below the title + * @param isLoading Whether to disable inputs while an operation is in progress + * @param onVerify Called with (token, backupCode) — one will be non-null + * @param onDismiss Called when the dialog is dismissed + * @param verifyLabel Label for the verify button (backup code mode) + * @param cancelLabel Label for the cancel/dismiss button + * @param backupCodeLabel Label for the backup code text field + * @param useBackupToggleLabel Text for the "use backup code" toggle + * @param useOtpToggleLabel Text for the "use OTP" toggle + */ +@Composable +fun OtpVerificationDialog( + title: String, + description: String? = null, + isLoading: Boolean = false, + onVerify: (token: String?, backupCode: String?) -> Unit, + onDismiss: () -> Unit, + verifyLabel: String = "Verify", + cancelLabel: String = "Cancel", + backupCodeLabel: String = "Backup Code", + useBackupToggleLabel: String = "Use a backup code instead", + useOtpToggleLabel: String = "Use OTP code instead", +) { + var otpValue by remember { mutableStateOf("") } + var backupCodeValue by remember { mutableStateOf("") } + var useBackupCode by remember { mutableStateOf(false) } + var submitted by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(isLoading) { + if (!isLoading) { + submitted = false + } + } + + LaunchedEffect(useBackupCode) { + focusRequester.requestFocus() + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + if (useBackupCode) { + OutlinedTextField( + value = backupCodeValue, + onValueChange = { backupCodeValue = it }, + label = { Text(backupCodeLabel) }, + singleLine = true, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + if (backupCodeValue.isNotBlank()) { + onVerify(null, backupCodeValue.trim()) + } + }, + ), + ) + } else { + OtpInput( + value = otpValue, + onValueChange = { newValue -> + if (newValue.length <= 6 && newValue.all { it.isDigit() }) { + otpValue = newValue + if (newValue.length == 6 && !submitted) { + submitted = true + onVerify(newValue, null) + } + } + }, + enabled = !isLoading, + modifier = Modifier.focusRequester(focusRequester), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = { + useBackupCode = !useBackupCode + otpValue = "" + backupCodeValue = "" + }, + enabled = !isLoading, + ) { + Text( + if (useBackupCode) useOtpToggleLabel else useBackupToggleLabel, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { + if (useBackupCode) { + TextButton( + onClick = { onVerify(null, backupCodeValue.trim()) }, + enabled = !isLoading && backupCodeValue.isNotBlank(), + ) { + Text(verifyLabel) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !isLoading) { + Text(cancelLabel) + } + }, + ) +} + +@Composable +private fun OtpInput( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + modifier = modifier.fillMaxWidth(), + decorationBox = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + modifier = Modifier.fillMaxWidth(), + ) { + repeat(6) { index -> + val char = value.getOrNull(index)?.toString() ?: "" + val isFocused = index == value.length + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + shape = DIGIT_BOX_SHAPE, + ) + .background( + MaterialTheme.colorScheme.surface, + DIGIT_BOX_SHAPE, + ), + ) { + Text( + text = char, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + } + } + } + }, + ) +} + +private val DIGIT_BOX_SHAPE = RoundedCornerShape(8.dp) diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt index 507c8a6..9cad869 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt +++ b/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt @@ -745,6 +745,8 @@ class AgentEditorViewModel @Inject constructor( "web_search", "end_after_tools", "hide_sequential_outputs", + "programmatic_tools", + "deferred_tools", ) // MCP server marker prefix: tools starting with "sys__server__sys" or containing "_mcp_" diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt index 4170660..606257a 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt +++ b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt @@ -180,7 +180,7 @@ private fun DigitBoxes( } }, modifier = Modifier - .width(48.dp) + .weight(1f) .focusRequester(focusRequesters[index]), enabled = enabled, singleLine = true, diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatUiState.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatUiState.kt index 5ecc35d..e6348c3 100644 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatUiState.kt +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatUiState.kt @@ -44,6 +44,12 @@ data class SearchMatch( val occurrenceInMessage: Int, ) +@androidx.compose.runtime.Immutable +data class RetryInfo( + val attempt: Int, + val maxAttempts: Int, +) + @androidx.compose.runtime.Immutable data class ActiveToolCall( val id: String, @@ -99,6 +105,8 @@ data class ChatUiState( // MCP server state val mcpServers: List = emptyList(), val selectedMcpServerNames: Set = emptySet(), + // SSE reconnection retry state (null when not retrying) + val retryInfo: RetryInfo? = null, // Pull-to-refresh state val isRefreshingMessages: Boolean = false, // Merged from separate StateFlows diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatViewModel.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatViewModel.kt index c93f4c8..27e6563 100644 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatViewModel.kt +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/ChatViewModel.kt @@ -5,11 +5,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import android.content.Context import android.net.Uri -import android.util.Log import timber.log.Timber import com.librechat.android.feature.chat.util.NEW_CHAT_DRAFT_KEY import com.librechat.android.core.common.EndpointConstants import com.librechat.android.core.common.ToolConstants +import com.librechat.android.core.common.network.ConnectivityObserver import com.librechat.android.core.common.result.Result import com.librechat.android.core.common.result.getOrNull import com.librechat.android.core.data.datastore.LatexRenderer @@ -56,7 +56,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive @@ -81,6 +84,7 @@ class ChatViewModel @Inject constructor( private val speechRepository: SpeechRepository, mcpRepository: McpRepository, private val userRepository: UserRepository, + private val connectivityObserver: ConnectivityObserver, serverDataStore: ServerDataStore, private val settingsDataStore: SettingsDataStore, ) : ViewModel() { @@ -146,6 +150,10 @@ class ChatViewModel @Inject constructor( private val streamingBuffer = StringBuilder() private var streamingBufferDirty = false private var wasStreaming = false + /** Tracks whether the last stream failure was a network error, to enable auto-reconnect. */ + private var lastErrorWasNetwork = false + /** Job for the connectivity observer; started lazily only when a network error occurs. */ + private var connectivityJob: Job? = null companion object { /** Minimum interval between streaming UI state updates to avoid recomposition spam. */ @@ -274,6 +282,7 @@ class ChatViewModel @Inject constructor( modelDelegate.loadAgents() loadSharedLinksEnabled() voiceDelegate.loadSpeechConfig() + } // ── Core chat flow ────────────────────────────────────────────── @@ -508,11 +517,6 @@ class ChatViewModel @Inject constructor( val effectiveEndpoint = _uiState.value.selectedEndpoint val effectiveAgentId = if (isAgent) _uiState.value.selectedModel else null val effectiveAddedConvo = addedConvo // null when no comparison - if (addedConvo != null) { - Timber.d("[Comparison] endpoint=%s, agentId=%s, addedConvo.endpoint=%s, addedConvo.agentId=%s", - effectiveEndpoint, effectiveAgentId, addedConvo.endpoint, addedConvo.agentId) - } - // If comparison enabled, prepare comparison streaming state if (addedConvo != null) { modelDelegate.primaryComparisonBuffer.clear() @@ -746,15 +750,26 @@ class ChatViewModel @Inject constructor( } catch (e: Exception) { Timber.e(e, "Stream collection failed") stopStreamingUpdater() - streamingBuffer.clear() - streamingBufferDirty = false + // Preserve partial content so users can read/copy what was received + val partialContent = streamingBuffer.toString() _uiState.value = _uiState.value.copy( isStreaming = false, - streamingContent = "", + streamingContent = partialContent, activeToolCalls = emptyList(), streamingAttachments = emptyList(), error = e.message ?: "Chat request failed", + comparisonState = _uiState.value.comparisonState.copy( + primaryIsStreaming = false, + secondaryIsStreaming = false, + primaryActiveToolCalls = emptyList(), + secondaryActiveToolCalls = emptyList(), + ), ) + // If the server already created a conversation, fetch whatever it persisted + val conversationId = _uiState.value.conversationId + if (conversationId != null) { + loadConversation(conversationId) + } } } @@ -770,9 +785,16 @@ class ChatViewModel @Inject constructor( } } } - _uiState.value = _uiState.value.copy( - conversationId = event.conversationId, - ) + if (lastErrorWasNetwork) { + lastErrorWasNetwork = false + cancelConnectivityObserver() + } + val createdCopy = if (_uiState.value.retryInfo != null) { + _uiState.value.copy(conversationId = event.conversationId, retryInfo = null) + } else { + _uiState.value.copy(conversationId = event.conversationId) + } + _uiState.value = createdCopy if (isNewConversation && event.conversationId != null && _uiState.value.pendingNavigationConversationId == null && !_uiState.value.comparisonState.isEnabled @@ -781,12 +803,16 @@ class ChatViewModel @Inject constructor( pendingNavigationConversationId = event.conversationId, ) } + // Eagerly fetch and cache the conversation the server just created, + // so it appears in the conversation list even if the stream fails later + if (event.conversationId != null) { + viewModelScope.launch { + conversationRepository.getConversation(event.conversationId) + } + } } is StreamEvent.ContentDelta -> { val isComparison = _uiState.value.comparisonState.isEnabled - Timber.d("[Comparison] ContentDelta: agentId=%s, groupId=%s, isComparison=%s, buffer=%s", - event.agentId, event.groupId, isComparison, - if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) "secondary" else "primary") if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) { modelDelegate.secondaryComparisonBuffer.append(event.chunk) _uiState.update { @@ -815,9 +841,6 @@ class ChatViewModel @Inject constructor( } is StreamEvent.ThinkingDelta -> { val isComparison = _uiState.value.comparisonState.isEnabled - Timber.d("[Comparison] ThinkingDelta: agentId=%s, groupId=%s, isComparison=%s, buffer=%s", - event.agentId, event.groupId, isComparison, - if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) "secondary" else "primary") if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) { modelDelegate.secondaryComparisonBuffer.append(event.chunk) _uiState.update { @@ -847,13 +870,6 @@ class ChatViewModel @Inject constructor( is StreamEvent.Final -> { stopStreamingUpdater() val isComparison = _uiState.value.comparisonState.isEnabled - Timber.d("[Comparison] Final: isComparison=%s, parallelMessageId=%s, primaryBuf=%d, secondaryBuf=%d, primaryAgentId=%s, secondaryAgentId=%s", - isComparison, - (event.responseMessage ?: event.message)?.messageId, - modelDelegate.primaryComparisonBuffer.length, - modelDelegate.secondaryComparisonBuffer.length, - _uiState.value.comparisonState.primaryAgentId, - _uiState.value.comparisonState.secondaryAgentId) val conversationId = _uiState.value.conversationId ?: event.conversation?.conversationId val completedResponseText = if (isComparison) { @@ -918,12 +934,18 @@ class ChatViewModel @Inject constructor( } is StreamEvent.Error -> { stopStreamingUpdater() - streamingBuffer.clear() - streamingBufferDirty = false + // Track network errors so auto-reconnect can kick in when connectivity returns + lastErrorWasNetwork = event.isNetworkError + if (event.isNetworkError) { + startConnectivityObserver() + } + // Preserve partial content so users can read/copy what was received + val partialContent = streamingBuffer.toString() _uiState.value = _uiState.value.copy( isStreaming = false, - streamingContent = "", + streamingContent = partialContent, error = event.message, + retryInfo = null, activeToolCalls = emptyList(), streamingAttachments = emptyList(), comparisonState = _uiState.value.comparisonState.copy( @@ -933,6 +955,19 @@ class ChatViewModel @Inject constructor( secondaryActiveToolCalls = emptyList(), ), ) + // If the server already created a conversation, fetch whatever it persisted + val conversationId = _uiState.value.conversationId + if (conversationId != null) { + loadConversation(conversationId) + } + } + is StreamEvent.Retrying -> { + _uiState.value = _uiState.value.copy( + retryInfo = RetryInfo( + attempt = event.attempt, + maxAttempts = event.maxAttempts, + ), + ) } is StreamEvent.ToolCallStart -> { val newToolCall = ActiveToolCall( @@ -999,9 +1034,24 @@ class ChatViewModel @Inject constructor( streamingAttachments = _uiState.value.streamingAttachments + attachment, ) } - is StreamEvent.Sync, - is StreamEvent.Step, - -> { /* no-op */ } + is StreamEvent.Sync -> { + // State-snapshot protocol: replace streaming buffer with synced content + if (lastErrorWasNetwork) { + lastErrorWasNetwork = false + cancelConnectivityObserver() + } + if (_uiState.value.retryInfo != null) { + _uiState.value = _uiState.value.copy(retryInfo = null) + } + val textContent = event.aggregatedContent + .mapNotNull { it.text } + .joinToString("") + streamingBuffer.clear() + streamingBuffer.append(textContent) + streamingBufferDirty = true + flushStreamingBuffer() + } + is StreamEvent.Step -> { /* no-op */ } } } @@ -1100,13 +1150,7 @@ class ChatViewModel @Inject constructor( val status = chatRepository.checkStreamStatus(conversationId) if (status.active) { _uiState.value = _uiState.value.copy(isStreaming = true) - streamingBuffer.clear() - streamingBufferDirty = false - startStreamingUpdater() - streamJob?.cancel() - streamJob = viewModelScope.launch { - collectStreamSafely(chatRepository.resumeStream(conversationId)) - } + resumeStream(conversationId) } else { _uiState.value = _uiState.value.copy( isStreaming = false, @@ -1124,6 +1168,21 @@ class ChatViewModel @Inject constructor( } } + /** + * Shared resume logic: clears the buffer, starts the updater, and launches + * stream collection. Caller is responsible for setting any UI state fields + * (e.g. isStreaming, error) before calling this. + */ + private fun resumeStream(conversationId: String) { + streamingBuffer.clear() + streamingBufferDirty = false + startStreamingUpdater() + streamJob?.cancel() + streamJob = viewModelScope.launch { + collectStreamSafely(chatRepository.resumeStream(conversationId)) + } + } + private fun resumeActiveStreamIfNeeded(conversationId: String) { viewModelScope.launch { try { @@ -1133,13 +1192,7 @@ class ChatViewModel @Inject constructor( isStreaming = true, screenState = ChatScreenState.ACTIVE, ) - streamingBuffer.clear() - streamingBufferDirty = false - startStreamingUpdater() - streamJob?.cancel() - streamJob = viewModelScope.launch { - collectStreamSafely(chatRepository.resumeStream(conversationId)) - } + resumeStream(conversationId) } } catch (e: Exception) { Timber.d(e, "No active stream to resume for $conversationId") @@ -1147,6 +1200,69 @@ class ChatViewModel @Inject constructor( } } + /** + * Starts observing connectivity for auto-reconnect after a network error. + * Cancels any existing observer first. The observer self-cancels after recovery fires. + */ + private fun startConnectivityObserver() { + connectivityJob?.cancel() + connectivityJob = viewModelScope.launch { + var wasConnected = true + connectivityObserver.isConnected.collect { connected -> + val recovered = !wasConnected && connected + wasConnected = connected + if (recovered) { + attemptNetworkRecovery() + } + } + } + } + + /** Cancels the connectivity observer and clears the network-error flag. */ + private fun cancelConnectivityObserver() { + connectivityJob?.cancel() + connectivityJob = null + } + + /** + * Called when network connectivity transitions from offline to online. + * If the last stream ended due to a network error, attempts to resume it + * or falls back to reloading the conversation from the server. + */ + private fun attemptNetworkRecovery() { + if (!lastErrorWasNetwork) return + val state = _uiState.value + val conversationId = state.conversationId ?: return + if (state.isStreaming) return + + lastErrorWasNetwork = false + cancelConnectivityObserver() + Timber.d("Network recovered, attempting to resume conversation $conversationId") + + viewModelScope.launch { + try { + val status = chatRepository.checkStreamStatus(conversationId) + if (status.active) { + _uiState.value = _uiState.value.copy( + isStreaming = true, + error = null, + retryInfo = null, + ) + resumeStream(conversationId) + } else { + // Stream expired while offline — reload conversation from server + _uiState.value = _uiState.value.copy( + error = null, + retryInfo = null, + ) + loadConversation(conversationId) + } + } catch (e: Exception) { + Timber.w(e, "Network recovery: could not check stream status") + } + } + } + fun submitFeedback(messageId: String, rating: String?) { val conversationId = _uiState.value.conversationId ?: return viewModelScope.launch { @@ -1255,7 +1371,7 @@ class ChatViewModel @Inject constructor( ) } is Result.Error -> { - Log.d("ChatViewModel", "Failed to load user profile: ${result.message}", result.exception) + Timber.d(result.exception, "Failed to load user profile: ${result.message}") } is Result.Loading -> { /* no-op */ } } diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt index 6357727..94afc01 100644 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt +++ b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/ModelSelectionDelegate.kt @@ -1,6 +1,5 @@ package com.librechat.android.feature.chat.viewmodel.delegate -import android.util.Log import com.librechat.android.core.common.EndpointConstants import com.librechat.android.core.common.ToolConstants import com.librechat.android.core.common.result.Result @@ -217,8 +216,6 @@ class ModelSelectionDelegate( agentId = if (isAgent) model else null, model = if (isAgent) null else model, ) - Timber.d("[Comparison] buildAddedConvo: endpoint=%s, model=%s, agentId=%s, conversationId=%s, parentMessageId=%s", - added.endpoint, added.model, added.agentId, added.conversationId, added.parentMessageId) return added } @@ -286,7 +283,7 @@ class ModelSelectionDelegate( } } is Result.Error -> { - Log.d("ModelSelectionDelegate", "Failed to load MCP servers: ${serversResult.message}", serversResult.exception) + Timber.d(serversResult.exception, "Failed to load MCP servers: ${serversResult.message}") } is Result.Loading -> { /* no-op */ } } diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index e270a6a..e5a33c7 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -9,4 +9,5 @@ android { dependencies { implementation(project(":core:network")) implementation(libs.coil.compose) + implementation(libs.timber) } diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt index 8f8c3c4..5d74098 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt @@ -1,8 +1,5 @@ package com.librechat.android.feature.settings.screen -import android.graphics.Bitmap -import android.graphics.Color -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -24,13 +21,13 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import com.librechat.android.core.ui.components.OtpVerificationDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -39,7 +36,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar -import androidx.compose.material3.CircularProgressIndicator + import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -49,7 +46,6 @@ 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.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.semantics.heading @@ -57,14 +53,15 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import com.librechat.android.feature.settings.R import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.feature.settings.screen.sections.BackupCodesDialog +import com.librechat.android.feature.settings.screen.sections.TwoFactorCodeDialog +import com.librechat.android.feature.settings.screen.sections.TwoFactorSetupDialog import com.librechat.android.feature.settings.viewmodel.SettingsViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -228,7 +225,7 @@ fun AccountSettingsContent( // 2FA enable setup dialog if (uiState.showTwoFactorSetupDialog) { - AccountTwoFactorSetupDialog( + TwoFactorSetupDialog( otpauthUrl = uiState.twoFactorOtpauthUrl, isLoading = uiState.isTwoFactorLoading, onConfirm = viewModel::confirmEnableTwoFactor, @@ -238,7 +235,7 @@ fun AccountSettingsContent( // 2FA disable dialog if (uiState.showDisableTwoFactorDialog) { - AccountTwoFactorCodeDialog( + TwoFactorCodeDialog( title = stringResource(R.string.dialog_title_disable_2fa), description = stringResource(R.string.twofa_disable_instructions), isLoading = uiState.isTwoFactorLoading, @@ -249,7 +246,7 @@ fun AccountSettingsContent( // Backup codes dialog if (uiState.showBackupCodesDialog) { - AccountBackupCodesDialog( + BackupCodesDialog( backupCodes = uiState.backupCodes, onDismiss = viewModel::dismissBackupCodesDialog, ) @@ -307,6 +304,60 @@ fun AccountSettingsContent( }, ) } + + // OTP dialog for account deletion when 2FA is enabled + if (uiState.showDeleteAccountOtpDialog) { + OtpVerificationDialog( + title = stringResource(R.string.otp_title_verify_identity), + description = stringResource(R.string.otp_desc_delete_account), + isLoading = uiState.isLoading, + onVerify = { token, backupCode -> + viewModel.deleteAccount(token = token, backupCode = backupCode) + }, + onDismiss = viewModel::dismissDeleteAccountOtpDialog, + verifyLabel = stringResource(R.string.otp_verify), + cancelLabel = stringResource(R.string.otp_cancel), + backupCodeLabel = stringResource(R.string.otp_backup_code_label), + useBackupToggleLabel = stringResource(R.string.otp_use_backup_code), + useOtpToggleLabel = stringResource(R.string.otp_use_otp_code), + ) + } + + // OTP dialog for enabling 2FA when re-enrolling + if (uiState.showEnableTwoFactorOtpDialog) { + OtpVerificationDialog( + title = stringResource(R.string.otp_title_verify_identity), + description = stringResource(R.string.otp_desc_reenroll_2fa), + isLoading = uiState.isTwoFactorLoading, + onVerify = { token, backupCode -> + viewModel.enableTwoFactorWithOtp(token = token, backupCode = backupCode) + }, + onDismiss = viewModel::dismissEnableTwoFactorOtpDialog, + verifyLabel = stringResource(R.string.otp_verify), + cancelLabel = stringResource(R.string.otp_cancel), + backupCodeLabel = stringResource(R.string.otp_backup_code_label), + useBackupToggleLabel = stringResource(R.string.otp_use_backup_code), + useOtpToggleLabel = stringResource(R.string.otp_use_otp_code), + ) + } + + // OTP dialog for regenerating backup codes + if (uiState.showBackupCodesOtpDialog) { + OtpVerificationDialog( + title = stringResource(R.string.otp_title_verify_identity), + description = stringResource(R.string.otp_desc_regenerate_backup_codes), + isLoading = uiState.isTwoFactorLoading, + onVerify = { token, backupCode -> + viewModel.viewBackupCodesWithOtp(token = token, backupCode = backupCode) + }, + onDismiss = viewModel::dismissBackupCodesOtpDialog, + verifyLabel = stringResource(R.string.otp_verify), + cancelLabel = stringResource(R.string.otp_cancel), + backupCodeLabel = stringResource(R.string.otp_backup_code_label), + useBackupToggleLabel = stringResource(R.string.otp_use_backup_code), + useOtpToggleLabel = stringResource(R.string.otp_use_otp_code), + ) + } } @Composable @@ -468,221 +519,3 @@ private fun AccountSettingsRow( HorizontalDivider() } -@Composable -private fun AccountTwoFactorSetupDialog( - otpauthUrl: String?, - isLoading: Boolean, - onConfirm: (String) -> Unit, - onDismiss: () -> Unit, -) { - var code by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_title_enable_2fa)) }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(R.string.twofa_scan_instructions), - style = MaterialTheme.typography.bodyMedium, - ) - if (otpauthUrl != null) { - var qrBitmap by remember { mutableStateOf(null) } - LaunchedEffect(otpauthUrl) { - qrBitmap = withContext(Dispatchers.Default) { - generateQrBitmapForAccount(otpauthUrl, 256) - } - } - if (qrBitmap != null) { - Image( - bitmap = qrBitmap!!.asImageBitmap(), - contentDescription = stringResource(R.string.cd_qr_code), - modifier = Modifier - .size(200.dp) - .align(Alignment.CenterHorizontally), - ) - } else { - CircularProgressIndicator( - modifier = Modifier - .size(200.dp) - .align(Alignment.CenterHorizontally), - ) - } - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.small, - ) { - Text( - text = otpauthUrl, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(12.dp), - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - } - OutlinedTextField( - value = code, - onValueChange = { code = it.filter { ch -> ch.isDigit() }.take(6) }, - label = { Text(stringResource(R.string.hint_verification_code)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - onClick = { onConfirm(code) }, - enabled = code.length == 6 && !isLoading, - ) { - Text(stringResource(if (isLoading) R.string.action_verifying else R.string.action_verify)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - }, - ) -} - -private fun generateQrBitmapForAccount(content: String, size: Int): Bitmap? { - return try { - val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) - val hash = content.hashCode() - val random = java.util.Random(hash.toLong()) - - for (x in 0 until size) { - for (y in 0 until size) { - bitmap.setPixel(x, y, Color.WHITE) - } - } - - val moduleSize = size / 25 - for (row in 0 until 25) { - for (col in 0 until 25) { - val isFinderPattern = (row < 7 && col < 7) || - (row < 7 && col >= 18) || - (row >= 18 && col < 7) - - val shouldFill = if (isFinderPattern) { - val innerRow = if (row >= 18) row - 18 else row - val innerCol = if (col >= 18) col - 18 else col - innerRow == 0 || innerRow == 6 || innerCol == 0 || innerCol == 6 || - (innerRow in 2..4 && innerCol in 2..4) - } else { - random.nextBoolean() - } - - if (shouldFill) { - val startX = col * moduleSize - val startY = row * moduleSize - for (px in startX until minOf(startX + moduleSize, size)) { - for (py in startY until minOf(startY + moduleSize, size)) { - bitmap.setPixel(px, py, Color.BLACK) - } - } - } - } - } - bitmap - } catch (_: Exception) { - null - } -} - -@Composable -private fun AccountTwoFactorCodeDialog( - title: String, - description: String, - isLoading: Boolean, - onConfirm: (String) -> Unit, - onDismiss: () -> Unit, -) { - var code by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - ) - OutlinedTextField( - value = code, - onValueChange = { code = it.filter { ch -> ch.isDigit() }.take(6) }, - label = { Text(stringResource(R.string.hint_verification_code)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - onClick = { onConfirm(code) }, - enabled = code.length == 6 && !isLoading, - ) { - Text(stringResource(if (isLoading) R.string.action_verifying else R.string.action_confirm)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - }, - ) -} - -@Composable -private fun AccountBackupCodesDialog( - backupCodes: List, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_title_backup_codes)) }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.backup_codes_instructions), - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(modifier = Modifier.height(4.dp)) - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.small, - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - backupCodes.forEach { code -> - Text( - text = code, - style = MaterialTheme.typography.bodyMedium, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - ) - } - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_done)) - } - }, - ) -} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt index cd203cf..6453d12 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt @@ -40,6 +40,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.LoadingIndicator +import com.librechat.android.core.ui.components.OtpVerificationDialog import com.librechat.android.feature.settings.R import com.librechat.android.feature.settings.screen.sections.AboutInfo import com.librechat.android.feature.settings.screen.sections.AccountInfo @@ -570,6 +571,60 @@ fun SettingsScreen( ) } + // OTP dialog for account deletion when 2FA is enabled + if (uiState.showDeleteAccountOtpDialog) { + OtpVerificationDialog( + title = stringResource(R.string.otp_title_verify_identity), + description = stringResource(R.string.otp_desc_delete_account), + isLoading = uiState.isLoading, + onVerify = { token, backupCode -> + viewModel.deleteAccount(token = token, backupCode = backupCode) + }, + onDismiss = viewModel::dismissDeleteAccountOtpDialog, + verifyLabel = stringResource(R.string.otp_verify), + cancelLabel = stringResource(R.string.otp_cancel), + backupCodeLabel = stringResource(R.string.otp_backup_code_label), + useBackupToggleLabel = stringResource(R.string.otp_use_backup_code), + useOtpToggleLabel = stringResource(R.string.otp_use_otp_code), + ) + } + + // OTP dialog for enabling 2FA when re-enrolling + if (uiState.showEnableTwoFactorOtpDialog) { + OtpVerificationDialog( + title = stringResource(R.string.otp_title_verify_identity), + description = stringResource(R.string.otp_desc_reenroll_2fa), + isLoading = uiState.isTwoFactorLoading, + onVerify = { token, backupCode -> + viewModel.enableTwoFactorWithOtp(token = token, backupCode = backupCode) + }, + onDismiss = viewModel::dismissEnableTwoFactorOtpDialog, + verifyLabel = stringResource(R.string.otp_verify), + cancelLabel = stringResource(R.string.otp_cancel), + backupCodeLabel = stringResource(R.string.otp_backup_code_label), + useBackupToggleLabel = stringResource(R.string.otp_use_backup_code), + useOtpToggleLabel = stringResource(R.string.otp_use_otp_code), + ) + } + + // OTP dialog for regenerating backup codes + if (uiState.showBackupCodesOtpDialog) { + OtpVerificationDialog( + title = stringResource(R.string.otp_title_verify_identity), + description = stringResource(R.string.otp_desc_regenerate_backup_codes), + isLoading = uiState.isTwoFactorLoading, + onVerify = { token, backupCode -> + viewModel.viewBackupCodesWithOtp(token = token, backupCode = backupCode) + }, + onDismiss = viewModel::dismissBackupCodesOtpDialog, + verifyLabel = stringResource(R.string.otp_verify), + cancelLabel = stringResource(R.string.otp_cancel), + backupCodeLabel = stringResource(R.string.otp_backup_code_label), + useBackupToggleLabel = stringResource(R.string.otp_use_backup_code), + useOtpToggleLabel = stringResource(R.string.otp_use_otp_code), + ) + } + // Language selector dialog if (uiState.showLanguageDialog) { LanguageSelectorDialog( diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt index 51a3a4e..413bb44 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt @@ -1,15 +1,11 @@ package com.librechat.android.feature.settings.screen.sections -import android.graphics.Bitmap -import android.graphics.Color -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -20,11 +16,8 @@ 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.asImageBitmap import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.librechat.android.feature.settings.R @@ -49,18 +42,6 @@ internal fun TwoFactorSetupDialog( style = MaterialTheme.typography.bodyMedium, ) if (otpauthUrl != null) { - val qrBitmap = remember(otpauthUrl) { - generateQrBitmap(otpauthUrl, 256) - } - if (qrBitmap != null) { - Image( - bitmap = qrBitmap.asImageBitmap(), - contentDescription = stringResource(R.string.cd_qr_code), - modifier = Modifier - .size(200.dp) - .align(Alignment.CenterHorizontally), - ) - } Surface( modifier = Modifier .fillMaxWidth() @@ -68,13 +49,13 @@ internal fun TwoFactorSetupDialog( color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.small, ) { - Text( - text = otpauthUrl, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(12.dp), - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) + androidx.compose.foundation.text.selection.SelectionContainer { + Text( + text = otpauthUrl, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + ) + } } } androidx.compose.material3.OutlinedTextField( @@ -102,55 +83,6 @@ internal fun TwoFactorSetupDialog( ) } -/** - * Generate a simple QR code bitmap from a string. - * Uses a basic implementation that encodes the URL into a visual pattern. - * For production use, integrate a proper QR library like ZXing. - */ -internal fun generateQrBitmap(content: String, size: Int): Bitmap? { - return try { - val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) - val hash = content.hashCode() - val random = java.util.Random(hash.toLong()) - - for (x in 0 until size) { - for (y in 0 until size) { - bitmap.setPixel(x, y, Color.WHITE) - } - } - - val moduleSize = size / 25 - for (row in 0 until 25) { - for (col in 0 until 25) { - val isFinderPattern = (row < 7 && col < 7) || - (row < 7 && col >= 18) || - (row >= 18 && col < 7) - - val shouldFill = if (isFinderPattern) { - val innerRow = if (row >= 18) row - 18 else row - val innerCol = if (col >= 18) col - 18 else col - innerRow == 0 || innerRow == 6 || innerCol == 0 || innerCol == 6 || - (innerRow in 2..4 && innerCol in 2..4) - } else { - random.nextBoolean() - } - - if (shouldFill) { - val startX = col * moduleSize - val startY = row * moduleSize - for (px in startX until minOf(startX + moduleSize, size)) { - for (py in startY until minOf(startY + moduleSize, size)) { - bitmap.setPixel(px, py, Color.BLACK) - } - } - } - } - } - bitmap - } catch (_: Exception) { - null - } -} @Composable internal fun TwoFactorCodeDialog( diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt index 7a13d16..bb7b280 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt @@ -2,7 +2,7 @@ package com.librechat.android.feature.settings.viewmodel import android.content.Context import android.net.Uri -import android.util.Log +import timber.log.Timber import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -44,6 +44,7 @@ import com.librechat.android.feature.settings.viewmodel.delegate.McpServerDelega import com.librechat.android.feature.settings.viewmodel.delegate.MemoryManagementDelegate import com.librechat.android.feature.settings.viewmodel.delegate.SpeechSettingsDelegate import com.librechat.android.feature.settings.viewmodel.delegate.TwoFactorSecurityDelegate +import com.librechat.android.feature.settings.viewmodel.delegate.isHttpStatus import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -96,6 +97,9 @@ data class SettingsUiState( val showBackupCodesDialog: Boolean = false, val backupCodes: List = emptyList(), val showDisableTwoFactorDialog: Boolean = false, + val showEnableTwoFactorOtpDialog: Boolean = false, + val showBackupCodesOtpDialog: Boolean = false, + val showDeleteAccountOtpDialog: Boolean = false, // MCP val mcpServers: List = emptyList(), val mcpConnectionStatus: Map = emptyMap(), @@ -509,7 +513,7 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy(tokenCredits = result.data.tokenCredits, isBalanceLoading = false) } } is Result.Error -> { - Log.d("SettingsViewModel", "Failed to load balance: ${result.message}", result.exception) + Timber.d(result.exception, "Failed to load balance: ${result.message}") _uiState.update { it.copy(isBalanceLoading = false) } } is Result.Loading -> { /* no-op */ } @@ -596,17 +600,23 @@ class SettingsViewModel @Inject constructor( } } - fun deleteAccount() { + fun deleteAccount(token: String? = null, backupCode: String? = null) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - when (val result = userRepository.deleteUser()) { + when (val result = userRepository.deleteUser(token = token, backupCode = backupCode)) { is Result.Success -> { authRepository.logout() - _uiState.update { it.copy(isLoading = false, isAccountDeleted = true) } + _uiState.update { it.copy(isLoading = false, isAccountDeleted = true, showDeleteAccountOtpDialog = false) } } is Result.Error -> { + val needsOtp = token == null && backupCode == null && + result.isHttpStatus(403) _uiState.update { - it.copy(isLoading = false, error = result.message ?: "Failed to delete account") + it.copy( + isLoading = false, + showDeleteAccountOtpDialog = if (needsOtp) true else it.showDeleteAccountOtpDialog, + error = if (needsOtp) null else (result.message ?: "Failed to delete account"), + ) } } is Result.Loading -> { /* no-op */ } @@ -614,6 +624,10 @@ class SettingsViewModel @Inject constructor( } } + fun dismissDeleteAccountOtpDialog() { + _uiState.update { it.copy(showDeleteAccountOtpDialog = false) } + } + fun retry() { loadUser() } @@ -644,12 +658,16 @@ class SettingsViewModel @Inject constructor( // Two-factor security fun toggleTwoFactor() = twoFactorDelegate.toggleTwoFactor() + fun enableTwoFactorWithOtp(token: String?, backupCode: String?) = twoFactorDelegate.enableTwoFactor(token = token, backupCode = backupCode) + fun dismissEnableTwoFactorOtpDialog() = twoFactorDelegate.dismissEnableTwoFactorOtpDialog() fun confirmEnableTwoFactor(code: String) = twoFactorDelegate.confirmEnableTwoFactor(code) fun confirmDisableTwoFactor(code: String) = twoFactorDelegate.confirmDisableTwoFactor(code) fun dismissTwoFactorSetupDialog() = twoFactorDelegate.dismissTwoFactorSetupDialog() fun dismissDisableTwoFactorDialog() = twoFactorDelegate.dismissDisableTwoFactorDialog() fun dismissBackupCodesDialog() = twoFactorDelegate.dismissBackupCodesDialog() fun viewBackupCodes() = twoFactorDelegate.viewBackupCodes() + fun viewBackupCodesWithOtp(token: String?, backupCode: String?) = twoFactorDelegate.viewBackupCodes(token = token, backupCode = backupCode) + fun dismissBackupCodesOtpDialog() = twoFactorDelegate.dismissBackupCodesOtpDialog() // Data management fun clearAllChats() = dataDelegate.clearAllChats() diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/TwoFactorSecurityDelegate.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/TwoFactorSecurityDelegate.kt index 75c7142..0c9eb0e 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/TwoFactorSecurityDelegate.kt +++ b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/TwoFactorSecurityDelegate.kt @@ -1,5 +1,6 @@ package com.librechat.android.feature.settings.viewmodel.delegate +import com.librechat.android.core.common.result.ApiException import com.librechat.android.core.common.result.Result import com.librechat.android.core.data.repository.AuthRepository import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle @@ -17,33 +18,48 @@ class TwoFactorSecurityDelegate( if (stateHandle.state.isTwoFactorEnabled) { stateHandle.update { copy(showDisableTwoFactorDialog = true) } } else { - stateHandle.scope.launch { - stateHandle.update { copy(isTwoFactorLoading = true) } - when (val result = authRepository.enableTwoFactor()) { - is Result.Success -> { - stateHandle.update { - copy( - isTwoFactorLoading = false, - showTwoFactorSetupDialog = true, - twoFactorOtpauthUrl = result.data.otpauthUrl, - backupCodes = result.data.backupCodes, - ) - } + enableTwoFactor() + } + } + + /** + * Enable 2FA. When re-enrolling (2FA already enabled on server), OTP is required. + */ + fun enableTwoFactor(token: String? = null, backupCode: String? = null) { + stateHandle.scope.launch { + stateHandle.update { copy(isTwoFactorLoading = true) } + when (val result = authRepository.enableTwoFactor(token = token, backupCode = backupCode)) { + is Result.Success -> { + stateHandle.update { + copy( + isTwoFactorLoading = false, + showTwoFactorSetupDialog = true, + showEnableTwoFactorOtpDialog = false, + twoFactorOtpauthUrl = result.data.otpauthUrl, + backupCodes = result.data.backupCodes, + ) } - is Result.Error -> { - stateHandle.update { - copy( - isTwoFactorLoading = false, - error = result.message ?: "Failed to enable two-factor authentication", - ) - } - } - is Result.Loading -> { /* no-op */ } } + is Result.Error -> { + // Server returns 400 when 2FA is already enabled and OTP is required to re-enroll + val needsOtp = token == null && backupCode == null && result.isHttpStatus(400) + stateHandle.update { + copy( + isTwoFactorLoading = false, + showEnableTwoFactorOtpDialog = if (needsOtp) true else showEnableTwoFactorOtpDialog, + error = if (needsOtp) null else (result.message ?: "Failed to enable two-factor authentication"), + ) + } + } + is Result.Loading -> { /* no-op */ } } } } + fun dismissEnableTwoFactorOtpDialog() { + stateHandle.update { copy(showEnableTwoFactorOtpDialog = false) } + } + fun confirmEnableTwoFactor(code: String) { stateHandle.scope.launch { stateHandle.update { copy(isTwoFactorLoading = true) } @@ -111,24 +127,31 @@ class TwoFactorSecurityDelegate( stateHandle.update { copy(showBackupCodesDialog = false, backupCodes = emptyList()) } } - fun viewBackupCodes() { + /** + * View/regenerate backup codes. Requires OTP when 2FA is enabled. + */ + fun viewBackupCodes(token: String? = null, backupCode: String? = null) { stateHandle.scope.launch { stateHandle.update { copy(isTwoFactorLoading = true) } - when (val result = authRepository.regenerateBackupCodes()) { + when (val result = authRepository.regenerateBackupCodes(token = token, backupCode = backupCode)) { is Result.Success -> { stateHandle.update { copy( isTwoFactorLoading = false, showBackupCodesDialog = true, + showBackupCodesOtpDialog = false, backupCodes = result.data.backupCodes, ) } } is Result.Error -> { + // Server returns 400 when 2FA is enabled and OTP is required to regenerate codes + val needsOtp = token == null && backupCode == null && result.isHttpStatus(400) stateHandle.update { copy( isTwoFactorLoading = false, - error = result.message ?: "Failed to retrieve backup codes", + showBackupCodesOtpDialog = if (needsOtp) true else showBackupCodesOtpDialog, + error = if (needsOtp) null else (result.message ?: "Failed to retrieve backup codes"), ) } } @@ -136,4 +159,16 @@ class TwoFactorSecurityDelegate( } } } + + fun dismissBackupCodesOtpDialog() { + stateHandle.update { copy(showBackupCodesOtpDialog = false) } + } + } + +/** + * Checks whether a [Result.Error] was caused by a specific HTTP status code. + * Shared across the settings module (e.g. 2FA delegate, account deletion). + */ +internal fun Result.Error.isHttpStatus(statusCode: Int): Boolean = + (exception as? ApiException)?.statusCode == statusCode diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 7b6354b..bbbf73d 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -168,10 +168,21 @@ Enable Two-Factor Authentication Disable Two-Factor Authentication - Scan the QR code below with your authenticator app (e.g. Google Authenticator, Authy), then enter the verification code. + Copy the URI below and paste it into your authenticator app (e.g. Google Authenticator, Authy), then enter the verification code. Enter your authenticator code to disable 2FA. Verification code + + Verify Identity + Enter your 2FA code to delete your account. + Enter your current 2FA code to re-enroll. + Enter your 2FA code to regenerate backup codes. + Verify + Cancel + Backup Code + Use a backup code instead + Use OTP code instead + Backup Codes Save these backup codes in a safe place. Each code can only be used once.