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.
This commit is contained in:
Garfie 2026-03-26 17:33:41 -05:00
parent 34ddeb9789
commit d30988f8b4
39 changed files with 761 additions and 438 deletions

View file

@ -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).

View file

@ -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 {

View file

@ -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)

View file

@ -9,14 +9,13 @@ interface AuthRepository {
suspend fun login(email: String, password: String): Result<LoginOutcome>
suspend fun loginWithOAuthToken(refreshToken: String): Result<User>
suspend fun verifyTwoFactor(tempToken: String, code: String): Result<User>
suspend fun verifyTempToken(tempToken: String, code: String): Result<User>
suspend fun register(name: String, email: String, username: String, password: String): Result<Unit>
suspend fun logout(): Result<Unit>
suspend fun isLoggedIn(): Boolean
suspend fun enableTwoFactor(): Result<TwoFactorSetupResponse>
suspend fun enableTwoFactor(token: String? = null, backupCode: String? = null): Result<TwoFactorSetupResponse>
suspend fun confirmTwoFactor(code: String): Result<TwoFactorSetupResponse>
suspend fun disableTwoFactor(code: String): Result<Unit>
suspend fun regenerateBackupCodes(): Result<TwoFactorSetupResponse>
suspend fun regenerateBackupCodes(token: String? = null, backupCode: String? = null): Result<TwoFactorSetupResponse>
suspend fun requestPasswordReset(email: String): Result<Unit>
suspend fun resetPassword(userId: String, token: String, password: String, confirmPassword: String): Result<Unit>
}

View file

@ -75,17 +75,6 @@ class AuthRepositoryImpl @Inject constructor(
}
}
override suspend fun verifyTempToken(tempToken: String, code: String): Result<User> {
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<TwoFactorSetupResponse> {
override suspend fun enableTwoFactor(token: String?, backupCode: String?): Result<TwoFactorSetupResponse> {
return safeApiCall {
authApi.enableTwoFactor()
authApi.enableTwoFactor(token = token, backupCode = backupCode)
}
}
@ -147,9 +136,9 @@ class AuthRepositoryImpl @Inject constructor(
}
}
override suspend fun regenerateBackupCodes(): Result<TwoFactorSetupResponse> {
override suspend fun regenerateBackupCodes(token: String?, backupCode: String?): Result<TwoFactorSetupResponse> {
return safeApiCall {
authApi.regenerateBackupCodes()
authApi.regenerateBackupCodes(token = token, backupCode = backupCode)
}
}

View file

@ -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,

View file

@ -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<Unit> = safeApiCall {
@ -91,6 +89,6 @@ class ChatRepositoryImpl @Inject constructor(
override fun resumeStream(conversationId: String): Flow<StreamEvent> = 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))
}
}

View file

@ -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

View file

@ -7,7 +7,7 @@ import com.librechat.android.core.model.response.TermsResponse
interface UserRepository {
suspend fun getUser(): Result<User>
suspend fun deleteUser(): Result<Unit>
suspend fun deleteUser(token: String? = null, backupCode: String? = null): Result<Unit>
suspend fun uploadAvatar(imageBytes: ByteArray): Result<User>
suspend fun verifyEmail(token: String): Result<Unit>
suspend fun resendVerification(email: String): Result<Unit>

View file

@ -20,8 +20,8 @@ class UserRepositoryImpl @Inject constructor(
userApi.getUser()
}
override suspend fun deleteUser(): Result<Unit> = safeApiCall {
userApi.deleteUser()
override suspend fun deleteUser(token: String?, backupCode: String?): Result<Unit> = safeApiCall {
userApi.deleteUser(token = token, backupCode = backupCode)
}
override suspend fun uploadAvatar(imageBytes: ByteArray): Result<User> = safeApiCall {

View file

@ -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<String>? = null,
) {
val avatarUrl: String?

View file

@ -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<String>? = null,
val iconURL: String? = null,
val greeting: String? = null,

View file

@ -24,6 +24,8 @@ data class Preset(
val iconURL: String? = null,
val greeting: String? = null,
val stop: List<String>? = 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,

View file

@ -91,6 +91,7 @@ data class InterfaceConfig(
val fileSearch: Boolean = true,
val fileCitations: Boolean = true,
val customWelcome: String? = null,
val remoteAgents: JsonElement? = null,
)
@Serializable

View file

@ -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(

View file

@ -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<String>? = null,
val tools: List<String>? = null,
val iconURL: String? = null,

View file

@ -24,4 +24,5 @@ data class CreateAgentRequest(
val isCollaborative: Boolean? = null,
@SerialName("projectIds") val projectIds: List<String>? = null,
@SerialName("support_contact") val supportContact: SupportContact? = null,
@SerialName("tool_options") val toolOptions: JsonObject? = null,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -24,4 +24,5 @@ data class UpdateAgentRequest(
val isCollaborative: Boolean? = null,
@SerialName("projectIds") val projectIds: List<String>? = null,
@SerialName("support_contact") val supportContact: SupportContact? = null,
@SerialName("tool_options") val toolOptions: JsonObject? = null,
)

View file

@ -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) {

View file

@ -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))
}
}
}

View file

@ -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

View file

@ -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<Boolean>? = null,
): Flow<StreamEvent> = 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

View file

@ -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)

View file

@ -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",

View file

@ -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)

View file

@ -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_"

View file

@ -180,7 +180,7 @@ private fun DigitBoxes(
}
},
modifier = Modifier
.width(48.dp)
.weight(1f)
.focusRequester(focusRequesters[index]),
enabled = enabled,
singleLine = true,

View file

@ -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<McpServerDisplayData> = emptyList(),
val selectedMcpServerNames: Set<String> = 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

View file

@ -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 */ }
}

View file

@ -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 */ }
}

View file

@ -9,4 +9,5 @@ android {
dependencies {
implementation(project(":core:network"))
implementation(libs.coil.compose)
implementation(libs.timber)
}

View file

@ -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<Bitmap?>(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<String>,
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))
}
},
)
}

View file

@ -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(

View file

@ -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(

View file

@ -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<String> = emptyList(),
val showDisableTwoFactorDialog: Boolean = false,
val showEnableTwoFactorOtpDialog: Boolean = false,
val showBackupCodesOtpDialog: Boolean = false,
val showDeleteAccountOtpDialog: Boolean = false,
// MCP
val mcpServers: List<McpServer> = emptyList(),
val mcpConnectionStatus: Map<String, McpServerStatus> = 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()

View file

@ -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

View file

@ -168,10 +168,21 @@
<!-- 2FA setup dialog -->
<string name="dialog_title_enable_2fa">Enable Two-Factor Authentication</string>
<string name="dialog_title_disable_2fa">Disable Two-Factor Authentication</string>
<string name="twofa_scan_instructions">Scan the QR code below with your authenticator app (e.g. Google Authenticator, Authy), then enter the verification code.</string>
<string name="twofa_scan_instructions">Copy the URI below and paste it into your authenticator app (e.g. Google Authenticator, Authy), then enter the verification code.</string>
<string name="twofa_disable_instructions">Enter your authenticator code to disable 2FA.</string>
<string name="hint_verification_code">Verification code</string>
<!-- OTP verification dialogs -->
<string name="otp_title_verify_identity">Verify Identity</string>
<string name="otp_desc_delete_account">Enter your 2FA code to delete your account.</string>
<string name="otp_desc_reenroll_2fa">Enter your current 2FA code to re-enroll.</string>
<string name="otp_desc_regenerate_backup_codes">Enter your 2FA code to regenerate backup codes.</string>
<string name="otp_verify">Verify</string>
<string name="otp_cancel">Cancel</string>
<string name="otp_backup_code_label">Backup Code</string>
<string name="otp_use_backup_code">Use a backup code instead</string>
<string name="otp_use_otp_code">Use OTP code instead</string>
<!-- Backup codes dialog -->
<string name="dialog_title_backup_codes">Backup Codes</string>
<string name="backup_codes_instructions">Save these backup codes in a safe place. Each code can only be used once.</string>