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
|
* 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).
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"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_"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 */ }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 */ }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue