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:
parent
1868bab690
commit
770603e466
700 changed files with 18361 additions and 4823 deletions
50
.github/workflows/android.yml
vendored
50
.github/workflows/android.yml
vendored
|
|
@ -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
|
||||
|
|
@ -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
99
CONTRIBUTING.md
Normal 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.
|
||||
|
|
@ -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+)
|
||||
|
|
|
|||
34
README.md
34
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.librechat.android.core.common.di
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
||||
expect val ioDispatcher: CoroutineDispatcher
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.librechat.android.core.common.network
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ConnectivityObserver {
|
||||
val isConnected: Flow<Boolean>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (v1→v2→v3) 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.librechat.android.core.data.repository
|
||||
|
||||
import java.io.File
|
||||
|
||||
internal actual fun deleteDirectoryRecursively(path: String) {
|
||||
File(path).deleteRecursively()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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
Loading…
Reference in a new issue