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 * Matches the VERSION constant from the official LibreChat repo's
* `packages/data-provider/src/config.ts` and `package.json`. * `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). * Represents a parsed semantic version (major.minor.patch).

View file

@ -9,6 +9,8 @@ object ToolConstants {
const val CODE_INTERPRETER = "code_interpreter" const val CODE_INTERPRETER = "code_interpreter"
const val FILE_SEARCH = "file_search" const val FILE_SEARCH = "file_search"
const val EXECUTE_CODE = "execute_code" const val EXECUTE_CODE = "execute_code"
const val PROGRAMMATIC_TOOLS = "programmatic_tools"
const val DEFERRED_TOOLS = "deferred_tools"
} }
object ChatLayoutConstants { 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 login(email: String, password: String): Result<LoginOutcome>
suspend fun loginWithOAuthToken(refreshToken: String): Result<User> suspend fun loginWithOAuthToken(refreshToken: String): Result<User>
suspend fun verifyTwoFactor(tempToken: String, code: 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 register(name: String, email: String, username: String, password: String): Result<Unit>
suspend fun logout(): Result<Unit> suspend fun logout(): Result<Unit>
suspend fun isLoggedIn(): Boolean 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 confirmTwoFactor(code: String): Result<TwoFactorSetupResponse>
suspend fun disableTwoFactor(code: String): Result<Unit> 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 requestPasswordReset(email: String): Result<Unit>
suspend fun resetPassword(userId: String, token: String, password: String, confirmPassword: 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( override suspend fun register(
name: String, name: String,
email: String, email: String,
@ -129,9 +118,9 @@ class AuthRepositoryImpl @Inject constructor(
return tokenManager.getAccessToken() != null return tokenManager.getAccessToken() != null
} }
override suspend fun enableTwoFactor(): Result<TwoFactorSetupResponse> { override suspend fun enableTwoFactor(token: String?, backupCode: String?): Result<TwoFactorSetupResponse> {
return safeApiCall { 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 { 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.ChatRequest
import com.librechat.android.core.model.request.EphemeralAgent import com.librechat.android.core.model.request.EphemeralAgent
import com.librechat.android.core.model.request.NO_PARENT import com.librechat.android.core.model.request.NO_PARENT
import timber.log.Timber
object ChatPayloadBuilder { object ChatPayloadBuilder {
@ -35,10 +34,6 @@ object ChatPayloadBuilder {
val resolvedParentMessageId = parentMessageId ?: NO_PARENT 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( return ChatRequest(
text = text, text = text,
conversationId = conversationId, conversationId = conversationId,

View file

@ -1,5 +1,6 @@
package com.librechat.android.core.data.repository 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.Result
import com.librechat.android.core.common.result.safeApiCall import com.librechat.android.core.common.result.safeApiCall
import com.librechat.android.core.model.FileReference 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.Flow
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -25,6 +25,7 @@ class ChatRepositoryImpl @Inject constructor(
private val chatApi: ChatApi, private val chatApi: ChatApi,
private val sseClient: SseClient, private val sseClient: SseClient,
@StreamingClient private val sseHttpClient: HttpClient, @StreamingClient private val sseHttpClient: HttpClient,
private val connectivityObserver: ConnectivityObserver,
) : ChatRepository { ) : ChatRepository {
override fun startChat( override fun startChat(
@ -62,9 +63,6 @@ class ChatRepositoryImpl @Inject constructor(
addedConvo = addedConvo, addedConvo = addedConvo,
ephemeralAgent = ephemeralAgent, 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 startResponse = chatApi.startChat(endpoint, request)
val streamId = startResponse.conversationId val streamId = startResponse.conversationId
@ -78,7 +76,7 @@ class ChatRepositoryImpl @Inject constructor(
// Phase 2: GET the SSE stream using the streamId // Phase 2: GET the SSE stream using the streamId
val streamUrl = "api/agents/chat/stream/$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 { 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 { override fun resumeStream(conversationId: String): Flow<StreamEvent> = flow {
val streamUrl = "api/agents/chat/stream/$conversationId" 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.EndpointConfig
import com.librechat.android.core.model.StartupConfig import com.librechat.android.core.model.StartupConfig
import com.librechat.android.core.network.api.ConfigApi 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 io.ktor.client.plugins.HttpRequestTimeoutException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow

View file

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

View file

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

View file

@ -40,6 +40,7 @@ data class Agent(
val updatedAt: String? = null, val updatedAt: String? = null,
val createdAt: String? = null, val createdAt: String? = null,
@SerialName("support_contact") val supportContact: JsonElement? = null, @SerialName("support_contact") val supportContact: JsonElement? = null,
@SerialName("tool_options") val toolOptions: JsonObject? = null,
val mcpServerNames: List<String>? = null, val mcpServerNames: List<String>? = null,
) { ) {
val avatarUrl: String? val avatarUrl: String?

View file

@ -28,6 +28,8 @@ data class Conversation(
val maxTokens: Int? = null, val maxTokens: Int? = null,
val system: String? = null, val system: String? = null,
@SerialName("reasoning_effort") val reasoningEffort: 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 stop: List<String>? = null,
val iconURL: String? = null, val iconURL: String? = null,
val greeting: String? = null, val greeting: String? = null,

View file

@ -24,6 +24,8 @@ data class Preset(
val iconURL: String? = null, val iconURL: String? = null,
val greeting: String? = null, val greeting: String? = null,
val stop: List<String>? = null, val stop: List<String>? = null,
val effort: String? = null,
@SerialName("thinkingLevel") val thinkingLevel: String? = null,
@SerialName("web_search") val webSearch: Boolean? = null, @SerialName("web_search") val webSearch: Boolean? = null,
val createdAt: String? = null, val createdAt: String? = null,
val updatedAt: String? = null, val updatedAt: String? = null,

View file

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

View file

@ -57,6 +57,12 @@ sealed interface StreamEvent {
data class Error( data class Error(
val message: String, val message: String,
val code: String? = null, val code: String? = null,
val isNetworkError: Boolean = false,
) : StreamEvent
data class Retrying(
val attempt: Int,
val maxAttempts: Int,
) : StreamEvent ) : StreamEvent
data class Step( data class Step(

View file

@ -29,6 +29,8 @@ data class ChatRequest(
val maxContextTokens: Int? = null, val maxContextTokens: Int? = null,
val system: String? = null, val system: String? = null,
@SerialName("reasoning_effort") val reasoningEffort: 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 stop: List<String>? = null,
val tools: List<String>? = null, val tools: List<String>? = null,
val iconURL: String? = null, val iconURL: String? = null,

View file

@ -24,4 +24,5 @@ data class CreateAgentRequest(
val isCollaborative: Boolean? = null, val isCollaborative: Boolean? = null,
@SerialName("projectIds") val projectIds: List<String>? = null, @SerialName("projectIds") val projectIds: List<String>? = null,
@SerialName("support_contact") val supportContact: SupportContact? = 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("web_search") val webSearch: Boolean? = null,
@SerialName("file_search") val fileSearch: Boolean? = null, @SerialName("file_search") val fileSearch: Boolean? = null,
@SerialName("execute_code") val executeCode: 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, val isCollaborative: Boolean? = null,
@SerialName("projectIds") val projectIds: List<String>? = null, @SerialName("projectIds") val projectIds: List<String>? = null,
@SerialName("support_contact") val supportContact: SupportContact? = 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.RegisterRequest
import com.librechat.android.core.model.request.ResetPasswordRequest import com.librechat.android.core.model.request.ResetPasswordRequest
import com.librechat.android.core.model.request.TwoFactorDisableRequest 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.TwoFactorVerifyRequest
import com.librechat.android.core.model.request.TwoFactorVerifyTempRequest import com.librechat.android.core.model.request.TwoFactorVerifyTempRequest
import com.librechat.android.core.model.response.LoginResponse import com.librechat.android.core.model.response.LoginResponse
@ -120,9 +121,12 @@ class AuthApi @Inject constructor(
return LoginResult(body, refreshToken) return LoginResult(body, refreshToken)
} }
suspend fun enableTwoFactor(): TwoFactorSetupResponse = suspend fun enableTwoFactor(token: String? = null, backupCode: String? = null): TwoFactorSetupResponse =
client.get { client.post {
url { path("api/auth/2fa/enable") } url { path("api/auth/2fa/enable") }
if (token != null || backupCode != null) {
setBody(OtpVerificationRequest(token = token, backupCode = backupCode))
}
}.body() }.body()
suspend fun confirmTwoFactor(code: String): TwoFactorSetupResponse = suspend fun confirmTwoFactor(code: String): TwoFactorSetupResponse =
@ -151,9 +155,12 @@ class AuthApi @Inject constructor(
return LoginResult(body, refreshToken) return LoginResult(body, refreshToken)
} }
suspend fun regenerateBackupCodes(): TwoFactorSetupResponse = suspend fun regenerateBackupCodes(token: String? = null, backupCode: String? = null): TwoFactorSetupResponse =
client.post { client.post {
url { path("api/auth/2fa/backup/regenerate") } url { path("api/auth/2fa/backup/regenerate") }
if (token != null || backupCode != null) {
setBody(OtpVerificationRequest(token = token, backupCode = backupCode))
}
}.body() }.body()
suspend fun disableTwoFactor(code: String) { 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.User
import com.librechat.android.core.model.UserFavorite 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.ResendVerificationRequest
import com.librechat.android.core.model.request.VerifyEmailRequest import com.librechat.android.core.model.request.VerifyEmailRequest
import com.librechat.android.core.model.response.TermsResponse import com.librechat.android.core.model.response.TermsResponse
@ -49,9 +50,12 @@ class UserApi @Inject constructor(
setBody(update) setBody(update)
}.body() }.body()
suspend fun deleteUser() { suspend fun deleteUser(token: String? = null, backupCode: String? = null) {
client.delete { client.delete {
url { path("api/user/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 package com.librechat.android.core.network.client
/** typealias ApiException = com.librechat.android.core.common.result.ApiException
* 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

@ -12,6 +12,7 @@ import io.ktor.http.isSuccess
import io.ktor.http.path import io.ktor.http.path
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import timber.log.Timber import timber.log.Timber
@ -24,10 +25,16 @@ class SseClient @Inject constructor(
private val mapper = SseEventMapper(json) private val mapper = SseEventMapper(json)
private val lineParser = SseLineParser() 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( fun connect(
client: HttpClient, client: HttpClient,
streamPath: String, streamPath: String,
resume: Boolean = false, resume: Boolean = false,
connectivityFlow: Flow<Boolean>? = null,
): Flow<StreamEvent> = flow { ): Flow<StreamEvent> = flow {
mapper.resetState() mapper.resetState()
var attempt = 0 var attempt = 0
@ -88,7 +95,7 @@ class SseClient @Inject constructor(
Timber.w(e, "SSE I/O error (attempt $attempt)") Timber.w(e, "SSE I/O error (attempt $attempt)")
attempt++ attempt++
if (attempt > maxRetries) { 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 done = true
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -101,6 +108,25 @@ class SseClient @Inject constructor(
} }
if (!done) { 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) val delayMs = min(initialDelayMs * (1L shl (attempt - 1)), maxDelayMs)
delay(delayMs) delay(delayMs)
shouldResume = true shouldResume = true

View file

@ -32,6 +32,9 @@ import timber.log.Timber
* {"created":{"message":{...}}} * {"created":{"message":{...}}}
* {"sync":true,"resumeState":{...}} * {"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) { class SseEventMapper(private val json: Json) {
@ -195,8 +198,35 @@ class SseEventMapper(private val json: Json) {
} }
private fun mapSyncEvent(root: JsonObject): StreamEvent? { private fun mapSyncEvent(root: JsonObject): StreamEvent? {
// TODO: Parse resumeState.aggregatedContent for full sync support val resumeState = root["resumeState"]?.jsonObject ?: return null
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 --- // --- LangGraph events ---
@ -222,8 +252,6 @@ class SseEventMapper(private val json: Json) {
val agentId = eventAgentId ?: activeAgentId val agentId = eventAgentId ?: activeAgentId
val groupId = eventGroupId ?: activeGroupId val groupId = eventGroupId ?: activeGroupId
Timber.d("[Comparison] mapLangGraphEvent: type=%s, agentId=%s, groupId=%s", eventType, agentId, groupId)
return when (eventType) { return when (eventType) {
"on_message_delta" -> mapMessageDelta(data, agentId, groupId) "on_message_delta" -> mapMessageDelta(data, agentId, groupId)
"on_reasoning_delta" -> mapReasoningDelta(data, agentId, groupId) "on_reasoning_delta" -> mapReasoningDelta(data, agentId, groupId)

View file

@ -332,6 +332,14 @@ object EndpointParameterRegistry {
default = "", default = "",
description = "Max tokens for thinking (-1 = dynamic, up to 32000).", 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( ParameterDefinition(
key = "web_search", key = "web_search",
label = "Grounding with Google 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", "web_search",
"end_after_tools", "end_after_tools",
"hide_sequential_outputs", "hide_sequential_outputs",
"programmatic_tools",
"deferred_tools",
) )
// MCP server marker prefix: tools starting with "sys__server__sys" or containing "_mcp_" // MCP server marker prefix: tools starting with "sys__server__sys" or containing "_mcp_"

View file

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

View file

@ -44,6 +44,12 @@ data class SearchMatch(
val occurrenceInMessage: Int, val occurrenceInMessage: Int,
) )
@androidx.compose.runtime.Immutable
data class RetryInfo(
val attempt: Int,
val maxAttempts: Int,
)
@androidx.compose.runtime.Immutable @androidx.compose.runtime.Immutable
data class ActiveToolCall( data class ActiveToolCall(
val id: String, val id: String,
@ -99,6 +105,8 @@ data class ChatUiState(
// MCP server state // MCP server state
val mcpServers: List<McpServerDisplayData> = emptyList(), val mcpServers: List<McpServerDisplayData> = emptyList(),
val selectedMcpServerNames: Set<String> = emptySet(), val selectedMcpServerNames: Set<String> = emptySet(),
// SSE reconnection retry state (null when not retrying)
val retryInfo: RetryInfo? = null,
// Pull-to-refresh state // Pull-to-refresh state
val isRefreshingMessages: Boolean = false, val isRefreshingMessages: Boolean = false,
// Merged from separate StateFlows // Merged from separate StateFlows

View file

@ -5,11 +5,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log
import timber.log.Timber import timber.log.Timber
import com.librechat.android.feature.chat.util.NEW_CHAT_DRAFT_KEY import com.librechat.android.feature.chat.util.NEW_CHAT_DRAFT_KEY
import com.librechat.android.core.common.EndpointConstants import com.librechat.android.core.common.EndpointConstants
import com.librechat.android.core.common.ToolConstants 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.Result
import com.librechat.android.core.common.result.getOrNull import com.librechat.android.core.common.result.getOrNull
import com.librechat.android.core.data.datastore.LatexRenderer 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.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -81,6 +84,7 @@ class ChatViewModel @Inject constructor(
private val speechRepository: SpeechRepository, private val speechRepository: SpeechRepository,
mcpRepository: McpRepository, mcpRepository: McpRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val connectivityObserver: ConnectivityObserver,
serverDataStore: ServerDataStore, serverDataStore: ServerDataStore,
private val settingsDataStore: SettingsDataStore, private val settingsDataStore: SettingsDataStore,
) : ViewModel() { ) : ViewModel() {
@ -146,6 +150,10 @@ class ChatViewModel @Inject constructor(
private val streamingBuffer = StringBuilder() private val streamingBuffer = StringBuilder()
private var streamingBufferDirty = false private var streamingBufferDirty = false
private var wasStreaming = 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 { companion object {
/** Minimum interval between streaming UI state updates to avoid recomposition spam. */ /** Minimum interval between streaming UI state updates to avoid recomposition spam. */
@ -274,6 +282,7 @@ class ChatViewModel @Inject constructor(
modelDelegate.loadAgents() modelDelegate.loadAgents()
loadSharedLinksEnabled() loadSharedLinksEnabled()
voiceDelegate.loadSpeechConfig() voiceDelegate.loadSpeechConfig()
} }
// ── Core chat flow ────────────────────────────────────────────── // ── Core chat flow ──────────────────────────────────────────────
@ -508,11 +517,6 @@ class ChatViewModel @Inject constructor(
val effectiveEndpoint = _uiState.value.selectedEndpoint val effectiveEndpoint = _uiState.value.selectedEndpoint
val effectiveAgentId = if (isAgent) _uiState.value.selectedModel else null val effectiveAgentId = if (isAgent) _uiState.value.selectedModel else null
val effectiveAddedConvo = addedConvo // null when no comparison 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 comparison enabled, prepare comparison streaming state
if (addedConvo != null) { if (addedConvo != null) {
modelDelegate.primaryComparisonBuffer.clear() modelDelegate.primaryComparisonBuffer.clear()
@ -746,15 +750,26 @@ class ChatViewModel @Inject constructor(
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Stream collection failed") Timber.e(e, "Stream collection failed")
stopStreamingUpdater() stopStreamingUpdater()
streamingBuffer.clear() // Preserve partial content so users can read/copy what was received
streamingBufferDirty = false val partialContent = streamingBuffer.toString()
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isStreaming = false, isStreaming = false,
streamingContent = "", streamingContent = partialContent,
activeToolCalls = emptyList(), activeToolCalls = emptyList(),
streamingAttachments = emptyList(), streamingAttachments = emptyList(),
error = e.message ?: "Chat request failed", 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( if (lastErrorWasNetwork) {
conversationId = event.conversationId, 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 && if (isNewConversation && event.conversationId != null &&
_uiState.value.pendingNavigationConversationId == null && _uiState.value.pendingNavigationConversationId == null &&
!_uiState.value.comparisonState.isEnabled !_uiState.value.comparisonState.isEnabled
@ -781,12 +803,16 @@ class ChatViewModel @Inject constructor(
pendingNavigationConversationId = event.conversationId, 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 -> { is StreamEvent.ContentDelta -> {
val isComparison = _uiState.value.comparisonState.isEnabled 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)) { if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) {
modelDelegate.secondaryComparisonBuffer.append(event.chunk) modelDelegate.secondaryComparisonBuffer.append(event.chunk)
_uiState.update { _uiState.update {
@ -815,9 +841,6 @@ class ChatViewModel @Inject constructor(
} }
is StreamEvent.ThinkingDelta -> { is StreamEvent.ThinkingDelta -> {
val isComparison = _uiState.value.comparisonState.isEnabled 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)) { if (isComparison && modelDelegate.isSecondaryEvent(event.agentId)) {
modelDelegate.secondaryComparisonBuffer.append(event.chunk) modelDelegate.secondaryComparisonBuffer.append(event.chunk)
_uiState.update { _uiState.update {
@ -847,13 +870,6 @@ class ChatViewModel @Inject constructor(
is StreamEvent.Final -> { is StreamEvent.Final -> {
stopStreamingUpdater() stopStreamingUpdater()
val isComparison = _uiState.value.comparisonState.isEnabled 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 val conversationId = _uiState.value.conversationId
?: event.conversation?.conversationId ?: event.conversation?.conversationId
val completedResponseText = if (isComparison) { val completedResponseText = if (isComparison) {
@ -918,12 +934,18 @@ class ChatViewModel @Inject constructor(
} }
is StreamEvent.Error -> { is StreamEvent.Error -> {
stopStreamingUpdater() stopStreamingUpdater()
streamingBuffer.clear() // Track network errors so auto-reconnect can kick in when connectivity returns
streamingBufferDirty = false 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( _uiState.value = _uiState.value.copy(
isStreaming = false, isStreaming = false,
streamingContent = "", streamingContent = partialContent,
error = event.message, error = event.message,
retryInfo = null,
activeToolCalls = emptyList(), activeToolCalls = emptyList(),
streamingAttachments = emptyList(), streamingAttachments = emptyList(),
comparisonState = _uiState.value.comparisonState.copy( comparisonState = _uiState.value.comparisonState.copy(
@ -933,6 +955,19 @@ class ChatViewModel @Inject constructor(
secondaryActiveToolCalls = emptyList(), 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 -> { is StreamEvent.ToolCallStart -> {
val newToolCall = ActiveToolCall( val newToolCall = ActiveToolCall(
@ -999,9 +1034,24 @@ class ChatViewModel @Inject constructor(
streamingAttachments = _uiState.value.streamingAttachments + attachment, streamingAttachments = _uiState.value.streamingAttachments + attachment,
) )
} }
is StreamEvent.Sync, is StreamEvent.Sync -> {
is StreamEvent.Step, // State-snapshot protocol: replace streaming buffer with synced content
-> { /* no-op */ } 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) val status = chatRepository.checkStreamStatus(conversationId)
if (status.active) { if (status.active) {
_uiState.value = _uiState.value.copy(isStreaming = true) _uiState.value = _uiState.value.copy(isStreaming = true)
streamingBuffer.clear() resumeStream(conversationId)
streamingBufferDirty = false
startStreamingUpdater()
streamJob?.cancel()
streamJob = viewModelScope.launch {
collectStreamSafely(chatRepository.resumeStream(conversationId))
}
} else { } else {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isStreaming = false, 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) { private fun resumeActiveStreamIfNeeded(conversationId: String) {
viewModelScope.launch { viewModelScope.launch {
try { try {
@ -1133,13 +1192,7 @@ class ChatViewModel @Inject constructor(
isStreaming = true, isStreaming = true,
screenState = ChatScreenState.ACTIVE, screenState = ChatScreenState.ACTIVE,
) )
streamingBuffer.clear() resumeStream(conversationId)
streamingBufferDirty = false
startStreamingUpdater()
streamJob?.cancel()
streamJob = viewModelScope.launch {
collectStreamSafely(chatRepository.resumeStream(conversationId))
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.d(e, "No active stream to resume for $conversationId") 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?) { fun submitFeedback(messageId: String, rating: String?) {
val conversationId = _uiState.value.conversationId ?: return val conversationId = _uiState.value.conversationId ?: return
viewModelScope.launch { viewModelScope.launch {
@ -1255,7 +1371,7 @@ class ChatViewModel @Inject constructor(
) )
} }
is Result.Error -> { 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 */ } is Result.Loading -> { /* no-op */ }
} }

View file

@ -1,6 +1,5 @@
package com.librechat.android.feature.chat.viewmodel.delegate 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.EndpointConstants
import com.librechat.android.core.common.ToolConstants import com.librechat.android.core.common.ToolConstants
import com.librechat.android.core.common.result.Result import com.librechat.android.core.common.result.Result
@ -217,8 +216,6 @@ class ModelSelectionDelegate(
agentId = if (isAgent) model else null, agentId = if (isAgent) model else null,
model = if (isAgent) null else model, 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 return added
} }
@ -286,7 +283,7 @@ class ModelSelectionDelegate(
} }
} }
is Result.Error -> { 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 */ } is Result.Loading -> { /* no-op */ }
} }

View file

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

View file

@ -1,8 +1,5 @@
package com.librechat.android.feature.settings.screen 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import com.librechat.android.core.ui.components.OtpVerificationDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@ -39,7 +36,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -49,7 +46,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.heading 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.text.style.TextOverflow
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.librechat.android.feature.settings.R import com.librechat.android.feature.settings.R
import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.ErrorBanner
import com.librechat.android.core.ui.components.LoadingIndicator 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 import com.librechat.android.feature.settings.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -228,7 +225,7 @@ fun AccountSettingsContent(
// 2FA enable setup dialog // 2FA enable setup dialog
if (uiState.showTwoFactorSetupDialog) { if (uiState.showTwoFactorSetupDialog) {
AccountTwoFactorSetupDialog( TwoFactorSetupDialog(
otpauthUrl = uiState.twoFactorOtpauthUrl, otpauthUrl = uiState.twoFactorOtpauthUrl,
isLoading = uiState.isTwoFactorLoading, isLoading = uiState.isTwoFactorLoading,
onConfirm = viewModel::confirmEnableTwoFactor, onConfirm = viewModel::confirmEnableTwoFactor,
@ -238,7 +235,7 @@ fun AccountSettingsContent(
// 2FA disable dialog // 2FA disable dialog
if (uiState.showDisableTwoFactorDialog) { if (uiState.showDisableTwoFactorDialog) {
AccountTwoFactorCodeDialog( TwoFactorCodeDialog(
title = stringResource(R.string.dialog_title_disable_2fa), title = stringResource(R.string.dialog_title_disable_2fa),
description = stringResource(R.string.twofa_disable_instructions), description = stringResource(R.string.twofa_disable_instructions),
isLoading = uiState.isTwoFactorLoading, isLoading = uiState.isTwoFactorLoading,
@ -249,7 +246,7 @@ fun AccountSettingsContent(
// Backup codes dialog // Backup codes dialog
if (uiState.showBackupCodesDialog) { if (uiState.showBackupCodesDialog) {
AccountBackupCodesDialog( BackupCodesDialog(
backupCodes = uiState.backupCodes, backupCodes = uiState.backupCodes,
onDismiss = viewModel::dismissBackupCodesDialog, 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 @Composable
@ -468,221 +519,3 @@ private fun AccountSettingsRow(
HorizontalDivider() 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.ErrorBanner
import com.librechat.android.core.ui.components.LoadingIndicator 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.R
import com.librechat.android.feature.settings.screen.sections.AboutInfo import com.librechat.android.feature.settings.screen.sections.AboutInfo
import com.librechat.android.feature.settings.screen.sections.AccountInfo 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 // Language selector dialog
if (uiState.showLanguageDialog) { if (uiState.showLanguageDialog) {
LanguageSelectorDialog( LanguageSelectorDialog(

View file

@ -1,15 +1,11 @@
package com.librechat.android.feature.settings.screen.sections 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -20,11 +16,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.librechat.android.feature.settings.R import com.librechat.android.feature.settings.R
@ -49,18 +42,6 @@ internal fun TwoFactorSetupDialog(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
if (otpauthUrl != null) { 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( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -68,13 +49,13 @@ internal fun TwoFactorSetupDialog(
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
) { ) {
Text( androidx.compose.foundation.text.selection.SelectionContainer {
text = otpauthUrl, Text(
style = MaterialTheme.typography.bodySmall, text = otpauthUrl,
modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodySmall,
maxLines = 3, modifier = Modifier.padding(12.dp),
overflow = TextOverflow.Ellipsis, )
) }
} }
} }
androidx.compose.material3.OutlinedTextField( 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 @Composable
internal fun TwoFactorCodeDialog( internal fun TwoFactorCodeDialog(

View file

@ -2,7 +2,7 @@ package com.librechat.android.feature.settings.viewmodel
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log import timber.log.Timber
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.MemoryManagementDelegate
import com.librechat.android.feature.settings.viewmodel.delegate.SpeechSettingsDelegate 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.TwoFactorSecurityDelegate
import com.librechat.android.feature.settings.viewmodel.delegate.isHttpStatus
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -96,6 +97,9 @@ data class SettingsUiState(
val showBackupCodesDialog: Boolean = false, val showBackupCodesDialog: Boolean = false,
val backupCodes: List<String> = emptyList(), val backupCodes: List<String> = emptyList(),
val showDisableTwoFactorDialog: Boolean = false, val showDisableTwoFactorDialog: Boolean = false,
val showEnableTwoFactorOtpDialog: Boolean = false,
val showBackupCodesOtpDialog: Boolean = false,
val showDeleteAccountOtpDialog: Boolean = false,
// MCP // MCP
val mcpServers: List<McpServer> = emptyList(), val mcpServers: List<McpServer> = emptyList(),
val mcpConnectionStatus: Map<String, McpServerStatus> = emptyMap(), val mcpConnectionStatus: Map<String, McpServerStatus> = emptyMap(),
@ -509,7 +513,7 @@ class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(tokenCredits = result.data.tokenCredits, isBalanceLoading = false) } _uiState.update { it.copy(tokenCredits = result.data.tokenCredits, isBalanceLoading = false) }
} }
is Result.Error -> { 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) } _uiState.update { it.copy(isBalanceLoading = false) }
} }
is Result.Loading -> { /* no-op */ } 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 { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) } _uiState.update { it.copy(isLoading = true) }
when (val result = userRepository.deleteUser()) { when (val result = userRepository.deleteUser(token = token, backupCode = backupCode)) {
is Result.Success -> { is Result.Success -> {
authRepository.logout() authRepository.logout()
_uiState.update { it.copy(isLoading = false, isAccountDeleted = true) } _uiState.update { it.copy(isLoading = false, isAccountDeleted = true, showDeleteAccountOtpDialog = false) }
} }
is Result.Error -> { is Result.Error -> {
val needsOtp = token == null && backupCode == null &&
result.isHttpStatus(403)
_uiState.update { _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 */ } is Result.Loading -> { /* no-op */ }
@ -614,6 +624,10 @@ class SettingsViewModel @Inject constructor(
} }
} }
fun dismissDeleteAccountOtpDialog() {
_uiState.update { it.copy(showDeleteAccountOtpDialog = false) }
}
fun retry() { fun retry() {
loadUser() loadUser()
} }
@ -644,12 +658,16 @@ class SettingsViewModel @Inject constructor(
// Two-factor security // Two-factor security
fun toggleTwoFactor() = twoFactorDelegate.toggleTwoFactor() 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 confirmEnableTwoFactor(code: String) = twoFactorDelegate.confirmEnableTwoFactor(code)
fun confirmDisableTwoFactor(code: String) = twoFactorDelegate.confirmDisableTwoFactor(code) fun confirmDisableTwoFactor(code: String) = twoFactorDelegate.confirmDisableTwoFactor(code)
fun dismissTwoFactorSetupDialog() = twoFactorDelegate.dismissTwoFactorSetupDialog() fun dismissTwoFactorSetupDialog() = twoFactorDelegate.dismissTwoFactorSetupDialog()
fun dismissDisableTwoFactorDialog() = twoFactorDelegate.dismissDisableTwoFactorDialog() fun dismissDisableTwoFactorDialog() = twoFactorDelegate.dismissDisableTwoFactorDialog()
fun dismissBackupCodesDialog() = twoFactorDelegate.dismissBackupCodesDialog() fun dismissBackupCodesDialog() = twoFactorDelegate.dismissBackupCodesDialog()
fun viewBackupCodes() = twoFactorDelegate.viewBackupCodes() fun viewBackupCodes() = twoFactorDelegate.viewBackupCodes()
fun viewBackupCodesWithOtp(token: String?, backupCode: String?) = twoFactorDelegate.viewBackupCodes(token = token, backupCode = backupCode)
fun dismissBackupCodesOtpDialog() = twoFactorDelegate.dismissBackupCodesOtpDialog()
// Data management // Data management
fun clearAllChats() = dataDelegate.clearAllChats() fun clearAllChats() = dataDelegate.clearAllChats()

View file

@ -1,5 +1,6 @@
package com.librechat.android.feature.settings.viewmodel.delegate 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.common.result.Result
import com.librechat.android.core.data.repository.AuthRepository import com.librechat.android.core.data.repository.AuthRepository
import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle
@ -17,33 +18,48 @@ class TwoFactorSecurityDelegate(
if (stateHandle.state.isTwoFactorEnabled) { if (stateHandle.state.isTwoFactorEnabled) {
stateHandle.update { copy(showDisableTwoFactorDialog = true) } stateHandle.update { copy(showDisableTwoFactorDialog = true) }
} else { } else {
stateHandle.scope.launch { enableTwoFactor()
stateHandle.update { copy(isTwoFactorLoading = true) } }
when (val result = authRepository.enableTwoFactor()) { }
is Result.Success -> {
stateHandle.update { /**
copy( * Enable 2FA. When re-enrolling (2FA already enabled on server), OTP is required.
isTwoFactorLoading = false, */
showTwoFactorSetupDialog = true, fun enableTwoFactor(token: String? = null, backupCode: String? = null) {
twoFactorOtpauthUrl = result.data.otpauthUrl, stateHandle.scope.launch {
backupCodes = result.data.backupCodes, 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) { fun confirmEnableTwoFactor(code: String) {
stateHandle.scope.launch { stateHandle.scope.launch {
stateHandle.update { copy(isTwoFactorLoading = true) } stateHandle.update { copy(isTwoFactorLoading = true) }
@ -111,24 +127,31 @@ class TwoFactorSecurityDelegate(
stateHandle.update { copy(showBackupCodesDialog = false, backupCodes = emptyList()) } 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.scope.launch {
stateHandle.update { copy(isTwoFactorLoading = true) } stateHandle.update { copy(isTwoFactorLoading = true) }
when (val result = authRepository.regenerateBackupCodes()) { when (val result = authRepository.regenerateBackupCodes(token = token, backupCode = backupCode)) {
is Result.Success -> { is Result.Success -> {
stateHandle.update { stateHandle.update {
copy( copy(
isTwoFactorLoading = false, isTwoFactorLoading = false,
showBackupCodesDialog = true, showBackupCodesDialog = true,
showBackupCodesOtpDialog = false,
backupCodes = result.data.backupCodes, backupCodes = result.data.backupCodes,
) )
} }
} }
is Result.Error -> { 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 { stateHandle.update {
copy( copy(
isTwoFactorLoading = false, 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 --> <!-- 2FA setup dialog -->
<string name="dialog_title_enable_2fa">Enable Two-Factor Authentication</string> <string name="dialog_title_enable_2fa">Enable Two-Factor Authentication</string>
<string name="dialog_title_disable_2fa">Disable 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="twofa_disable_instructions">Enter your authenticator code to disable 2FA.</string>
<string name="hint_verification_code">Verification code</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 --> <!-- Backup codes dialog -->
<string name="dialog_title_backup_codes">Backup Codes</string> <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> <string name="backup_codes_instructions">Save these backup codes in a safe place. Each code can only be used once.</string>