KMP iOS migration

Complete Kotlin Multiplatform migration with iOS parity:
- All 13 modules converted to KMP (commonMain + androidMain + iosMain)
- 100% feature parity: auth, chat, SSE streaming, conversations,
  settings, agents, files, voice input, TTS, OAuth, file picker,
  clipboard paste
- Platform consolidation: DataStore path, SessionCacheCleaner,
  bubble layouts, ChatInput core, MIME types, mention parsing,
  TextField styling, FilterChip components extracted to commonMain
- iOS crash reporting via Kotlin/Native exception hook
- YAML parsing via custom pure-Kotlin parser (zero dependencies)
- Dependency health: Timber removed, kaml replaced, FuzzyWuzzy removed,
  security-crypto stabilized
- CONTRIBUTING.md
- Gradle configuration cache enabled
This commit is contained in:
Garfie 2026-04-02 19:54:58 -05:00
parent 1868bab690
commit 770603e466
700 changed files with 18361 additions and 4823 deletions

View file

@ -1,50 +0,0 @@
name: Android CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Cache Gradle packages
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Lint
run: ./gradlew lint
- name: Unit Tests
run: ./gradlew test
- name: Build Debug APK
run: ./gradlew assembleDebug
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: debug-apk
path: app/build/outputs/apk/debug/app-debug.apk

View file

@ -61,5 +61,3 @@ Each module has its own `CLAUDE.md` with specific guidance.
- **`UPSTREAM_VERSION`** — Tracks which official tag/commit this Android build is based on. Updated by the `/sync-upstream` skill.
- **`BackendVersion.kt`** (`core/common/`) — `SUPPORTED_BACKEND_VERSION` constant must match the tag in `UPSTREAM_VERSION` (without `v` prefix).
- **`/sync-upstream`** — Claude Code skill to diff upstream releases, identify gaps, propose changes, and implement them with user approval. Uses Agent Teams (investigator, android-expert, implementer, verifier).
@~/.claude/librechat-android-local.md

99
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,99 @@
# Contributing to LibreChat Native KMP
## Prerequisites
| Tool | Version |
|------|---------|
| JDK | 17+ |
| Android Studio | Latest stable |
| Xcode | 15+ (iOS development) |
| Gradle | 8.11.1 (via wrapper) |
| Kotlin | 2.1.20 |
## Getting Started
```bash
# Clone with submodules (upstream LibreChat server reference)
git clone --recursive https://github.com/anthropics/LibreChat-Android.git
cd LibreChat-Android
```
### Android
```bash
./gradlew assembleDebug
```
Open the project in Android Studio and run on an emulator or device. Debug APK output: `app/build/outputs/apk/debug/app-debug.apk`.
### iOS
```bash
# Build the shared KMP framework
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
```
Open `iosApp/iosApp.xcodeproj` in Xcode and run on an iOS simulator. The shared framework must be rebuilt whenever shared code changes.
## Project Structure
The project is a Kotlin Multiplatform (KMP) app with shared business logic and platform-specific UI layers.
### Modules
```
app/ -> Android entry point, adaptive navigation (phone/tablet)
shared/ -> KMP umbrella module (commonMain, androidMain, iosMain)
core/common/ -> Result type, dispatcher DI, coroutine scopes, extensions
core/model/ -> @Serializable data classes (pure Kotlin)
core/network/ -> Ktor client, API services, SSE client, auth interceptor
core/data/ -> Room DB, DataStore, repositories
core/ui/ -> Material 3 theme, shared composables
feature/auth/ -> Login, Register, 2FA, OAuth, Forgot Password
feature/chat/ -> Real-time chat with SSE streaming, message rendering
feature/conversations/ -> Paginated list, CRUD, tags, search, export/import
feature/settings/ -> Account, appearance, about
feature/agents/ -> Agent marketplace with search and categories
feature/files/ -> File upload, management, image viewer
```
Source sets: `commonMain` = shared code, `androidMain` / `iosMain` = platform-specific implementations.
### Convention Plugins
`build-logic/convention/` contains 13 convention plugins (8 Android + 5 KMP) that standardize module configuration. See `CLAUDE.md` for details.
## Adding a New Feature Module
1. Create the module directory under `feature/`
2. Apply the `librechat.kmp.feature` convention plugin in `build.gradle.kts`
3. Create `di/<Feature>Module.kt` with a Koin `module { }` containing `viewModelOf(::YourViewModel)` definitions
4. Register the module in the application startup Koin configuration
5. Use `koinViewModel()` in screen composables to inject ViewModels
## Code Style
- Follow [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html)
- ViewModels live in `commonMain` only — no platform-specific ViewModels
- Handle platform differences via `expect`/`actual` declarations or delegate factories
- Use [Kermit](https://github.com/touchlab/Kermit) for logging (`co.touchlab.kermit.Logger`), not `println`, `Log.d`, or `NSLog`
- Feature modules depend on `:core:*` only, never on each other
## Testing
- Unit tests go in `commonTest` or `androidUnitTest`
- Maestro flows for E2E testing (8 existing flows in `.maestro/`)
- Run tests before submitting:
```bash
./gradlew test
```
## Architecture Rules
- Feature modules depend on `:core:*` only, never on each other
- Unidirectional data flow: UI -> ViewModel -> Repository -> API/Room
- Room is a read-through cache; server is source of truth
- Custom SSE parser over raw `ByteReadChannel` (not Ktor SSE plugin)
See `CLAUDE.md` for the full architecture overview and backend quirks.

View file

@ -5,7 +5,7 @@ Build a native Android app (LibreChat-Android) with full feature parity to the L
## Tech Stack (Mandated)
- **UI**: Jetpack Compose + Compose Navigation
- **DI**: Hilt
- **DI**: Koin
- **Network**: Ktor Client
- **Serialization**: Kotlinx Serialization
- **Min SDK**: TBD (recommend 26+)

View file

@ -37,9 +37,19 @@ Without this setting, the server's violation system may accumulate ban points ag
- **Registration** — The app respects your server's registration settings. If registration is disabled server-side, only the login form is shown.
## Requirements
| Tool | Version |
|------|---------|
| JDK | 17+ |
| Android Studio | Latest stable |
| Xcode | 15+ (iOS only) |
| Gradle | 8.11.1 (via wrapper) |
| Kotlin | 2.1.20 |
## Building from Source
**Requirements:** Android Studio, JDK 17+
### Android
```bash
./gradlew assembleDebug
@ -53,14 +63,30 @@ For a release build:
./gradlew assembleRelease
```
### iOS
Build the shared KMP framework and open in Xcode:
```bash
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
open iosApp/iosApp.xcodeproj
```
Run on an iOS simulator from Xcode. The shared framework must be rebuilt whenever shared code changes.
### Tech Stack
- Jetpack Compose + Compose Navigation
- Hilt (dependency injection)
- Kotlin Multiplatform (KMP) with shared business logic
- Jetpack Compose (Android) + SwiftUI (iOS)
- Koin (dependency injection)
- Ktor Client with OkHttp engine
- Kotlinx Serialization
- Room (cache), DataStore (preferences), EncryptedSharedPreferences (tokens)
- Kotlin 2.1.0, compileSdk 35, minSdk 26
- Kotlin 2.1.20, compileSdk 35, minSdk 26
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style, and PR guidelines.
## License

View file

@ -36,8 +36,9 @@ dependencies {
implementation(libs.koin.compose.viewmodel.navigation)
implementation(libs.compose.material3.wsc)
implementation(libs.bundles.lifecycle)
implementation(libs.coil.compose)
implementation(libs.timber)
implementation(libs.coil3.compose)
implementation(libs.coil3.network.ktor)
implementation(libs.kermit)
debugImplementation(libs.leakcanary)

View file

@ -1,37 +1,35 @@
package com.librechat.android
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.crossfade
import com.librechat.android.core.common.di.commonModule
import com.librechat.android.core.data.di.dataModule
import com.librechat.android.core.network.client.TokenManager
import com.librechat.android.core.network.di.networkModule
import com.librechat.android.feature.agents.di.agentsModule
import com.librechat.android.feature.auth.di.authModule
import com.librechat.android.feature.auth.di.authPlatformModule
import com.librechat.android.feature.chat.di.chatModule
import com.librechat.android.feature.conversations.di.conversationsModule
import com.librechat.android.feature.files.di.filesModule
import com.librechat.android.feature.settings.di.settingsModule
import com.librechat.android.navigation.appModule
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import io.ktor.client.HttpClient
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import timber.log.Timber
import co.touchlab.kermit.Logger
class LibreChatApplication : Application(), ImageLoaderFactory {
class LibreChatApplication : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
try {
startKoin {
if (BuildConfig.DEBUG) {
@ -45,6 +43,7 @@ class LibreChatApplication : Application(), ImageLoaderFactory {
dataModule,
appModule,
authModule,
authPlatformModule,
chatModule,
conversationsModule,
settingsModule,
@ -53,49 +52,21 @@ class LibreChatApplication : Application(), ImageLoaderFactory {
)
}
} catch (e: Exception) {
Timber.e(e, "Koin initialization failed")
Logger.e(e) { "Koin initialization failed" }
throw e // Always rethrow — DI failure is unrecoverable
}
}
override fun newImageLoader(): ImageLoader {
val tokenManager: TokenManager = get()
override fun newImageLoader(context: coil3.PlatformContext): ImageLoader {
val httpClient: HttpClient = get()
val okHttpClient = OkHttpClient.Builder()
.addInterceptor { chain ->
val token = runBlocking { tokenManager.getAccessToken() }
val requestBuilder = chain.request().newBuilder()
// The backend's uaParser middleware (ua-parser-js) rejects requests
// without a recognized browser User-Agent with 403. This must match
// the UA string used by the Ktor HttpClient.
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36",
)
if (token != null) {
requestBuilder.addHeader("Authorization", "Bearer $token")
}
val request = requestBuilder.build()
Timber.d("Coil image request: %s", request.url)
val response = chain.proceed(request)
if (!response.isSuccessful) {
Timber.w(
"Coil image request failed: %s -> %d %s",
request.url,
response.code,
response.message,
)
}
response
return ImageLoader.Builder(context)
.components {
add(KtorNetworkFetcherFactory(httpClient))
}
.build()
return ImageLoader.Builder(this)
.okHttpClient(okHttpClient)
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.25)
MemoryCache.Builder()
.maxSizePercent(context, 0.25)
.build()
}
.diskCache {

View file

@ -40,7 +40,7 @@ import com.librechat.android.feature.chat.ShareIntentConsumer
import com.librechat.android.feature.chat.SharedContent
import com.librechat.android.navigation.LibreChatNavHost
import org.koin.android.ext.android.inject
import timber.log.Timber
import co.touchlab.kermit.Logger
class MainActivity : ComponentActivity() {
@ -66,7 +66,7 @@ class MainActivity : ComponentActivity() {
setContent {
val windowSizeClass = calculateWindowSizeClass(this)
val isConnected by connectivityObserver.isConnected.collectAsStateWithLifecycle(initialValue = true)
val themeMode by themeDataStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
val themeMode by themeDataStore.themeMode.collectAsStateWithLifecycle(initialValue = themeDataStore.initialThemeMode)
val darkTheme = when (themeMode) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
@ -138,7 +138,7 @@ class MainActivity : ComponentActivity() {
if (uri.scheme == "librechat" && uri.host in KNOWN_DEEP_LINK_HOSTS) {
deepLinkUri = uri
} else if (uri.scheme == "librechat") {
Timber.w("Ignoring deep link with unknown host: %s", uri.host)
Logger.w { "Ignoring deep link with unknown host: ${uri.host}" }
}
}
}
@ -164,7 +164,7 @@ class MainActivity : ComponentActivity() {
val fileUris = if (sharedUri != null) listOf(sharedUri) else emptyList()
if (sharedText != null || fileUris.isNotEmpty()) {
Timber.d("Share intent received: text=%s, uris=%d", sharedText != null, fileUris.size)
Logger.d { "Share intent received: text=${sharedText != null}, uris=${fileUris.size}" }
ShareIntentConsumer.setPendingShare(
SharedContent(text = sharedText, fileUris = fileUris),
)
@ -181,7 +181,7 @@ class MainActivity : ComponentActivity() {
}
if (!sharedUris.isNullOrEmpty()) {
Timber.d("Share multiple intent received: uris=%d", sharedUris.size)
Logger.d { "Share multiple intent received: uris=${sharedUris.size}" }
ShareIntentConsumer.setPendingShare(
SharedContent(fileUris = sharedUris),
)

View file

@ -60,7 +60,7 @@ import com.librechat.android.feature.settings.navigation.SHARED_LINKS_ROUTE
import com.librechat.android.feature.settings.navigation.settingsGraph
import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
import timber.log.Timber
import co.touchlab.kermit.Logger
/** Maps a [SettingsCategory] to its corresponding navigation route. */
fun SettingsCategory.toRoute(): String = when (this) {
@ -214,7 +214,7 @@ private fun PhoneLayout(
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navController.navigateToChat(conversationId)
} else {
Timber.w("Ignoring deep link with invalid conversation ID: %s", conversationId)
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
}
}
}

View file

@ -25,11 +25,11 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import co.touchlab.kermit.Logger
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
/**
* Combined UI state for the drawer sidebar. A single emission replaces
@ -64,7 +64,7 @@ class NavHostViewModel(
private val settingsDataStore: com.librechat.android.core.data.datastore.SettingsDataStore,
) : ViewModel() {
private val _isLoggedIn = MutableStateFlow(false)
private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
/**
@ -227,7 +227,7 @@ class NavHostViewModel(
nextCursor = result.data
_hasMore.value = result.data != null
} else {
Timber.w("Failed to load more conversations")
Logger.w { "Failed to load more conversations" }
}
_isLoadingMore.value = false
}
@ -308,12 +308,12 @@ class NavHostViewModel(
viewModelScope.launch {
val result = bannerRepository.getBanners()
if (result is Result.Success) {
val now = Instant.now()
val now = Clock.System.now()
_banners.value = result.data.filter { banner ->
val from = banner.displayFrom?.let { runCatching { Instant.parse(it) }.getOrNull() }
val to = banner.displayTo?.let { runCatching { Instant.parse(it) }.getOrNull() }
val afterStart = from == null || !now.isBefore(from)
val beforeEnd = to == null || now.isBefore(to)
val afterStart = from == null || now >= from
val beforeEnd = to == null || now < to
afterStart && beforeEnd
}
}
@ -350,11 +350,9 @@ class NavHostViewModel(
backendVersion = detectedVersion,
)
} else {
Timber.d(
"Version mismatch (backend=%s, supported=%s) but user dismissed for this version",
detectedVersion,
checkResult.supportedVersion,
)
Logger.d {
"Version mismatch (backend=$detectedVersion, supported=${checkResult.supportedVersion}) but user dismissed for this version"
}
}
}
}
@ -434,17 +432,26 @@ private fun Conversation.toDrawerDisplayData(
}
private fun Instant.toRelativeTimeString(): String {
val now = Instant.now()
val minutes = ChronoUnit.MINUTES.between(this, now)
val hours = ChronoUnit.HOURS.between(this, now)
val days = ChronoUnit.DAYS.between(this, now)
val now = Clock.System.now()
val duration = now - this
val minutes = duration.inWholeMinutes
val hours = duration.inWholeHours
val days = duration.inWholeDays
return when {
minutes < 1 -> "Just now"
minutes < 60 -> "${minutes}m ago"
hours < 24 -> "${hours}h ago"
days < 7 -> "${days}d ago"
else -> atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("MMM d"))
else -> {
val date = toLocalDateTime(TimeZone.currentSystemDefault()).date
val monthAbbr = when (date.monthNumber) {
1 -> "Jan"; 2 -> "Feb"; 3 -> "Mar"; 4 -> "Apr"
5 -> "May"; 6 -> "Jun"; 7 -> "Jul"; 8 -> "Aug"
9 -> "Sep"; 10 -> "Oct"; 11 -> "Nov"; 12 -> "Dec"
else -> ""
}
"$monthAbbr ${date.dayOfMonth}"
}
}
}

View file

@ -58,7 +58,7 @@ import com.librechat.android.feature.settings.navigation.SETTINGS_TABBED_ROUTE
import com.librechat.android.feature.settings.navigation.SHARED_LINKS_ROUTE
import com.librechat.android.feature.settings.navigation.settingsGraph
import kotlinx.coroutines.launch
import timber.log.Timber
import co.touchlab.kermit.Logger
private val SidebarWidth = 320.dp
@ -109,7 +109,7 @@ fun TabletLayout(
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navController.navigateToChat(conversationId)
} else {
Timber.w("Ignoring deep link with invalid conversation ID: %s", conversationId)
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
}
}
}

View file

@ -56,9 +56,11 @@ import com.librechat.android.core.network.di.networkModule
import com.librechat.android.core.network.sse.SseClient
import com.librechat.android.feature.agents.di.agentsModule
import com.librechat.android.feature.auth.di.authModule
import com.librechat.android.feature.auth.oauth.OAuthLauncher
import com.librechat.android.feature.chat.di.chatModule
import com.librechat.android.feature.conversations.di.conversationsModule
import com.librechat.android.feature.files.di.filesModule
import com.librechat.android.feature.files.platform.FileReader
import com.librechat.android.feature.settings.di.settingsModule
import com.librechat.android.navigation.appModule
import kotlinx.coroutines.CoroutineDispatcher
@ -155,6 +157,10 @@ class KoinGraphVerificationTest {
SpeechRepository::class,
TagRepository::class,
UserRepository::class,
// feature:auth platform provides
OAuthLauncher::class,
// feature:files platform provides
FileReader::class,
// Wrappers/DSL types that verify can't resolve via constructor
kotlin.Lazy::class,
java.io.File::class,

View file

@ -6,6 +6,7 @@ dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.compose.gradlePlugin)
compileOnly(libs.compose.multiplatform.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.room.gradlePlugin)
compileOnly(libs.detekt.gradlePlugin)
@ -46,5 +47,25 @@ gradlePlugin {
id = "librechat.android.detekt"
implementationClass = "AndroidDetektConventionPlugin"
}
register("kmpLibrary") {
id = "librechat.kmp.library"
implementationClass = "KmpLibraryConventionPlugin"
}
register("kmpCompose") {
id = "librechat.kmp.compose"
implementationClass = "KmpComposeConventionPlugin"
}
register("kmpKoin") {
id = "librechat.kmp.koin"
implementationClass = "KmpKoinConventionPlugin"
}
register("kmpRoom") {
id = "librechat.kmp.room"
implementationClass = "KmpRoomConventionPlugin"
}
register("kmpFeature") {
id = "librechat.kmp.feature"
implementationClass = "KmpFeatureConventionPlugin"
}
}
}

View file

@ -2,6 +2,8 @@ import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
@ -11,8 +13,14 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
pluginManager.apply("librechat.android.detekt")
pluginManager.apply("org.jetbrains.kotlinx.kover")
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.addAll("-opt-in=kotlin.time.ExperimentalTime")
}
}
extensions.configure<ApplicationExtension> {
compileSdk = 35
compileSdk = 36
defaultConfig {
minSdk = 26
targetSdk = 35

View file

@ -2,6 +2,8 @@ import com.android.build.gradle.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
@ -11,8 +13,14 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
pluginManager.apply("librechat.android.detekt")
pluginManager.apply("org.jetbrains.kotlinx.kover")
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.addAll("-opt-in=kotlin.time.ExperimentalTime")
}
}
extensions.configure<LibraryExtension> {
compileSdk = 35
compileSdk = 36
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View file

@ -0,0 +1,32 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.compose.ComposeExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
class KmpComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("org.jetbrains.compose")
pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
val compose = extensions.getByType<ComposeExtension>().dependencies
extensions.configure<KotlinMultiplatformExtension> {
sourceSets.commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation(compose.ui)
implementation(compose.animation)
@Suppress("DEPRECATION")
implementation(compose.components.resources)
@Suppress("DEPRECATION")
implementation(compose.components.uiToolingPreview)
}
}
}
}
}

View file

@ -0,0 +1,34 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
class KmpFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("librechat.kmp.library")
pluginManager.apply("librechat.kmp.compose")
pluginManager.apply("librechat.kmp.koin")
pluginManager.apply("librechat.kotlin.serialization")
extensions.configure<KotlinMultiplatformExtension> {
sourceSets.commonMain.dependencies {
implementation(project(":core:ui"))
implementation(project(":core:data"))
implementation(project(":core:model"))
implementation(project(":core:common"))
implementation(libs.findLibrary("koin-compose").get())
implementation(libs.findLibrary("koin-compose-viewmodel").get())
implementation(libs.findLibrary("koin-compose-viewmodel-navigation").get())
implementation(libs.findLibrary("navigation-compose-kmp").get())
implementation(libs.findLibrary("lifecycle-runtime-compose-kmp").get())
implementation(libs.findLibrary("lifecycle-viewmodel-compose-kmp").get())
}
}
}
}
}
private val Project.libs
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
.named("libs")

View file

@ -0,0 +1,23 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
class KmpKoinConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
extensions.configure<KotlinMultiplatformExtension> {
sourceSets.commonMain.dependencies {
implementation(libs.findLibrary("koin-core").get())
}
sourceSets.androidMain.dependencies {
implementation(libs.findLibrary("koin-android").get())
}
}
}
}
}
private val Project.libs
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
.named("libs")

View file

@ -0,0 +1,69 @@
import com.android.build.gradle.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
class KmpLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.multiplatform")
pluginManager.apply("librechat.android.detekt")
pluginManager.apply("org.jetbrains.kotlinx.kover")
extensions.configure<KotlinMultiplatformExtension> {
compilerOptions {
freeCompilerArgs.addAll(
"-opt-in=kotlin.time.ExperimentalTime",
)
}
androidTarget {
compilations.configureEach {
compileTaskProvider.configure {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
}
}
iosArm64()
iosSimulatorArm64()
sourceSets.commonTest.dependencies {
implementation(kotlin("test"))
}
sourceSets.named("androidUnitTest").configure {
dependencies {
implementation(libs.findBundle("testing").get())
}
}
}
extensions.configure<LibraryExtension> {
compileSdk = 36
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17
targetCompatibility = org.gradle.api.JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
lint {
disable += "NullSafeMutableLiveData"
}
testOptions {
unitTests.isReturnDefaultValues = true
}
}
dependencies.add("coreLibraryDesugaring", libs.findLibrary("desugar-jdk").get())
}
}
}
private val Project.libs
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
.named("libs")

View file

@ -0,0 +1,36 @@
import androidx.room.gradle.RoomExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
class KmpRoomConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("androidx.room")
pluginManager.apply("com.google.devtools.ksp")
extensions.configure<RoomExtension> {
schemaDirectory("$projectDir/schemas")
}
extensions.configure<org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension> {
sourceSets.commonMain.dependencies {
implementation(libs.findLibrary("room-runtime").get())
implementation(libs.findLibrary("sqlite-bundled").get())
}
}
dependencies {
add("kspCommonMainMetadata", libs.findLibrary("room-compiler").get())
add("kspAndroid", libs.findLibrary("room-compiler").get())
add("kspIosArm64", libs.findLibrary("room-compiler").get())
add("kspIosSimulatorArm64", libs.findLibrary("room-compiler").get())
}
}
}
}
private val Project.libs
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
.named("libs")

View file

@ -2,10 +2,13 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.room) apply false
alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.skie) apply false
alias(libs.plugins.detekt) apply false
alias(libs.plugins.kover)
}

View file

@ -3,40 +3,11 @@
// enabling skip optimizations for composable parameters of these types.
// See: https://developer.android.com/develop/ui/compose/performance/stability/fix#configuration-file
// Core model classes (all are data classes with val-only fields, never mutated after creation)
com.librechat.android.core.model.Message
com.librechat.android.core.model.Conversation
com.librechat.android.core.model.MessageContentPart
com.librechat.android.core.model.ToolCall
com.librechat.android.core.model.ToolCallFunction
com.librechat.android.core.model.FileObject
com.librechat.android.core.model.FileReference
com.librechat.android.core.model.Attachment
com.librechat.android.core.model.Feedback
com.librechat.android.core.model.EndpointConfig
com.librechat.android.core.model.Preset
com.librechat.android.core.model.ConversationTag
com.librechat.android.core.model.Agent
com.librechat.android.core.model.PromptGroup
com.librechat.android.core.model.Prompt
com.librechat.android.core.model.mcp.McpServer
com.librechat.android.core.model.mcp.McpTool
com.librechat.android.core.model.mcp.McpApiKeyConfig
com.librechat.android.core.model.mcp.McpOAuthConfig
com.librechat.android.core.model.mcp.McpConnectionStatusResponse
com.librechat.android.core.model.mcp.McpServerStatus
com.librechat.android.core.model.Banner
com.librechat.android.core.model.speech.TtsVoice
com.librechat.android.core.model.ApiKey
com.librechat.android.core.model.SharedLink
com.librechat.android.core.model.AgentTool
com.librechat.android.core.model.AgentAction
com.librechat.android.core.model.AgentCategory
com.librechat.android.core.model.UserFavorite
com.librechat.android.core.model.Memory
com.librechat.android.core.model.StartupConfig
com.librechat.android.core.model.ModelSpecs
com.librechat.android.core.model.InterfaceConfig
// Core model classes — all are data classes with val-only fields, never mutated after creation.
// Wildcard patterns auto-cover new models without manual maintenance.
com.librechat.android.core.model.*
com.librechat.android.core.model.mcp.*
com.librechat.android.core.model.speech.*
// Kotlin stdlib collections (we only use immutable instances in practice)
kotlin.collections.List

View file

@ -1,13 +1,24 @@
plugins {
id("librechat.android.library")
id("librechat.android.koin")
id("librechat.kmp.library")
id("librechat.kmp.koin")
}
android {
namespace = "com.librechat.android.core.common"
}
dependencies {
implementation(libs.coroutines.core)
implementation(libs.coroutines.android)
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.coroutines.core)
api(libs.kotlinx.datetime)
}
androidMain.dependencies {
implementation(libs.coroutines.android)
implementation(libs.koin.android)
}
named("androidUnitTest").dependencies {
implementation(libs.koin.test)
}
}
}

View file

@ -0,0 +1,11 @@
package com.librechat.android.core.common.di
import com.librechat.android.core.common.network.AndroidConnectivityObserver
import com.librechat.android.core.common.network.ConnectivityObserver
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.Module
import org.koin.dsl.module
actual val commonPlatformModule: Module = module {
single<ConnectivityObserver> { AndroidConnectivityObserver(androidContext()) }
}

View file

@ -0,0 +1,6 @@
package com.librechat.android.core.common.di
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO

View file

@ -0,0 +1,13 @@
package com.librechat.android.core.common.extensions
import java.time.Month
import java.time.format.TextStyle
import java.util.Locale
actual fun formatMonthYear(month: Int, year: Int): String {
val monthName = Month.of(month).getDisplayName(TextStyle.FULL, Locale.getDefault())
return "$monthName $year"
}
actual fun formatMonthAbbrev(month: Int): String =
Month.of(month).getDisplayName(TextStyle.SHORT, Locale.getDefault())

View file

@ -11,12 +11,12 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
class ConnectivityObserver(
class AndroidConnectivityObserver(
private val context: Context,
) {
) : ConnectivityObserver {
// Permission is declared in app module's AndroidManifest.xml
@SuppressLint("MissingPermission")
val isConnected: Flow<Boolean> = callbackFlow {
override val isConnected: Flow<Boolean> = callbackFlow {
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
// Emit current state

View file

@ -0,0 +1,91 @@
package com.librechat.android.core.common.network
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
* Contract tests for ConnectivityObserver interface.
* Uses a fake implementation to verify Flow behavior.
*/
class ConnectivityObserverContractTest {
private class FakeConnectivityObserver(initiallyConnected: Boolean = true) : ConnectivityObserver {
private val _isConnected = MutableStateFlow(initiallyConnected)
override val isConnected: Flow<Boolean> = _isConnected
fun setConnected(connected: Boolean) {
_isConnected.value = connected
}
}
@Test
fun `emits initial connected state`() = runTest {
val observer = FakeConnectivityObserver(initiallyConnected = true)
observer.isConnected.test {
assertThat(awaitItem()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `emits initial disconnected state`() = runTest {
val observer = FakeConnectivityObserver(initiallyConnected = false)
observer.isConnected.test {
assertThat(awaitItem()).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `emits false when connection is lost`() = runTest {
val observer = FakeConnectivityObserver(initiallyConnected = true)
observer.isConnected.test {
assertThat(awaitItem()).isTrue()
observer.setConnected(false)
assertThat(awaitItem()).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `emits true when connection is restored`() = runTest {
val observer = FakeConnectivityObserver(initiallyConnected = false)
observer.isConnected.test {
assertThat(awaitItem()).isFalse()
observer.setConnected(true)
assertThat(awaitItem()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `deduplicates consecutive identical states`() = runTest {
val observer = FakeConnectivityObserver(initiallyConnected = true)
observer.isConnected.test {
assertThat(awaitItem()).isTrue()
// Setting same value again should not emit
observer.setConnected(true)
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `tracks multiple state transitions`() = runTest {
val observer = FakeConnectivityObserver(initiallyConnected = true)
observer.isConnected.test {
assertThat(awaitItem()).isTrue()
observer.setConnected(false)
assertThat(awaitItem()).isFalse()
observer.setConnected(true)
assertThat(awaitItem()).isTrue()
observer.setConnected(false)
assertThat(awaitItem()).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
}

View file

@ -5,12 +5,17 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
expect val commonPlatformModule: Module
val commonModule = module {
includes(commonPlatformModule)
// Dispatchers
single<CoroutineDispatcher>(KoinQualifiers.IO) { Dispatchers.IO }
single<CoroutineDispatcher>(KoinQualifiers.IO) { ioDispatcher }
single<CoroutineDispatcher>(KoinQualifiers.Default) { Dispatchers.Default }
single<CoroutineDispatcher>(KoinQualifiers.Main) { Dispatchers.Main }
@ -20,7 +25,4 @@ val commonModule = module {
single<CoroutineScope>(KoinQualifiers.ApplicationScope) {
CoroutineScope(SupervisorJob() + get<CoroutineDispatcher>(KoinQualifiers.Default))
}
// Connectivity
single { ConnectivityObserver(androidContext()) }
}

View file

@ -0,0 +1,5 @@
package com.librechat.android.core.common.di
import kotlinx.coroutines.CoroutineDispatcher
expect val ioDispatcher: CoroutineDispatcher

View file

@ -0,0 +1,31 @@
package com.librechat.android.core.common.extensions
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.daysUntil
import kotlinx.datetime.toLocalDateTime
fun String.toInstantOrNull(): Instant? =
try { Instant.parse(this) } catch (_: Exception) { null }
fun Instant.toRelativeDateGroup(): String {
val tz = TimeZone.currentSystemDefault()
val today = Clock.System.now().toLocalDateTime(tz).date
val date = toLocalDateTime(tz).date
val daysBetween = date.daysUntil(today)
return when {
daysBetween == 0 -> "Today"
daysBetween == 1 -> "Yesterday"
daysBetween <= 7 -> "Previous 7 Days"
daysBetween <= 30 -> "Previous 30 Days"
else -> date.formatMonthYear()
}
}
fun Long.toRelativeDateGroup(): String =
Instant.fromEpochMilliseconds(this).toRelativeDateGroup()
private fun LocalDate.formatMonthYear(): String =
formatMonthYear(monthNumber, year)

View file

@ -0,0 +1,11 @@
package com.librechat.android.core.common.extensions
/**
* Returns a locale-aware full month name + year string (e.g. "January 2025" in English).
*/
expect fun formatMonthYear(month: Int, year: Int): String
/**
* Returns a locale-aware abbreviated month name (e.g. "Jan" in English).
*/
expect fun formatMonthAbbrev(month: Int): String

View file

@ -4,6 +4,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.retryWhen
import kotlin.time.Clock
import kotlin.math.pow
fun <T> Flow<T>.retryWithBackoff(
@ -21,7 +22,7 @@ fun <T> Flow<T>.retryWithBackoff(
fun <T> Flow<T>.throttleFirst(periodMillis: Long): Flow<T> = flow {
var lastTime = 0L
collect { value ->
val currentTime = System.currentTimeMillis()
val currentTime = Clock.System.now().toEpochMilliseconds()
if (currentTime - lastTime >= periodMillis) {
lastTime = currentTime
emit(value)

View file

@ -0,0 +1,7 @@
package com.librechat.android.core.common.network
import kotlinx.coroutines.flow.Flow
interface ConnectivityObserver {
val isConnected: Flow<Boolean>
}

View file

@ -0,0 +1,91 @@
package com.librechat.android.core.common.result
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
import kotlin.test.fail
class SafeApiCallTest {
@Test
fun safeApiCallReturnsSuccessOnSuccessfulBlock() = runTest {
val result = safeApiCall { "hello" }
assertIs<Result.Success<String>>(result)
assertEquals("hello", result.data)
}
@Test
fun safeApiCallReturnsErrorOnException() = runTest {
val result = safeApiCall<String> { throw IllegalStateException("boom") }
assertIs<Result.Error>(result)
assertEquals("boom", result.message)
assertIs<IllegalStateException>(result.exception)
}
@Test
fun isSuccessReturnsTrueForSuccess() {
val result: Result<String> = Result.Success("data")
assertTrue(result.isSuccess())
assertFalse(result.isError())
assertFalse(result.isLoading())
}
@Test
fun isErrorReturnsTrueForError() {
val result: Result<String> = Result.Error(message = "fail")
assertTrue(result.isError())
assertFalse(result.isSuccess())
}
@Test
fun isLoadingReturnsTrueForLoading() {
val result: Result<String> = Result.Loading
assertTrue(result.isLoading())
}
@Test
fun getOrNullReturnsDataForSuccess() {
val result: Result<String> = Result.Success("data")
assertEquals("data", result.getOrNull())
}
@Test
fun getOrNullReturnsNullForError() {
val result: Result<String> = Result.Error(message = "fail")
assertNull(result.getOrNull())
}
@Test
fun getOrThrowReturnsDataForSuccess() {
val result: Result<String> = Result.Success("data")
assertEquals("data", result.getOrThrow())
}
@Test
fun getOrThrowThrowsForErrorWithException() {
val original = IllegalArgumentException("bad arg")
val result: Result<String> = Result.Error(original, "bad arg")
try {
result.getOrThrow()
fail("Should have thrown")
} catch (e: Exception) {
assertSame(original, e)
}
}
@Test
fun getOrThrowThrowsForLoading() {
val result: Result<String> = Result.Loading
try {
result.getOrThrow()
fail("Should have thrown")
} catch (e: IllegalStateException) {
assertEquals("Result is still loading", e.message)
}
}
}

View file

@ -0,0 +1,10 @@
package com.librechat.android.core.common.di
import com.librechat.android.core.common.network.ConnectivityObserver
import com.librechat.android.core.common.network.IosConnectivityObserver
import org.koin.core.module.Module
import org.koin.dsl.module
actual val commonPlatformModule: Module = module {
single<ConnectivityObserver> { IosConnectivityObserver() }
}

View file

@ -0,0 +1,6 @@
package com.librechat.android.core.common.di
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default

View file

@ -0,0 +1,33 @@
package com.librechat.android.core.common.extensions
import platform.Foundation.NSCalendar
import platform.Foundation.NSDateComponents
import platform.Foundation.NSDateFormatter
import platform.Foundation.NSLocale
import platform.Foundation.currentLocale
actual fun formatMonthYear(month: Int, year: Int): String {
val formatter = NSDateFormatter()
formatter.locale = NSLocale.currentLocale
formatter.dateFormat = "MMMM yyyy"
val components = NSDateComponents()
components.month = month.toLong()
components.year = year.toLong()
components.day = 1
val calendar = NSCalendar.currentCalendar
val date = calendar.dateFromComponents(components) ?: return ""
return formatter.stringFromDate(date)
}
actual fun formatMonthAbbrev(month: Int): String {
val formatter = NSDateFormatter()
formatter.locale = NSLocale.currentLocale
formatter.dateFormat = "MMM"
val components = NSDateComponents()
components.month = month.toLong()
components.year = 2000
components.day = 1
val calendar = NSCalendar.currentCalendar
val date = calendar.dateFromComponents(components) ?: return ""
return formatter.stringFromDate(date)
}

View file

@ -0,0 +1,32 @@
package com.librechat.android.core.common.network
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import platform.Network.nw_path_get_status
import platform.Network.nw_path_monitor_cancel
import platform.Network.nw_path_monitor_create
import platform.Network.nw_path_monitor_set_queue
import platform.Network.nw_path_monitor_set_update_handler
import platform.Network.nw_path_monitor_start
import platform.Network.nw_path_status_satisfied
import platform.darwin.dispatch_queue_create
@OptIn(ExperimentalForeignApi::class)
class IosConnectivityObserver : ConnectivityObserver {
override val isConnected: Flow<Boolean> = callbackFlow {
val monitor = nw_path_monitor_create()
val queue = dispatch_queue_create("com.librechat.network.monitor", null)
nw_path_monitor_set_update_handler(monitor) { path ->
val connected = nw_path_get_status(path) == nw_path_status_satisfied
trySend(connected)
}
nw_path_monitor_set_queue(monitor, queue)
nw_path_monitor_start(monitor)
awaitClose { nw_path_monitor_cancel(monitor) }
}.distinctUntilChanged()
}

View file

@ -1,26 +0,0 @@
package com.librechat.android.core.common.extensions
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
fun String.toInstantOrNull(): Instant? =
try { Instant.parse(this) } catch (_: Exception) { null }
fun Instant.toRelativeDateGroup(): String {
val today = LocalDate.now()
val date = atZone(ZoneId.systemDefault()).toLocalDate()
val daysBetween = ChronoUnit.DAYS.between(date, today)
return when {
daysBetween == 0L -> "Today"
daysBetween == 1L -> "Yesterday"
daysBetween <= 7L -> "Previous 7 Days"
daysBetween <= 30L -> "Previous 30 Days"
else -> date.format(DateTimeFormatter.ofPattern("MMMM yyyy"))
}
}
fun Long.toRelativeDateGroup(): String =
Instant.ofEpochMilli(this).toRelativeDateGroup()

View file

@ -1,86 +0,0 @@
package com.librechat.android.core.common.result
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SafeApiCallTest {
@Test
fun `safeApiCall returns Success on successful block`() = runTest {
val result = safeApiCall { "hello" }
assertThat(result).isInstanceOf(Result.Success::class.java)
assertThat((result as Result.Success).data).isEqualTo("hello")
}
@Test
fun `safeApiCall returns Error on exception`() = runTest {
val result = safeApiCall<String> { throw IllegalStateException("boom") }
assertThat(result).isInstanceOf(Result.Error::class.java)
val error = result as Result.Error
assertThat(error.message).isEqualTo("boom")
assertThat(error.exception).isInstanceOf(IllegalStateException::class.java)
}
@Test
fun `isSuccess returns true for Success`() {
val result: Result<String> = Result.Success("data")
assertThat(result.isSuccess()).isTrue()
assertThat(result.isError()).isFalse()
assertThat(result.isLoading()).isFalse()
}
@Test
fun `isError returns true for Error`() {
val result: Result<String> = Result.Error(message = "fail")
assertThat(result.isError()).isTrue()
assertThat(result.isSuccess()).isFalse()
}
@Test
fun `isLoading returns true for Loading`() {
val result: Result<String> = Result.Loading
assertThat(result.isLoading()).isTrue()
}
@Test
fun `getOrNull returns data for Success`() {
val result: Result<String> = Result.Success("data")
assertThat(result.getOrNull()).isEqualTo("data")
}
@Test
fun `getOrNull returns null for Error`() {
val result: Result<String> = Result.Error(message = "fail")
assertThat(result.getOrNull()).isNull()
}
@Test
fun `getOrThrow returns data for Success`() {
val result: Result<String> = Result.Success("data")
assertThat(result.getOrThrow()).isEqualTo("data")
}
@Test
fun `getOrThrow throws for Error with exception`() {
val original = IllegalArgumentException("bad arg")
val result: Result<String> = Result.Error(original, "bad arg")
try {
result.getOrThrow()
assertThat(false).isTrue() // Should not reach here
} catch (e: Exception) {
assertThat(e).isSameInstanceAs(original)
}
}
@Test
fun `getOrThrow throws for Loading`() {
val result: Result<String> = Result.Loading
try {
result.getOrThrow()
assertThat(false).isTrue() // Should not reach here
} catch (e: IllegalStateException) {
assertThat(e.message).isEqualTo("Result is still loading")
}
}
}

View file

@ -1,7 +1,7 @@
plugins {
id("librechat.android.library")
id("librechat.android.koin")
id("librechat.android.room")
id("librechat.kmp.library")
id("librechat.kmp.koin")
id("librechat.kmp.room")
id("librechat.kotlin.serialization")
}
@ -9,14 +9,34 @@ android {
namespace = "com.librechat.android.core.data"
}
dependencies {
implementation(project(":core:network"))
implementation(project(":core:model"))
implementation(project(":core:common"))
implementation(libs.bundles.room)
implementation(libs.datastore.preferences)
implementation(libs.security.crypto)
implementation(libs.bundles.ktor)
implementation(libs.kotlinx.serialization.json)
implementation(libs.timber)
kotlin {
sourceSets {
commonMain.dependencies {
implementation(project(":core:network"))
implementation(project(":core:model"))
implementation(project(":core:common"))
implementation(libs.datastore.preferences)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
implementation(libs.kermit)
}
androidMain.dependencies {
implementation(libs.koin.android)
implementation(libs.security.crypto)
}
named("androidUnitTest").dependencies {
implementation(libs.koin.test)
}
named("androidInstrumentedTest").dependencies {
implementation(libs.room.testing)
implementation(libs.truth)
implementation(libs.coroutines.test)
implementation(libs.android.test.runner)
implementation(libs.android.test.ext.junit)
}
}
}

View file

@ -0,0 +1,261 @@
package com.librechat.android.core.data.db
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Validates Room auto-migrations (v1v2v3) preserve all entity data
* including complex JSON-serialized fields.
*
* Strategy: Create a v1 database, populate all 6 v1 entity types with
* realistic data (including JSON columns), run migrations to v3,
* verify every row and field survived intact.
*/
@RunWith(AndroidJUnit4::class)
class RoomMigrationTest {
private val testDbName = "migration-test"
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
LibreChatDatabase::class.java,
)
@Test
fun migrateV1ToV3_allEntitiesPreserved() {
// --- Create v1 database with all 6 entity types ---
val db = helper.createDatabase(testDbName, 1)
// 1. Conversation with JSON tags and modelParams
db.insert(
"conversations",
SQLiteDatabase.CONFLICT_REPLACE,
ContentValues().apply {
put("conversationId", "conv-001")
put("title", "Migration Test Chat")
put("user", "user-001")
put("endpoint", "openAI")
put("endpointType", "custom")
put("model", "gpt-4o")
put("agentId", "agent-001")
put("isArchived", 0)
put("tags", """["important","work"]""")
put("iconURL", "https://example.com/icon.png")
put("greeting", "Hello!")
put("modelParams", """{"temperature":0.7,"maxTokens":4096}""")
put("createdAt", 1700000000000L)
put("updatedAt", 1700001000000L)
put("lastSyncedAt", 1700000500000L)
},
)
// 2. Message with JSON content, files, attachments, feedback, metadata
db.insert(
"messages",
SQLiteDatabase.CONFLICT_REPLACE,
ContentValues().apply {
put("messageId", "msg-001")
put("conversationId", "conv-001")
put("parentMessageId", "msg-000")
put("sender", "Assistant")
put("text", "Hello from migration test!")
put("content", """[{"type":"text","text":"Hello"},{"type":"image_file","image_file":{"file_id":"f1"}}]""")
put("isCreatedByUser", 0)
put("model", "gpt-4o")
put("endpoint", "openAI")
put("iconURL", null as String?)
put("unfinished", 0)
put("error", 0)
put("finishReason", "stop")
put("tokenCount", 42)
put("feedback", """{"rating":"thumbsUp","comment":"Great response"}""")
put("files", """[{"file_id":"f1","filename":"test.png","type":"image/png"}]""")
put("attachments", """[{"type":"file","url":"https://example.com/doc.pdf"}]""")
put("metadata", """{"plugins":[],"finish_reason":"stop"}""")
put("createdAt", 1700000100000L)
put("updatedAt", 1700000200000L)
},
)
// 3. File entity
db.insert(
"files",
SQLiteDatabase.CONFLICT_REPLACE,
ContentValues().apply {
put("fileId", "file-001")
put("user", "user-001")
put("conversationId", "conv-001")
put("messageId", "msg-001")
put("filename", "screenshot.png")
put("filepath", "/uploads/user-001/screenshot.png")
put("type", "image/png")
put("bytes", 102400L)
put("source", "local")
put("width", 1920)
put("height", 1080)
put("createdAt", 1700000100000L)
put("updatedAt", 1700000100000L)
},
)
// 4. Agent with JSON tools and conversationStarters
db.insert(
"agents",
SQLiteDatabase.CONFLICT_REPLACE,
ContentValues().apply {
put("id", "agent-001")
put("name", "Code Helper")
put("description", "Helps with code")
put("avatar", """{"filepath":"/images/agent.png","source":"local"}""")
put("provider", "openAI")
put("model", "gpt-4o")
put("category", "coding")
put("authorName", "test-user")
put("isPromoted", 1)
put("conversationStarters", """["Write a function","Debug this code","Explain this pattern"]""")
put("tools", """["code_interpreter","file_search","dalle"]""")
put("updatedAt", 1700000000000L)
},
)
// 5. Preset with JSON params
// Note: "order" is a SQL reserved word, so use execSQL with backtick-quoted column
db.execSQL(
"""INSERT INTO presets (presetId, title, endpoint, model, isDefault, `order`, params, createdAt, updatedAt)
VALUES ('preset-001', 'Creative Writing', 'openAI', 'gpt-4o', 1, 0,
'{"temperature":0.9,"topP":0.95,"maxTokens":8192}', 1700000000000, 1700000000000)""",
)
// 6. Conversation tag
db.insert(
"conversation_tags",
SQLiteDatabase.CONFLICT_REPLACE,
ContentValues().apply {
put("tag", "important")
put("user", "user-001")
put("description", "Important conversations")
put("count", 5)
put("position", 0)
put("createdAt", 1700000000000L)
put("updatedAt", 1700000000000L)
},
)
db.close()
// --- Run migrations v1 → v2 → v3 ---
val migratedDb = helper.runMigrationsAndValidate(testDbName, 3, true)
// --- Verify all data survived ---
// Conversations
migratedDb.query("SELECT * FROM conversations WHERE conversationId = 'conv-001'").use { cursor ->
assertThat(cursor.moveToFirst()).isTrue()
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("title"))).isEqualTo("Migration Test Chat")
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("endpoint"))).isEqualTo("openAI")
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("tags"))).isEqualTo("""["important","work"]""")
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("modelParams"))).isEqualTo("""{"temperature":0.7,"maxTokens":4096}""")
assertThat(cursor.getLong(cursor.getColumnIndexOrThrow("createdAt"))).isEqualTo(1700000000000L)
}
// Messages (with all JSON fields)
migratedDb.query("SELECT * FROM messages WHERE messageId = 'msg-001'").use { cursor ->
assertThat(cursor.moveToFirst()).isTrue()
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("text"))).isEqualTo("Hello from migration test!")
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("content"))).contains("image_file")
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("feedback"))).contains("thumbsUp")
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("files"))).contains("test.png")
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("attachments"))).contains("doc.pdf")
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("metadata"))).contains("finish_reason")
assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("tokenCount"))).isEqualTo(42)
}
// Files
migratedDb.query("SELECT * FROM files WHERE fileId = 'file-001'").use { cursor ->
assertThat(cursor.moveToFirst()).isTrue()
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("filename"))).isEqualTo("screenshot.png")
assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("width"))).isEqualTo(1920)
assertThat(cursor.getLong(cursor.getColumnIndexOrThrow("bytes"))).isEqualTo(102400L)
}
// Agents (with JSON fields)
migratedDb.query("SELECT * FROM agents WHERE id = 'agent-001'").use { cursor ->
assertThat(cursor.moveToFirst()).isTrue()
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("name"))).isEqualTo("Code Helper")
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("avatar"))).contains("filepath")
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("tools"))).contains("code_interpreter")
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("conversationStarters"))).contains("Debug this code")
}
// Presets (with JSON params)
migratedDb.query("SELECT * FROM presets WHERE presetId = 'preset-001'").use { cursor ->
assertThat(cursor.moveToFirst()).isTrue()
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("title"))).isEqualTo("Creative Writing")
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("params"))).contains("temperature")
}
// Conversation tags
migratedDb.query("SELECT * FROM conversation_tags WHERE tag = 'important'").use { cursor ->
assertThat(cursor.moveToFirst()).isTrue()
assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("count"))).isEqualTo(5)
}
// v1→v2 added drafts table — verify it exists and is writable
migratedDb.execSQL(
"INSERT INTO drafts (conversation_id, text, updated_at) VALUES ('conv-001', 'Draft text', 1700002000000)",
)
migratedDb.query("SELECT * FROM drafts WHERE conversation_id = 'conv-001'").use { cursor ->
assertThat(cursor.moveToFirst()).isTrue()
assertThat(cursor.getString(cursor.getColumnIndexOrThrow("text"))).isEqualTo("Draft text")
}
migratedDb.close()
}
@Test
fun migrateV1ToV3_viaRoomApi_entitiesReadable() {
// Create and populate a v1 database
val db = helper.createDatabase(testDbName, 1)
db.insert(
"conversations",
SQLiteDatabase.CONFLICT_REPLACE,
ContentValues().apply {
put("conversationId", "conv-api-test")
put("title", "Room API Test")
put("user", "user-001")
put("isArchived", 0)
put("tags", "[]")
put("createdAt", 1700000000000L)
put("updatedAt", 1700000000000L)
put("lastSyncedAt", 0L)
},
)
db.close()
// Open with Room (auto-runs migrations)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val roomDb = Room.databaseBuilder(
context,
LibreChatDatabase::class.java,
testDbName,
).build()
// Verify we can read the migrated data via DAO
val conversation = kotlinx.coroutines.runBlocking {
roomDb.conversationDao().getById("conv-api-test")
}
assertThat(conversation).isNotNull()
assertThat(conversation!!.title).isEqualTo("Room API Test")
roomDb.close()
}
}

View file

@ -0,0 +1,58 @@
package com.librechat.android.core.data.datastore
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import io.ktor.client.HttpClient
import java.security.KeyStoreException
class TokenDataStore(
context: Context,
refreshClient: Lazy<HttpClient>,
) : CommonTokenDataStore(refreshClient) {
private val masterKey: MasterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
init {
initializeTokenCache()
}
override fun readAccessToken(): String? = prefs.getString(KEY_ACCESS_TOKEN, null)
override fun readRefreshToken(): String? = prefs.getString(KEY_REFRESH_TOKEN, null)
override fun writeTokens(accessToken: String, refreshToken: String) {
prefs.edit()
.putString(KEY_ACCESS_TOKEN, accessToken)
.putString(KEY_REFRESH_TOKEN, refreshToken)
.apply()
}
override fun removeTokens() {
prefs.edit()
.remove(KEY_ACCESS_TOKEN)
.remove(KEY_REFRESH_TOKEN)
.apply()
}
override fun onKeystoreCorruption() {
removeTokens()
}
override fun isKeystoreException(e: Exception): Boolean = e is KeyStoreException
companion object {
private const val PREFS_NAME = "librechat_tokens"
}
}

View file

@ -0,0 +1,44 @@
package com.librechat.android.core.data.di
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import androidx.room.Room
import com.librechat.android.core.common.di.KoinQualifiers
import com.librechat.android.core.data.datastore.TokenDataStore
import com.librechat.android.core.data.db.LibreChatDatabase
import com.librechat.android.core.data.repository.CommonSessionCacheCleaner
import com.librechat.android.core.data.repository.SessionCacheCleaner
import com.librechat.android.core.network.client.SecureTokenStorage
import com.librechat.android.core.network.client.TokenManager
import io.ktor.client.HttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.Module
import org.koin.dsl.binds
import org.koin.dsl.module
private val android.content.Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = DATASTORE_FILE_NAME,
)
actual val dataPlatformModule: Module = module {
// --- Database ---
single {
Room.databaseBuilder(androidContext(), LibreChatDatabase::class.java, "librechat.db").build()
}
// --- DataStore ---
single<DataStore<Preferences>> { androidContext().settingsDataStore }
// --- Token Storage ---
single {
TokenDataStore(
context = androidContext(),
refreshClient = lazy(LazyThreadSafetyMode.NONE) { get<HttpClient>(KoinQualifiers.Refresh) },
)
} binds arrayOf(TokenManager::class, SecureTokenStorage::class)
// --- Session Cache Cleaner ---
single<SessionCacheCleaner> { CommonSessionCacheCleaner(androidContext().cacheDir.absolutePath) }
}

View file

@ -0,0 +1,7 @@
package com.librechat.android.core.data.repository
import java.io.File
internal actual fun deleteDirectoryRecursively(path: String) {
File(path).deleteRecursively()
}

View file

@ -0,0 +1,237 @@
package com.librechat.android.core.data.datastore
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
/**
* Verifies DataStore preference round-trips after KMP migration.
* Each test writes preferences via the DataStore class, then reads
* them back and asserts the values match.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class DataStoreRoundTripTest {
@get:Rule
val tmpFolder = TemporaryFolder()
private val testDispatcher = UnconfinedTestDispatcher()
private fun createDataStore(name: String): DataStore<Preferences> {
return PreferenceDataStoreFactory.create {
File(tmpFolder.root, "$name.preferences_pb")
}
}
// --- ServerDataStore ---
@Test
fun serverDataStore_setAndGetUrl() = runTest(testDispatcher) {
val ds = createDataStore("server")
val store = ServerDataStore(
dataStore = ds,
ioDispatcher = testDispatcher,
)
assertThat(store.getBaseUrl()).isEmpty()
store.setServerUrl("https://chat.example.com")
assertThat(store.getBaseUrl()).isEqualTo("https://chat.example.com")
}
@Test
fun serverDataStore_trimsTrailingSlash() = runTest(testDispatcher) {
val ds = createDataStore("server-slash")
val store = ServerDataStore(
dataStore = ds,
ioDispatcher = testDispatcher,
)
store.setServerUrl("https://chat.example.com/")
assertThat(store.getBaseUrl()).isEqualTo("https://chat.example.com")
}
@Test
fun serverDataStore_hasServerUrl() = runTest(testDispatcher) {
val ds = createDataStore("server-has")
val store = ServerDataStore(
dataStore = ds,
ioDispatcher = testDispatcher,
)
assertThat(store.hasServerUrl().first()).isFalse()
store.setServerUrl("https://chat.example.com")
assertThat(store.hasServerUrl().first()).isTrue()
}
// --- ThemeDataStore ---
@Test
fun themeDataStore_defaultIsSystem() = runTest(testDispatcher) {
val ds = createDataStore("theme")
val store = ThemeDataStore(ds)
assertThat(store.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
}
@Test
fun themeDataStore_roundTrip_allModes() = runTest(testDispatcher) {
val ds = createDataStore("theme-all")
val store = ThemeDataStore(ds)
store.setThemeMode(ThemeMode.LIGHT)
assertThat(store.themeMode.first()).isEqualTo(ThemeMode.LIGHT)
store.setThemeMode(ThemeMode.DARK)
assertThat(store.themeMode.first()).isEqualTo(ThemeMode.DARK)
store.setThemeMode(ThemeMode.SYSTEM)
assertThat(store.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
}
// --- SettingsDataStore ---
@Test
fun settingsDataStore_defaults() = runTest(testDispatcher) {
val ds = createDataStore("settings")
val store = SettingsDataStore(ds)
assertThat(store.autoScrollEnabled.first()).isTrue()
assertThat(store.showThinkingBlocks.first()).isTrue()
assertThat(store.autoReadEnabled.first()).isFalse()
assertThat(store.dismissKeyboardOnSend.first()).isTrue()
assertThat(store.chatFontSize.first()).isEqualTo(ChatFontSize.MEDIUM)
assertThat(store.latexRenderer.first()).isEqualTo(LatexRenderer.KATEX)
assertThat(store.showAvatars.first()).isTrue()
assertThat(store.showBubbles.first()).isFalse()
}
@Test
fun settingsDataStore_roundTrip_booleans() = runTest(testDispatcher) {
val ds = createDataStore("settings-bool")
val store = SettingsDataStore(ds)
store.setAutoScrollEnabled(false)
store.setShowThinkingBlocks(false)
store.setAutoReadEnabled(true)
store.setDismissKeyboardOnSend(false)
store.setShowImageDescriptions(true)
assertThat(store.autoScrollEnabled.first()).isFalse()
assertThat(store.showThinkingBlocks.first()).isFalse()
assertThat(store.autoReadEnabled.first()).isTrue()
assertThat(store.dismissKeyboardOnSend.first()).isFalse()
assertThat(store.showImageDescriptions.first()).isTrue()
}
@Test
fun settingsDataStore_roundTrip_enums() = runTest(testDispatcher) {
val ds = createDataStore("settings-enums")
val store = SettingsDataStore(ds)
store.setLatexRenderer(LatexRenderer.NATIVE)
assertThat(store.latexRenderer.first()).isEqualTo(LatexRenderer.NATIVE)
store.setChatFontSize(ChatFontSize.LARGE)
assertThat(store.chatFontSize.first()).isEqualTo(ChatFontSize.LARGE)
store.setChatFontSize(ChatFontSize.SMALL)
assertThat(store.chatFontSize.first()).isEqualTo(ChatFontSize.SMALL)
}
@Test
fun settingsDataStore_roundTrip_strings() = runTest(testDispatcher) {
val ds = createDataStore("settings-strings")
val store = SettingsDataStore(ds)
store.setLastUsedModel("anthropic", "claude-3.5-sonnet")
assertThat(store.lastUsedEndpoint.first()).isEqualTo("anthropic")
assertThat(store.lastUsedModel.first()).isEqualTo("claude-3.5-sonnet")
store.setSelectedVoiceId("voice-en-us-001")
assertThat(store.selectedVoiceId.first()).isEqualTo("voice-en-us-001")
}
@Test
fun settingsDataStore_roundTrip_floats() = runTest(testDispatcher) {
val ds = createDataStore("settings-floats")
val store = SettingsDataStore(ds)
store.setTtsSpeechRate(1.5f)
store.setTtsPitch(0.8f)
assertThat(store.ttsSpeechRate.first()).isEqualTo(1.5f)
assertThat(store.ttsPitch.first()).isEqualTo(0.8f)
}
@Test
fun settingsDataStore_roundTrip_bookmarks() = runTest(testDispatcher) {
val ds = createDataStore("settings-bookmarks")
val store = SettingsDataStore(ds)
assertThat(store.bookmarkedConversationIds.first()).isEmpty()
store.toggleBookmark("conv-001")
assertThat(store.bookmarkedConversationIds.first()).containsExactly("conv-001")
store.toggleBookmark("conv-002")
assertThat(store.bookmarkedConversationIds.first()).containsExactly("conv-001", "conv-002")
// Toggle off
store.toggleBookmark("conv-001")
assertThat(store.bookmarkedConversationIds.first()).containsExactly("conv-002")
}
@Test
fun settingsDataStore_roundTrip_mcpServers() = runTest(testDispatcher) {
val ds = createDataStore("settings-mcp")
val store = SettingsDataStore(ds)
store.setSelectedMcpServers(setOf("server-a", "server-b"))
assertThat(store.selectedMcpServers.first()).containsExactly("server-a", "server-b")
store.setSelectedMcpServers(emptySet())
assertThat(store.selectedMcpServers.first()).isEmpty()
}
// --- ConfigCacheDataStore ---
@Test
fun configCacheDataStore_roundTrip_availableModels() = runTest(testDispatcher) {
val ds = createDataStore("config-cache")
val json = Json { ignoreUnknownKeys = true; isLenient = true }
val store = ConfigCacheDataStore(ds, json)
val models = mapOf(
"openAI" to listOf("gpt-4o", "gpt-4o-mini"),
"anthropic" to listOf("claude-3.5-sonnet", "claude-3-haiku"),
)
store.saveAvailableModels(models)
val loaded = store.loadAvailableModels()
assertThat(loaded).isNotNull()
assertThat(loaded!!["openAI"]).containsExactly("gpt-4o", "gpt-4o-mini")
assertThat(loaded["anthropic"]).containsExactly("claude-3.5-sonnet", "claude-3-haiku")
}
@Test
fun configCacheDataStore_returnsNullWhenEmpty() = runTest(testDispatcher) {
val ds = createDataStore("config-empty")
val json = Json { ignoreUnknownKeys = true }
val store = ConfigCacheDataStore(ds, json)
assertThat(store.loadStartupConfig()).isNull()
assertThat(store.loadEndpointConfigs()).isNull()
assertThat(store.loadAvailableModels()).isNull()
}
}

View file

@ -0,0 +1,137 @@
package com.librechat.android.core.data.datastore
import co.touchlab.kermit.Logger
import com.librechat.android.core.model.response.RefreshResponse
import com.librechat.android.core.network.client.CookieHelper
import com.librechat.android.core.network.client.SecureTokenStorage
import com.librechat.android.core.network.client.TokenManager
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.concurrent.Volatile
abstract class CommonTokenDataStore(
private val refreshClient: Lazy<HttpClient>,
) : TokenManager, SecureTokenStorage {
@Volatile
private var cachedAccessToken: String? = null
private var tokenInitialized = false
/**
* Eagerly load tokens from platform storage into the in-memory cache.
* Must be called from each platform subclass's `init {}` block (not from
* the super constructor, which runs before subclass properties are initialised).
*/
protected fun initializeTokenCache() {
cachedAccessToken = readAccessToken()
tokenInitialized = true
}
private fun ensureTokenLoaded(): String? {
if (!tokenInitialized) {
cachedAccessToken = readAccessToken()
tokenInitialized = true
}
return cachedAccessToken
}
private val refreshMutex = Mutex()
private val _sessionExpired = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
override val sessionExpiredFlow: SharedFlow<Unit> = _sessionExpired.asSharedFlow()
// Platform implementations provide these
protected abstract fun readAccessToken(): String?
protected abstract fun readRefreshToken(): String?
protected abstract fun writeTokens(accessToken: String, refreshToken: String)
protected abstract fun removeTokens()
protected abstract fun onKeystoreCorruption()
// --- TokenManager ---
override val isAuthenticated: Boolean
get() = ensureTokenLoaded() != null
override suspend fun getAccessToken(): String? = ensureTokenLoaded()
override suspend fun setTokens(accessToken: String, refreshToken: String) {
cachedAccessToken = accessToken
writeTokens(accessToken, refreshToken)
}
override suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock {
val storedRefreshToken = readRefreshToken()
if (storedRefreshToken.isNullOrBlank()) {
Logger.w { "No refresh token available" }
return false
}
return try {
val httpResponse: HttpResponse = refreshClient.value
.post("/api/auth/refresh") {
header("Cookie", "refreshToken=$storedRefreshToken")
setBody(mapOf("refreshToken" to storedRefreshToken))
}
val body: RefreshResponse = httpResponse.body()
val newRefreshToken = CookieHelper.extractRefreshToken(httpResponse.headers)
?: storedRefreshToken
setTokens(body.token, newRefreshToken)
Logger.d { "Token refreshed successfully" }
true
} catch (e: ClientRequestException) {
Logger.w(e) { "Auth error during token refresh (status=${e.response.status})" }
clearTokens()
false
} catch (e: Exception) {
if (isKeystoreException(e)) {
Logger.e(e) { "Keystore corruption—clearing tokens" }
onKeystoreCorruption()
clearTokens()
} else {
Logger.w(e) { "Error during token refresh" }
}
false
}
}
override suspend fun clearTokens() {
cachedAccessToken = null
removeTokens()
}
override fun emitSessionExpired() {
_sessionExpired.tryEmit(Unit)
}
// --- SecureTokenStorage ---
override suspend fun getRefreshToken(): String? = readRefreshToken()
override suspend fun storeTokens(accessToken: String, refreshToken: String) {
setTokens(accessToken, refreshToken)
}
override suspend fun clearAll() {
clearTokens()
}
protected open fun isKeystoreException(e: Exception): Boolean = false
companion object {
const val KEY_ACCESS_TOKEN = "access_token"
const val KEY_REFRESH_TOKEN = "refresh_token"
}
}

View file

@ -11,7 +11,7 @@ import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import timber.log.Timber
import co.touchlab.kermit.Logger
class ConfigCacheDataStore(
private val dataStore: DataStore<Preferences>,
@ -23,7 +23,7 @@ class ConfigCacheDataStore(
val serialized = json.encodeToString(StartupConfig.serializer(), config)
dataStore.edit { prefs -> prefs[KEY_STARTUP_CONFIG] = serialized }
} catch (e: Exception) {
Timber.w(e, "Failed to cache startup config")
Logger.w(e) { "Failed to cache startup config" }
}
}
@ -33,7 +33,7 @@ class ConfigCacheDataStore(
val serialized = prefs[KEY_STARTUP_CONFIG] ?: return null
json.decodeFromString(StartupConfig.serializer(), serialized)
} catch (e: Exception) {
Timber.w(e, "Failed to load cached startup config")
Logger.w(e) { "Failed to load cached startup config" }
null
}
}
@ -44,7 +44,7 @@ class ConfigCacheDataStore(
val serialized = json.encodeToString(serializer, endpoints)
dataStore.edit { prefs -> prefs[KEY_ENDPOINT_CONFIGS] = serialized }
} catch (e: Exception) {
Timber.w(e, "Failed to cache endpoint configs")
Logger.w(e) { "Failed to cache endpoint configs" }
}
}
@ -55,7 +55,7 @@ class ConfigCacheDataStore(
val serializer = MapSerializer(String.serializer(), EndpointConfig.serializer())
json.decodeFromString(serializer, serialized)
} catch (e: Exception) {
Timber.w(e, "Failed to load cached endpoint configs")
Logger.w(e) { "Failed to load cached endpoint configs" }
null
}
}
@ -69,7 +69,7 @@ class ConfigCacheDataStore(
val serialized = json.encodeToString(serializer, models)
dataStore.edit { prefs -> prefs[KEY_AVAILABLE_MODELS] = serialized }
} catch (e: Exception) {
Timber.w(e, "Failed to cache available models")
Logger.w(e) { "Failed to cache available models" }
}
}
@ -83,7 +83,7 @@ class ConfigCacheDataStore(
)
json.decodeFromString(serializer, serialized)
} catch (e: Exception) {
Timber.w(e, "Failed to load cached available models")
Logger.w(e) { "Failed to load cached available models" }
null
}
}

View file

@ -0,0 +1,88 @@
package com.librechat.android.core.data.datastore
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.librechat.android.core.network.client.ServerUrlProvider
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
/**
* Optional fallback for reading/writing the server URL in a location
* that survives app reinstall (e.g. iOS Keychain). On platforms without
* a fallback (Android), pass null.
*/
interface ServerUrlKeychainFallback {
fun readServerUrl(): String?
fun writeServerUrl(url: String)
fun removeServerUrl()
}
class ServerDataStore(
private val dataStore: DataStore<Preferences>,
ioDispatcher: CoroutineDispatcher,
private val keychainFallback: ServerUrlKeychainFallback? = null,
) : ServerUrlProvider {
private val _currentUrl = MutableStateFlow("")
val currentUrlFlow: Flow<String> = _currentUrl
init {
// Load the persisted URL synchronously so getBaseUrl() is ready
// immediately after construction. This is a singleton created once
// at app startup, so a brief block is acceptable.
var url = runBlocking(ioDispatcher) {
dataStore.data
.map { prefs -> prefs[KEY_SERVER_URL].orEmpty() }
.first()
}
// If DataStore is empty (e.g. after reinstall), try the Keychain fallback.
// Keychain items persist across iOS app reinstalls.
if (url.isBlank() && keychainFallback != null) {
val restored = keychainFallback.readServerUrl()
if (!restored.isNullOrBlank()) {
url = restored
// Re-persist to DataStore so subsequent reads don't need the fallback
runBlocking(ioDispatcher) {
dataStore.edit { prefs -> prefs[KEY_SERVER_URL] = restored }
}
}
}
_currentUrl.value = url
}
override fun getBaseUrl(): String = _currentUrl.value
fun hasServerUrl(): Flow<Boolean> =
dataStore.data.map { prefs ->
!prefs[KEY_SERVER_URL].isNullOrBlank()
}
suspend fun setServerUrl(url: String) {
val trimmed = url.trimEnd('/')
_currentUrl.value = trimmed
dataStore.edit { prefs ->
prefs[KEY_SERVER_URL] = trimmed
}
// Mirror to Keychain so the URL survives app reinstall on iOS
keychainFallback?.writeServerUrl(trimmed)
}
/**
* Clear the server URL from both DataStore and Keychain fallback.
*/
suspend fun clearServerUrl() {
_currentUrl.value = ""
dataStore.edit { prefs -> prefs.remove(KEY_SERVER_URL) }
keychainFallback?.removeServerUrl()
}
companion object {
private val KEY_SERVER_URL = stringPreferencesKey("server_url")
}
}

View file

@ -4,8 +4,12 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
enum class ThemeMode {
SYSTEM, LIGHT, DARK
@ -15,12 +19,16 @@ class ThemeDataStore(
private val dataStore: DataStore<Preferences>,
) {
/**
* Synchronously loaded initial value so the first compose frame uses the correct theme
* and avoids a flash of light mode when the user has dark mode saved.
*/
val initialThemeMode: ThemeMode = runBlocking(Dispatchers.IO) {
dataStore.data.map { prefs -> prefs[KEY_THEME].toThemeMode() }.first()
}
val themeMode: Flow<ThemeMode> = dataStore.data.map { prefs ->
when (prefs[KEY_THEME]) {
"light" -> ThemeMode.LIGHT
"dark" -> ThemeMode.DARK
else -> ThemeMode.SYSTEM
}
prefs[KEY_THEME].toThemeMode()
}
suspend fun setThemeMode(mode: ThemeMode) {
@ -35,5 +43,11 @@ class ThemeDataStore(
companion object {
private val KEY_THEME = stringPreferencesKey("theme_mode")
private fun String?.toThemeMode(): ThemeMode = when (this) {
"light" -> ThemeMode.LIGHT
"dark" -> ThemeMode.DARK
else -> ThemeMode.SYSTEM
}
}
}

View file

@ -1,8 +1,10 @@
package com.librechat.android.core.data.db
import androidx.room.AutoMigration
import androidx.room.ConstructedBy
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.RoomDatabaseConstructor
import androidx.room.TypeConverters
import com.librechat.android.core.data.db.converter.Converters
import com.librechat.android.core.data.db.dao.AgentDao
@ -38,6 +40,7 @@ import com.librechat.android.core.data.db.entity.PresetEntity
],
)
@TypeConverters(Converters::class)
@ConstructedBy(LibreChatDatabaseConstructor::class)
abstract class LibreChatDatabase : RoomDatabase() {
abstract fun conversationDao(): ConversationDao
abstract fun messageDao(): MessageDao
@ -47,3 +50,7 @@ abstract class LibreChatDatabase : RoomDatabase() {
abstract fun conversationTagDao(): ConversationTagDao
abstract fun draftDao(): DraftDao
}
// Room KSP auto-generates the actual implementations for each platform
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object LibreChatDatabaseConstructor : RoomDatabaseConstructor<LibreChatDatabase>

View file

@ -3,6 +3,7 @@ package com.librechat.android.core.data.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlin.time.Clock
@Entity(tableName = "drafts")
data class DraftEntity(
@ -12,5 +13,5 @@ data class DraftEntity(
@ColumnInfo(name = "text")
val text: String,
@ColumnInfo(name = "updated_at")
val updatedAt: Long = System.currentTimeMillis(),
val updatedAt: Long = Clock.System.now().toEpochMilliseconds(),
)

View file

@ -1,16 +1,11 @@
package com.librechat.android.core.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import androidx.room.Room
import com.librechat.android.core.common.di.KoinQualifiers
import com.librechat.android.core.data.datastore.ConfigCacheDataStore
import com.librechat.android.core.data.datastore.ServerDataStore
import com.librechat.android.core.data.datastore.ServerUrlKeychainFallback
import com.librechat.android.core.data.datastore.SettingsDataStore
import com.librechat.android.core.data.datastore.ThemeDataStore
import com.librechat.android.core.data.datastore.TokenDataStore
import com.librechat.android.core.data.db.LibreChatDatabase
import com.librechat.android.core.data.repository.AgentRepository
import com.librechat.android.core.data.repository.AgentRepositoryImpl
@ -46,6 +41,7 @@ import com.librechat.android.core.data.repository.PromptRepository
import com.librechat.android.core.data.repository.PromptRepositoryImpl
import com.librechat.android.core.data.repository.SearchRepository
import com.librechat.android.core.data.repository.SearchRepositoryImpl
import com.librechat.android.core.data.repository.SessionCacheCleaner
import com.librechat.android.core.data.repository.ShareRepository
import com.librechat.android.core.data.repository.ShareRepositoryImpl
import com.librechat.android.core.data.repository.SpeechRepository
@ -54,27 +50,20 @@ import com.librechat.android.core.data.repository.TagRepository
import com.librechat.android.core.data.repository.TagRepositoryImpl
import com.librechat.android.core.data.repository.UserRepository
import com.librechat.android.core.data.repository.UserRepositoryImpl
import com.librechat.android.core.network.client.SecureTokenStorage
import com.librechat.android.core.network.client.ServerUrlProvider
import com.librechat.android.core.network.client.TokenManager
import io.ktor.client.HttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.binds
import org.koin.dsl.module
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "librechat_settings",
)
expect val dataPlatformModule: Module
val dataModule = module {
// --- Database + DAOs ---
includes(dataPlatformModule)
// --- DAOs ---
single {
Room.databaseBuilder(androidContext(), LibreChatDatabase::class.java, "librechat.db").build()
}
single { get<LibreChatDatabase>().conversationDao() }
single { get<LibreChatDatabase>().messageDao() }
single { get<LibreChatDatabase>().fileDao() }
@ -83,20 +72,15 @@ val dataModule = module {
single { get<LibreChatDatabase>().conversationTagDao() }
single { get<LibreChatDatabase>().draftDao() }
// --- DataStore ---
single<DataStore<Preferences>> { androidContext().settingsDataStore }
// --- Datastores ---
single {
TokenDataStore(
context = androidContext(),
refreshClient = lazy(LazyThreadSafetyMode.NONE) { get<HttpClient>(KoinQualifiers.Refresh) },
ServerDataStore(
dataStore = get(),
ioDispatcher = get(KoinQualifiers.IO),
keychainFallback = getOrNull(),
)
} binds arrayOf(TokenManager::class, SecureTokenStorage::class)
singleOf(::ServerDataStore) bind ServerUrlProvider::class
} bind ServerUrlProvider::class
singleOf(::ThemeDataStore)
singleOf(::ConfigCacheDataStore)
singleOf(::SettingsDataStore)
@ -108,7 +92,7 @@ val dataModule = module {
authApi = get(),
userApi = get(),
tokenManager = get(),
context = androidContext(),
sessionCacheCleaner = get(),
)
}

View file

@ -0,0 +1,6 @@
package com.librechat.android.core.data.di
/**
* The base name used for the DataStore preferences file on all platforms.
*/
internal const val DATASTORE_FILE_NAME = "librechat_settings"

View file

@ -3,6 +3,8 @@ package com.librechat.android.core.data.mapper
import com.librechat.android.core.data.db.entity.ConversationEntity
import com.librechat.android.core.model.Conversation
import com.librechat.android.core.model.EModelEndpoint
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.serialization.json.Json
private val json = Json { ignoreUnknownKeys = true }
@ -51,14 +53,14 @@ fun ConversationEntity.toModel(): Conversation = Conversation(
fun List<ConversationEntity>.toModels(): List<Conversation> = map { it.toModel() }
private fun parseTimestamp(dateString: String?): Long {
if (dateString == null) return System.currentTimeMillis()
if (dateString == null) return Clock.System.now().toEpochMilliseconds()
return try {
java.time.Instant.parse(dateString).toEpochMilli()
Instant.parse(dateString).toEpochMilliseconds()
} catch (_: Exception) {
System.currentTimeMillis()
Clock.System.now().toEpochMilliseconds()
}
}
private fun formatTimestamp(epochMillis: Long): String {
return java.time.Instant.ofEpochMilli(epochMillis).toString()
return Instant.fromEpochMilliseconds(epochMillis).toString()
}

View file

@ -6,6 +6,8 @@ import com.librechat.android.core.model.Feedback
import com.librechat.android.core.model.FileReference
import com.librechat.android.core.model.Message
import com.librechat.android.core.model.MessageContentPart
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
@ -71,14 +73,14 @@ fun MessageEntity.toModel(): Message = Message(
fun List<MessageEntity>.toModels(): List<Message> = map { it.toModel() }
private fun parseTimestamp(dateString: String?): Long {
if (dateString == null) return System.currentTimeMillis()
if (dateString == null) return Clock.System.now().toEpochMilliseconds()
return try {
java.time.Instant.parse(dateString).toEpochMilli()
Instant.parse(dateString).toEpochMilliseconds()
} catch (_: Exception) {
System.currentTimeMillis()
Clock.System.now().toEpochMilliseconds()
}
}
private fun formatTimestamp(epochMillis: Long): String {
return java.time.Instant.ofEpochMilli(epochMillis).toString()
return Instant.fromEpochMilliseconds(epochMillis).toString()
}

View file

@ -1,6 +1,5 @@
package com.librechat.android.core.data.repository
import android.content.Context
import com.librechat.android.core.common.result.Result
import com.librechat.android.core.common.result.safeApiCall
import com.librechat.android.core.model.LoginOutcome
@ -9,14 +8,12 @@ import com.librechat.android.core.model.response.TwoFactorSetupResponse
import com.librechat.android.core.network.api.AuthApi
import com.librechat.android.core.network.api.UserApi
import com.librechat.android.core.network.client.TokenManager
import timber.log.Timber
import java.io.File
class AuthRepositoryImpl(
private val authApi: AuthApi,
private val userApi: UserApi,
private val tokenManager: TokenManager,
private val context: Context,
private val sessionCacheCleaner: SessionCacheCleaner,
) : AuthRepository {
override suspend fun login(email: String, password: String): Result<LoginOutcome> {
@ -56,10 +53,6 @@ class AuthRepositoryImpl(
}
}
/**
* Verify 2FA during login using temporary token.
* Uses /api/auth/2fa/verify-temp which expects { tempToken, token, backupCode? }.
*/
override suspend fun verifyTwoFactor(tempToken: String, code: String): Result<User> {
return safeApiCall {
val result = authApi.verifyTempToken(tempToken = tempToken, totpCode = code)
@ -88,28 +81,11 @@ class AuthRepositoryImpl(
authApi.logout()
} finally {
tokenManager.clearTokens()
clearSessionCaches()
sessionCacheCleaner.clearSessionCaches()
}
}
}
/**
* Clears cached images and temporary files from the previous session.
* This prevents data from one user's session persisting after logout.
*/
private fun clearSessionCaches() {
try {
// Coil image cache (memory cache clears naturally as composables leave composition)
File(context.cacheDir, "image_cache").deleteRecursively()
// Temporary artifact files
File(context.cacheDir, "artifacts").deleteRecursively()
// Shared image files
File(context.cacheDir, "shared_images").deleteRecursively()
} catch (e: Exception) {
Timber.w(e, "Failed to clear session caches on logout")
}
}
override suspend fun isLoggedIn(): Boolean {
return tokenManager.getAccessToken() != null
}

View file

@ -0,0 +1,31 @@
package com.librechat.android.core.data.repository
import co.touchlab.kermit.Logger
/**
* Subdirectories under the platform cache root that are cleared on logout.
*/
internal val CACHE_SUBDIRECTORIES = listOf("image_cache", "artifacts", "shared_images")
/**
* Deletes a directory and all its contents at the given [path].
*/
internal expect fun deleteDirectoryRecursively(path: String)
/**
* Common implementation of [SessionCacheCleaner] that iterates [CACHE_SUBDIRECTORIES]
* and deletes each from the provided [cacheRoot] directory.
*/
class CommonSessionCacheCleaner(
private val cacheRoot: String,
) : SessionCacheCleaner {
override fun clearSessionCaches() {
try {
for (subdir in CACHE_SUBDIRECTORIES) {
deleteDirectoryRecursively("$cacheRoot/$subdir")
}
} catch (e: Exception) {
Logger.w(e) { "Failed to clear session caches on logout" }
}
}
}

View file

@ -13,8 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.SerializationException
import timber.log.Timber
import java.io.IOException
import co.touchlab.kermit.Logger
class ConfigRepositoryImpl(
private val configApi: ConfigApi,
@ -51,10 +50,8 @@ class ConfigRepositoryImpl(
Result.Error(e, message)
} catch (e: HttpRequestTimeoutException) {
Result.Error(e, "Connection timed out. Check the URL and try again.")
} catch (e: IOException) {
Result.Error(e, "Could not reach the server. Check the URL and your connection.")
} catch (e: Exception) {
Result.Error(e, "This doesn't appear to be a LibreChat server")
Result.Error(e, "Could not reach the server. Check the URL and your connection.")
}
}
@ -86,7 +83,7 @@ class ConfigRepositoryImpl(
if (result is Result.Error) {
val cached = _startupConfig.value
if (cached != null) {
Timber.d("Using cached startup config (network unavailable)")
Logger.d { "Using cached startup config (network unavailable)" }
return Result.Success(cached)
}
}
@ -110,7 +107,7 @@ class ConfigRepositoryImpl(
if (result is Result.Error) {
val cached = _endpointConfigs.value
if (cached.isNotEmpty()) {
Timber.d("Using cached endpoint configs (network unavailable)")
Logger.d { "Using cached endpoint configs (network unavailable)" }
return Result.Success(cached)
}
}
@ -134,7 +131,7 @@ class ConfigRepositoryImpl(
if (result is Result.Error) {
val cached = _availableModels.value
if (cached.isNotEmpty()) {
Timber.d("Using cached models (network unavailable)")
Logger.d { "Using cached models (network unavailable)" }
return Result.Success(cached)
}
}
@ -167,14 +164,14 @@ class ConfigRepositoryImpl(
?: BackendVersion.extractVersionFromFooter(config?.customFooter)
if (detectedVersion != null) {
Timber.d("Backend version detected: %s (supported: %s)", detectedVersion, supported)
Logger.d { "Backend version detected: $detectedVersion (supported: $supported)" }
VersionCheckResult(
backendVersion = detectedVersion,
supportedVersion = supported,
isCompatible = BackendVersion.isCompatible(supported, detectedVersion),
)
} else {
Timber.d("Backend version could not be determined, skipping check")
Logger.d { "Backend version could not be determined, skipping check" }
VersionCheckResult(
backendVersion = null,
supportedVersion = supported,

View file

@ -11,7 +11,8 @@ import com.librechat.android.core.model.request.ForkConversationRequest
import com.librechat.android.core.network.api.ConversationsApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
import kotlin.time.Clock
import co.touchlab.kermit.Logger
class ConversationRepositoryImpl(
private val conversationsApi: ConversationsApi,
@ -66,7 +67,7 @@ class ConversationRepositoryImpl(
override suspend fun updateTitle(id: String, title: String): Result<Conversation> {
return safeApiCall {
val updated = conversationsApi.updateTitle(id, title)
conversationDao.updateTitle(id, title, System.currentTimeMillis())
conversationDao.updateTitle(id, title, Clock.System.now().toEpochMilliseconds())
updated
}
}
@ -75,7 +76,7 @@ class ConversationRepositoryImpl(
return safeApiCall {
val response = conversationsApi.generateTitle(conversationId)
// Update the local cache with the generated title
conversationDao.updateTitle(conversationId, response.title, System.currentTimeMillis())
conversationDao.updateTitle(conversationId, response.title, Clock.System.now().toEpochMilliseconds())
response.title
}
}
@ -83,7 +84,7 @@ class ConversationRepositoryImpl(
override suspend fun archive(id: String, isArchived: Boolean): Result<Conversation> {
return safeApiCall {
val updated = conversationsApi.archive(id, isArchived)
conversationDao.updateArchived(id, isArchived, System.currentTimeMillis())
conversationDao.updateArchived(id, isArchived, Clock.System.now().toEpochMilliseconds())
updated
}
}
@ -131,7 +132,7 @@ class ConversationRepositoryImpl(
override suspend fun importConversation(jsonContent: String): Result<Conversation> {
return safeApiCall {
val fileBytes = jsonContent.toByteArray(Charsets.UTF_8)
val fileBytes = jsonContent.encodeToByteArray()
val imported = conversationsApi.importConversations(
fileBytes = fileBytes,
filename = "conversation.json",
@ -161,7 +162,7 @@ class ConversationRepositoryImpl(
val entities = response.conversations.map { it.toEntity() }
conversationDao.upsertAll(entities)
} catch (e: Exception) {
Timber.w(e, "Failed to refresh conversations")
Logger.w(e) { "Failed to refresh conversations" }
}
}
}

Some files were not shown because too many files have changed in this diff Show more