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:
parent
34ddeb9789
commit
d30988f8b4
39 changed files with 761 additions and 438 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ data class InterfaceConfig(
|
|||
val fileSearch: Boolean = true,
|
||||
val fileCitations: Boolean = true,
|
||||
val customWelcome: String? = null,
|
||||
val remoteAgents: JsonElement? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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_"
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ private fun DigitBoxes(
|
|||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.width(48.dp)
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequesters[index]),
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ android {
|
|||
dependencies {
|
||||
implementation(project(":core:network"))
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue