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.
|
- **`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).
|
- **`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).
|
- **`/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)
|
## Tech Stack (Mandated)
|
||||||
- **UI**: Jetpack Compose + Compose Navigation
|
- **UI**: Jetpack Compose + Compose Navigation
|
||||||
- **DI**: Hilt
|
- **DI**: Koin
|
||||||
- **Network**: Ktor Client
|
- **Network**: Ktor Client
|
||||||
- **Serialization**: Kotlinx Serialization
|
- **Serialization**: Kotlinx Serialization
|
||||||
- **Min SDK**: TBD (recommend 26+)
|
- **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.
|
- **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
|
## Building from Source
|
||||||
|
|
||||||
**Requirements:** Android Studio, JDK 17+
|
### Android
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
|
|
@ -53,14 +63,30 @@ For a release build:
|
||||||
./gradlew assembleRelease
|
./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
|
### Tech Stack
|
||||||
|
|
||||||
- Jetpack Compose + Compose Navigation
|
- Kotlin Multiplatform (KMP) with shared business logic
|
||||||
- Hilt (dependency injection)
|
- Jetpack Compose (Android) + SwiftUI (iOS)
|
||||||
|
- Koin (dependency injection)
|
||||||
- Ktor Client with OkHttp engine
|
- Ktor Client with OkHttp engine
|
||||||
- Kotlinx Serialization
|
- Kotlinx Serialization
|
||||||
- Room (cache), DataStore (preferences), EncryptedSharedPreferences (tokens)
|
- 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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,9 @@ dependencies {
|
||||||
implementation(libs.koin.compose.viewmodel.navigation)
|
implementation(libs.koin.compose.viewmodel.navigation)
|
||||||
implementation(libs.compose.material3.wsc)
|
implementation(libs.compose.material3.wsc)
|
||||||
implementation(libs.bundles.lifecycle)
|
implementation(libs.bundles.lifecycle)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil3.compose)
|
||||||
implementation(libs.timber)
|
implementation(libs.coil3.network.ktor)
|
||||||
|
implementation(libs.kermit)
|
||||||
|
|
||||||
debugImplementation(libs.leakcanary)
|
debugImplementation(libs.leakcanary)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,35 @@
|
||||||
package com.librechat.android
|
package com.librechat.android
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import coil.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil.ImageLoaderFactory
|
import coil3.SingletonImageLoader
|
||||||
import coil.disk.DiskCache
|
import coil3.disk.DiskCache
|
||||||
import coil.memory.MemoryCache
|
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.common.di.commonModule
|
||||||
import com.librechat.android.core.data.di.dataModule
|
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.core.network.di.networkModule
|
||||||
import com.librechat.android.feature.agents.di.agentsModule
|
import com.librechat.android.feature.agents.di.agentsModule
|
||||||
import com.librechat.android.feature.auth.di.authModule
|
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.chat.di.chatModule
|
||||||
import com.librechat.android.feature.conversations.di.conversationsModule
|
import com.librechat.android.feature.conversations.di.conversationsModule
|
||||||
import com.librechat.android.feature.files.di.filesModule
|
import com.librechat.android.feature.files.di.filesModule
|
||||||
import com.librechat.android.feature.settings.di.settingsModule
|
import com.librechat.android.feature.settings.di.settingsModule
|
||||||
import com.librechat.android.navigation.appModule
|
import com.librechat.android.navigation.appModule
|
||||||
import kotlinx.coroutines.runBlocking
|
import io.ktor.client.HttpClient
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.android.ext.koin.androidLogger
|
import org.koin.android.ext.koin.androidLogger
|
||||||
import org.koin.core.context.startKoin
|
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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
Timber.plant(Timber.DebugTree())
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
startKoin {
|
startKoin {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
|
|
@ -45,6 +43,7 @@ class LibreChatApplication : Application(), ImageLoaderFactory {
|
||||||
dataModule,
|
dataModule,
|
||||||
appModule,
|
appModule,
|
||||||
authModule,
|
authModule,
|
||||||
|
authPlatformModule,
|
||||||
chatModule,
|
chatModule,
|
||||||
conversationsModule,
|
conversationsModule,
|
||||||
settingsModule,
|
settingsModule,
|
||||||
|
|
@ -53,49 +52,21 @@ class LibreChatApplication : Application(), ImageLoaderFactory {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Koin initialization failed")
|
Logger.e(e) { "Koin initialization failed" }
|
||||||
throw e // Always rethrow — DI failure is unrecoverable
|
throw e // Always rethrow — DI failure is unrecoverable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun newImageLoader(): ImageLoader {
|
override fun newImageLoader(context: coil3.PlatformContext): ImageLoader {
|
||||||
val tokenManager: TokenManager = get()
|
val httpClient: HttpClient = get()
|
||||||
|
|
||||||
val okHttpClient = OkHttpClient.Builder()
|
return ImageLoader.Builder(context)
|
||||||
.addInterceptor { chain ->
|
.components {
|
||||||
val token = runBlocking { tokenManager.getAccessToken() }
|
add(KtorNetworkFetcherFactory(httpClient))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
.build()
|
|
||||||
|
|
||||||
return ImageLoader.Builder(this)
|
|
||||||
.okHttpClient(okHttpClient)
|
|
||||||
.memoryCache {
|
.memoryCache {
|
||||||
MemoryCache.Builder(this)
|
MemoryCache.Builder()
|
||||||
.maxSizePercent(0.25)
|
.maxSizePercent(context, 0.25)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
.diskCache {
|
.diskCache {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ import com.librechat.android.feature.chat.ShareIntentConsumer
|
||||||
import com.librechat.android.feature.chat.SharedContent
|
import com.librechat.android.feature.chat.SharedContent
|
||||||
import com.librechat.android.navigation.LibreChatNavHost
|
import com.librechat.android.navigation.LibreChatNavHost
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import timber.log.Timber
|
import co.touchlab.kermit.Logger
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ class MainActivity : ComponentActivity() {
|
||||||
setContent {
|
setContent {
|
||||||
val windowSizeClass = calculateWindowSizeClass(this)
|
val windowSizeClass = calculateWindowSizeClass(this)
|
||||||
val isConnected by connectivityObserver.isConnected.collectAsStateWithLifecycle(initialValue = true)
|
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) {
|
val darkTheme = when (themeMode) {
|
||||||
ThemeMode.LIGHT -> false
|
ThemeMode.LIGHT -> false
|
||||||
ThemeMode.DARK -> true
|
ThemeMode.DARK -> true
|
||||||
|
|
@ -138,7 +138,7 @@ class MainActivity : ComponentActivity() {
|
||||||
if (uri.scheme == "librechat" && uri.host in KNOWN_DEEP_LINK_HOSTS) {
|
if (uri.scheme == "librechat" && uri.host in KNOWN_DEEP_LINK_HOSTS) {
|
||||||
deepLinkUri = uri
|
deepLinkUri = uri
|
||||||
} else if (uri.scheme == "librechat") {
|
} 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()
|
val fileUris = if (sharedUri != null) listOf(sharedUri) else emptyList()
|
||||||
|
|
||||||
if (sharedText != null || fileUris.isNotEmpty()) {
|
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(
|
ShareIntentConsumer.setPendingShare(
|
||||||
SharedContent(text = sharedText, fileUris = fileUris),
|
SharedContent(text = sharedText, fileUris = fileUris),
|
||||||
)
|
)
|
||||||
|
|
@ -181,7 +181,7 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sharedUris.isNullOrEmpty()) {
|
if (!sharedUris.isNullOrEmpty()) {
|
||||||
Timber.d("Share multiple intent received: uris=%d", sharedUris.size)
|
Logger.d { "Share multiple intent received: uris=${sharedUris.size}" }
|
||||||
ShareIntentConsumer.setPendingShare(
|
ShareIntentConsumer.setPendingShare(
|
||||||
SharedContent(fileUris = sharedUris),
|
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 com.librechat.android.feature.settings.navigation.settingsGraph
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import timber.log.Timber
|
import co.touchlab.kermit.Logger
|
||||||
|
|
||||||
/** Maps a [SettingsCategory] to its corresponding navigation route. */
|
/** Maps a [SettingsCategory] to its corresponding navigation route. */
|
||||||
fun SettingsCategory.toRoute(): String = when (this) {
|
fun SettingsCategory.toRoute(): String = when (this) {
|
||||||
|
|
@ -214,7 +214,7 @@ private fun PhoneLayout(
|
||||||
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||||
navController.navigateToChat(conversationId)
|
navController.navigateToChat(conversationId)
|
||||||
} else {
|
} 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.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import timber.log.Timber
|
import co.touchlab.kermit.Logger
|
||||||
import java.time.Instant
|
import kotlin.time.Clock
|
||||||
import java.time.ZoneId
|
import kotlin.time.Instant
|
||||||
import java.time.format.DateTimeFormatter
|
import kotlinx.datetime.TimeZone
|
||||||
import java.time.temporal.ChronoUnit
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Combined UI state for the drawer sidebar. A single emission replaces
|
* 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,
|
private val settingsDataStore: com.librechat.android.core.data.datastore.SettingsDataStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _isLoggedIn = MutableStateFlow(false)
|
private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated)
|
||||||
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -227,7 +227,7 @@ class NavHostViewModel(
|
||||||
nextCursor = result.data
|
nextCursor = result.data
|
||||||
_hasMore.value = result.data != null
|
_hasMore.value = result.data != null
|
||||||
} else {
|
} else {
|
||||||
Timber.w("Failed to load more conversations")
|
Logger.w { "Failed to load more conversations" }
|
||||||
}
|
}
|
||||||
_isLoadingMore.value = false
|
_isLoadingMore.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -308,12 +308,12 @@ class NavHostViewModel(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = bannerRepository.getBanners()
|
val result = bannerRepository.getBanners()
|
||||||
if (result is Result.Success) {
|
if (result is Result.Success) {
|
||||||
val now = Instant.now()
|
val now = Clock.System.now()
|
||||||
_banners.value = result.data.filter { banner ->
|
_banners.value = result.data.filter { banner ->
|
||||||
val from = banner.displayFrom?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
val from = banner.displayFrom?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||||
val to = banner.displayTo?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
val to = banner.displayTo?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||||
val afterStart = from == null || !now.isBefore(from)
|
val afterStart = from == null || now >= from
|
||||||
val beforeEnd = to == null || now.isBefore(to)
|
val beforeEnd = to == null || now < to
|
||||||
afterStart && beforeEnd
|
afterStart && beforeEnd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -350,11 +350,9 @@ class NavHostViewModel(
|
||||||
backendVersion = detectedVersion,
|
backendVersion = detectedVersion,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Timber.d(
|
Logger.d {
|
||||||
"Version mismatch (backend=%s, supported=%s) but user dismissed for this version",
|
"Version mismatch (backend=$detectedVersion, supported=${checkResult.supportedVersion}) but user dismissed for this version"
|
||||||
detectedVersion,
|
}
|
||||||
checkResult.supportedVersion,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -434,17 +432,26 @@ private fun Conversation.toDrawerDisplayData(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Instant.toRelativeTimeString(): String {
|
private fun Instant.toRelativeTimeString(): String {
|
||||||
val now = Instant.now()
|
val now = Clock.System.now()
|
||||||
val minutes = ChronoUnit.MINUTES.between(this, now)
|
val duration = now - this
|
||||||
val hours = ChronoUnit.HOURS.between(this, now)
|
val minutes = duration.inWholeMinutes
|
||||||
val days = ChronoUnit.DAYS.between(this, now)
|
val hours = duration.inWholeHours
|
||||||
|
val days = duration.inWholeDays
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
minutes < 1 -> "Just now"
|
minutes < 1 -> "Just now"
|
||||||
minutes < 60 -> "${minutes}m ago"
|
minutes < 60 -> "${minutes}m ago"
|
||||||
hours < 24 -> "${hours}h ago"
|
hours < 24 -> "${hours}h ago"
|
||||||
days < 7 -> "${days}d ago"
|
days < 7 -> "${days}d ago"
|
||||||
else -> atZone(ZoneId.systemDefault())
|
else -> {
|
||||||
.format(DateTimeFormatter.ofPattern("MMM d"))
|
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.SHARED_LINKS_ROUTE
|
||||||
import com.librechat.android.feature.settings.navigation.settingsGraph
|
import com.librechat.android.feature.settings.navigation.settingsGraph
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import co.touchlab.kermit.Logger
|
||||||
|
|
||||||
private val SidebarWidth = 320.dp
|
private val SidebarWidth = 320.dp
|
||||||
|
|
||||||
|
|
@ -109,7 +109,7 @@ fun TabletLayout(
|
||||||
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||||
navController.navigateToChat(conversationId)
|
navController.navigateToChat(conversationId)
|
||||||
} else {
|
} 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.core.network.sse.SseClient
|
||||||
import com.librechat.android.feature.agents.di.agentsModule
|
import com.librechat.android.feature.agents.di.agentsModule
|
||||||
import com.librechat.android.feature.auth.di.authModule
|
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.chat.di.chatModule
|
||||||
import com.librechat.android.feature.conversations.di.conversationsModule
|
import com.librechat.android.feature.conversations.di.conversationsModule
|
||||||
import com.librechat.android.feature.files.di.filesModule
|
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.feature.settings.di.settingsModule
|
||||||
import com.librechat.android.navigation.appModule
|
import com.librechat.android.navigation.appModule
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
|
@ -155,6 +157,10 @@ class KoinGraphVerificationTest {
|
||||||
SpeechRepository::class,
|
SpeechRepository::class,
|
||||||
TagRepository::class,
|
TagRepository::class,
|
||||||
UserRepository::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
|
// Wrappers/DSL types that verify can't resolve via constructor
|
||||||
kotlin.Lazy::class,
|
kotlin.Lazy::class,
|
||||||
java.io.File::class,
|
java.io.File::class,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ dependencies {
|
||||||
compileOnly(libs.android.gradlePlugin)
|
compileOnly(libs.android.gradlePlugin)
|
||||||
compileOnly(libs.kotlin.gradlePlugin)
|
compileOnly(libs.kotlin.gradlePlugin)
|
||||||
compileOnly(libs.compose.gradlePlugin)
|
compileOnly(libs.compose.gradlePlugin)
|
||||||
|
compileOnly(libs.compose.multiplatform.gradlePlugin)
|
||||||
compileOnly(libs.ksp.gradlePlugin)
|
compileOnly(libs.ksp.gradlePlugin)
|
||||||
compileOnly(libs.room.gradlePlugin)
|
compileOnly(libs.room.gradlePlugin)
|
||||||
compileOnly(libs.detekt.gradlePlugin)
|
compileOnly(libs.detekt.gradlePlugin)
|
||||||
|
|
@ -46,5 +47,25 @@ gradlePlugin {
|
||||||
id = "librechat.android.detekt"
|
id = "librechat.android.detekt"
|
||||||
implementationClass = "AndroidDetektConventionPlugin"
|
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.Plugin
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
import org.gradle.kotlin.dsl.configure
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
import org.gradle.kotlin.dsl.withType
|
||||||
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
|
||||||
class AndroidApplicationConventionPlugin : Plugin<Project> {
|
class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||||
override fun apply(target: Project) {
|
override fun apply(target: Project) {
|
||||||
|
|
@ -11,8 +13,14 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||||
pluginManager.apply("librechat.android.detekt")
|
pluginManager.apply("librechat.android.detekt")
|
||||||
pluginManager.apply("org.jetbrains.kotlinx.kover")
|
pluginManager.apply("org.jetbrains.kotlinx.kover")
|
||||||
|
|
||||||
|
tasks.withType<KotlinCompile>().configureEach {
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.addAll("-opt-in=kotlin.time.ExperimentalTime")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extensions.configure<ApplicationExtension> {
|
extensions.configure<ApplicationExtension> {
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import com.android.build.gradle.LibraryExtension
|
||||||
import org.gradle.api.Plugin
|
import org.gradle.api.Plugin
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
import org.gradle.kotlin.dsl.configure
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
import org.gradle.kotlin.dsl.withType
|
||||||
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
|
||||||
class AndroidLibraryConventionPlugin : Plugin<Project> {
|
class AndroidLibraryConventionPlugin : Plugin<Project> {
|
||||||
override fun apply(target: Project) {
|
override fun apply(target: Project) {
|
||||||
|
|
@ -11,8 +13,14 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
|
||||||
pluginManager.apply("librechat.android.detekt")
|
pluginManager.apply("librechat.android.detekt")
|
||||||
pluginManager.apply("org.jetbrains.kotlinx.kover")
|
pluginManager.apply("org.jetbrains.kotlinx.kover")
|
||||||
|
|
||||||
|
tasks.withType<KotlinCompile>().configureEach {
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.addAll("-opt-in=kotlin.time.ExperimentalTime")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extensions.configure<LibraryExtension> {
|
extensions.configure<LibraryExtension> {
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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.application) apply false
|
||||||
alias(libs.plugins.android.library) apply false
|
alias(libs.plugins.android.library) apply false
|
||||||
alias(libs.plugins.kotlin.android) 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.compose) apply false
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.ksp) apply false
|
||||||
alias(libs.plugins.room) 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.detekt) apply false
|
||||||
alias(libs.plugins.kover)
|
alias(libs.plugins.kover)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,40 +3,11 @@
|
||||||
// enabling skip optimizations for composable parameters of these types.
|
// enabling skip optimizations for composable parameters of these types.
|
||||||
// See: https://developer.android.com/develop/ui/compose/performance/stability/fix#configuration-file
|
// 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)
|
// Core model classes — all are data classes with val-only fields, never mutated after creation.
|
||||||
com.librechat.android.core.model.Message
|
// Wildcard patterns auto-cover new models without manual maintenance.
|
||||||
com.librechat.android.core.model.Conversation
|
com.librechat.android.core.model.*
|
||||||
com.librechat.android.core.model.MessageContentPart
|
com.librechat.android.core.model.mcp.*
|
||||||
com.librechat.android.core.model.ToolCall
|
com.librechat.android.core.model.speech.*
|
||||||
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
|
|
||||||
|
|
||||||
// Kotlin stdlib collections (we only use immutable instances in practice)
|
// Kotlin stdlib collections (we only use immutable instances in practice)
|
||||||
kotlin.collections.List
|
kotlin.collections.List
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,24 @@
|
||||||
plugins {
|
plugins {
|
||||||
id("librechat.android.library")
|
id("librechat.kmp.library")
|
||||||
id("librechat.android.koin")
|
id("librechat.kmp.koin")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.librechat.android.core.common"
|
namespace = "com.librechat.android.core.common"
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
kotlin {
|
||||||
implementation(libs.coroutines.core)
|
sourceSets {
|
||||||
implementation(libs.coroutines.android)
|
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.callbackFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
class ConnectivityObserver(
|
class AndroidConnectivityObserver(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
) {
|
) : ConnectivityObserver {
|
||||||
// Permission is declared in app module's AndroidManifest.xml
|
// Permission is declared in app module's AndroidManifest.xml
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
val isConnected: Flow<Boolean> = callbackFlow {
|
override val isConnected: Flow<Boolean> = callbackFlow {
|
||||||
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||||
|
|
||||||
// Emit current state
|
// 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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
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
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
expect val commonPlatformModule: Module
|
||||||
|
|
||||||
val commonModule = module {
|
val commonModule = module {
|
||||||
|
includes(commonPlatformModule)
|
||||||
|
|
||||||
// Dispatchers
|
// Dispatchers
|
||||||
single<CoroutineDispatcher>(KoinQualifiers.IO) { Dispatchers.IO }
|
single<CoroutineDispatcher>(KoinQualifiers.IO) { ioDispatcher }
|
||||||
single<CoroutineDispatcher>(KoinQualifiers.Default) { Dispatchers.Default }
|
single<CoroutineDispatcher>(KoinQualifiers.Default) { Dispatchers.Default }
|
||||||
single<CoroutineDispatcher>(KoinQualifiers.Main) { Dispatchers.Main }
|
single<CoroutineDispatcher>(KoinQualifiers.Main) { Dispatchers.Main }
|
||||||
|
|
||||||
|
|
@ -20,7 +25,4 @@ val commonModule = module {
|
||||||
single<CoroutineScope>(KoinQualifiers.ApplicationScope) {
|
single<CoroutineScope>(KoinQualifiers.ApplicationScope) {
|
||||||
CoroutineScope(SupervisorJob() + get<CoroutineDispatcher>(KoinQualifiers.Default))
|
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.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.retryWhen
|
import kotlinx.coroutines.flow.retryWhen
|
||||||
|
import kotlin.time.Clock
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
|
|
||||||
fun <T> Flow<T>.retryWithBackoff(
|
fun <T> Flow<T>.retryWithBackoff(
|
||||||
|
|
@ -21,7 +22,7 @@ fun <T> Flow<T>.retryWithBackoff(
|
||||||
fun <T> Flow<T>.throttleFirst(periodMillis: Long): Flow<T> = flow {
|
fun <T> Flow<T>.throttleFirst(periodMillis: Long): Flow<T> = flow {
|
||||||
var lastTime = 0L
|
var lastTime = 0L
|
||||||
collect { value ->
|
collect { value ->
|
||||||
val currentTime = System.currentTimeMillis()
|
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||||
if (currentTime - lastTime >= periodMillis) {
|
if (currentTime - lastTime >= periodMillis) {
|
||||||
lastTime = currentTime
|
lastTime = currentTime
|
||||||
emit(value)
|
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 {
|
plugins {
|
||||||
id("librechat.android.library")
|
id("librechat.kmp.library")
|
||||||
id("librechat.android.koin")
|
id("librechat.kmp.koin")
|
||||||
id("librechat.android.room")
|
id("librechat.kmp.room")
|
||||||
id("librechat.kotlin.serialization")
|
id("librechat.kotlin.serialization")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -9,14 +9,34 @@ android {
|
||||||
namespace = "com.librechat.android.core.data"
|
namespace = "com.librechat.android.core.data"
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
kotlin {
|
||||||
implementation(project(":core:network"))
|
sourceSets {
|
||||||
implementation(project(":core:model"))
|
commonMain.dependencies {
|
||||||
implementation(project(":core:common"))
|
implementation(project(":core:network"))
|
||||||
implementation(libs.bundles.room)
|
implementation(project(":core:model"))
|
||||||
implementation(libs.datastore.preferences)
|
implementation(project(":core:common"))
|
||||||
implementation(libs.security.crypto)
|
implementation(libs.datastore.preferences)
|
||||||
implementation(libs.bundles.ktor)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.timber)
|
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.MapSerializer
|
||||||
import kotlinx.serialization.builtins.serializer
|
import kotlinx.serialization.builtins.serializer
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import timber.log.Timber
|
import co.touchlab.kermit.Logger
|
||||||
|
|
||||||
class ConfigCacheDataStore(
|
class ConfigCacheDataStore(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
|
|
@ -23,7 +23,7 @@ class ConfigCacheDataStore(
|
||||||
val serialized = json.encodeToString(StartupConfig.serializer(), config)
|
val serialized = json.encodeToString(StartupConfig.serializer(), config)
|
||||||
dataStore.edit { prefs -> prefs[KEY_STARTUP_CONFIG] = serialized }
|
dataStore.edit { prefs -> prefs[KEY_STARTUP_CONFIG] = serialized }
|
||||||
} catch (e: Exception) {
|
} 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
|
val serialized = prefs[KEY_STARTUP_CONFIG] ?: return null
|
||||||
json.decodeFromString(StartupConfig.serializer(), serialized)
|
json.decodeFromString(StartupConfig.serializer(), serialized)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.w(e, "Failed to load cached startup config")
|
Logger.w(e) { "Failed to load cached startup config" }
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ class ConfigCacheDataStore(
|
||||||
val serialized = json.encodeToString(serializer, endpoints)
|
val serialized = json.encodeToString(serializer, endpoints)
|
||||||
dataStore.edit { prefs -> prefs[KEY_ENDPOINT_CONFIGS] = serialized }
|
dataStore.edit { prefs -> prefs[KEY_ENDPOINT_CONFIGS] = serialized }
|
||||||
} catch (e: Exception) {
|
} 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())
|
val serializer = MapSerializer(String.serializer(), EndpointConfig.serializer())
|
||||||
json.decodeFromString(serializer, serialized)
|
json.decodeFromString(serializer, serialized)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.w(e, "Failed to load cached endpoint configs")
|
Logger.w(e) { "Failed to load cached endpoint configs" }
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +69,7 @@ class ConfigCacheDataStore(
|
||||||
val serialized = json.encodeToString(serializer, models)
|
val serialized = json.encodeToString(serializer, models)
|
||||||
dataStore.edit { prefs -> prefs[KEY_AVAILABLE_MODELS] = serialized }
|
dataStore.edit { prefs -> prefs[KEY_AVAILABLE_MODELS] = serialized }
|
||||||
} catch (e: Exception) {
|
} 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)
|
json.decodeFromString(serializer, serialized)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.w(e, "Failed to load cached available models")
|
Logger.w(e) { "Failed to load cached available models" }
|
||||||
null
|
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.Preferences
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
enum class ThemeMode {
|
enum class ThemeMode {
|
||||||
SYSTEM, LIGHT, DARK
|
SYSTEM, LIGHT, DARK
|
||||||
|
|
@ -15,12 +19,16 @@ class ThemeDataStore(
|
||||||
private val dataStore: DataStore<Preferences>,
|
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 ->
|
val themeMode: Flow<ThemeMode> = dataStore.data.map { prefs ->
|
||||||
when (prefs[KEY_THEME]) {
|
prefs[KEY_THEME].toThemeMode()
|
||||||
"light" -> ThemeMode.LIGHT
|
|
||||||
"dark" -> ThemeMode.DARK
|
|
||||||
else -> ThemeMode.SYSTEM
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setThemeMode(mode: ThemeMode) {
|
suspend fun setThemeMode(mode: ThemeMode) {
|
||||||
|
|
@ -35,5 +43,11 @@ class ThemeDataStore(
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val KEY_THEME = stringPreferencesKey("theme_mode")
|
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
|
package com.librechat.android.core.data.db
|
||||||
|
|
||||||
import androidx.room.AutoMigration
|
import androidx.room.AutoMigration
|
||||||
|
import androidx.room.ConstructedBy
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.RoomDatabaseConstructor
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import com.librechat.android.core.data.db.converter.Converters
|
import com.librechat.android.core.data.db.converter.Converters
|
||||||
import com.librechat.android.core.data.db.dao.AgentDao
|
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)
|
@TypeConverters(Converters::class)
|
||||||
|
@ConstructedBy(LibreChatDatabaseConstructor::class)
|
||||||
abstract class LibreChatDatabase : RoomDatabase() {
|
abstract class LibreChatDatabase : RoomDatabase() {
|
||||||
abstract fun conversationDao(): ConversationDao
|
abstract fun conversationDao(): ConversationDao
|
||||||
abstract fun messageDao(): MessageDao
|
abstract fun messageDao(): MessageDao
|
||||||
|
|
@ -47,3 +50,7 @@ abstract class LibreChatDatabase : RoomDatabase() {
|
||||||
abstract fun conversationTagDao(): ConversationTagDao
|
abstract fun conversationTagDao(): ConversationTagDao
|
||||||
abstract fun draftDao(): DraftDao
|
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.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
@Entity(tableName = "drafts")
|
@Entity(tableName = "drafts")
|
||||||
data class DraftEntity(
|
data class DraftEntity(
|
||||||
|
|
@ -12,5 +13,5 @@ data class DraftEntity(
|
||||||
@ColumnInfo(name = "text")
|
@ColumnInfo(name = "text")
|
||||||
val text: String,
|
val text: String,
|
||||||
@ColumnInfo(name = "updated_at")
|
@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
|
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.common.di.KoinQualifiers
|
||||||
import com.librechat.android.core.data.datastore.ConfigCacheDataStore
|
import com.librechat.android.core.data.datastore.ConfigCacheDataStore
|
||||||
import com.librechat.android.core.data.datastore.ServerDataStore
|
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.SettingsDataStore
|
||||||
import com.librechat.android.core.data.datastore.ThemeDataStore
|
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.db.LibreChatDatabase
|
||||||
import com.librechat.android.core.data.repository.AgentRepository
|
import com.librechat.android.core.data.repository.AgentRepository
|
||||||
import com.librechat.android.core.data.repository.AgentRepositoryImpl
|
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.PromptRepositoryImpl
|
||||||
import com.librechat.android.core.data.repository.SearchRepository
|
import com.librechat.android.core.data.repository.SearchRepository
|
||||||
import com.librechat.android.core.data.repository.SearchRepositoryImpl
|
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.ShareRepository
|
||||||
import com.librechat.android.core.data.repository.ShareRepositoryImpl
|
import com.librechat.android.core.data.repository.ShareRepositoryImpl
|
||||||
import com.librechat.android.core.data.repository.SpeechRepository
|
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.TagRepositoryImpl
|
||||||
import com.librechat.android.core.data.repository.UserRepository
|
import com.librechat.android.core.data.repository.UserRepository
|
||||||
import com.librechat.android.core.data.repository.UserRepositoryImpl
|
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.ServerUrlProvider
|
||||||
import com.librechat.android.core.network.client.TokenManager
|
import org.koin.core.module.Module
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import org.koin.android.ext.koin.androidContext
|
|
||||||
import org.koin.core.module.dsl.singleOf
|
import org.koin.core.module.dsl.singleOf
|
||||||
import org.koin.dsl.bind
|
import org.koin.dsl.bind
|
||||||
import org.koin.dsl.binds
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
|
expect val dataPlatformModule: Module
|
||||||
name = "librechat_settings",
|
|
||||||
)
|
|
||||||
|
|
||||||
val dataModule = 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>().conversationDao() }
|
||||||
single { get<LibreChatDatabase>().messageDao() }
|
single { get<LibreChatDatabase>().messageDao() }
|
||||||
single { get<LibreChatDatabase>().fileDao() }
|
single { get<LibreChatDatabase>().fileDao() }
|
||||||
|
|
@ -83,20 +72,15 @@ val dataModule = module {
|
||||||
single { get<LibreChatDatabase>().conversationTagDao() }
|
single { get<LibreChatDatabase>().conversationTagDao() }
|
||||||
single { get<LibreChatDatabase>().draftDao() }
|
single { get<LibreChatDatabase>().draftDao() }
|
||||||
|
|
||||||
// --- DataStore ---
|
|
||||||
|
|
||||||
single<DataStore<Preferences>> { androidContext().settingsDataStore }
|
|
||||||
|
|
||||||
// --- Datastores ---
|
// --- Datastores ---
|
||||||
|
|
||||||
single {
|
single {
|
||||||
TokenDataStore(
|
ServerDataStore(
|
||||||
context = androidContext(),
|
dataStore = get(),
|
||||||
refreshClient = lazy(LazyThreadSafetyMode.NONE) { get<HttpClient>(KoinQualifiers.Refresh) },
|
ioDispatcher = get(KoinQualifiers.IO),
|
||||||
|
keychainFallback = getOrNull(),
|
||||||
)
|
)
|
||||||
} binds arrayOf(TokenManager::class, SecureTokenStorage::class)
|
} bind ServerUrlProvider::class
|
||||||
|
|
||||||
singleOf(::ServerDataStore) bind ServerUrlProvider::class
|
|
||||||
singleOf(::ThemeDataStore)
|
singleOf(::ThemeDataStore)
|
||||||
singleOf(::ConfigCacheDataStore)
|
singleOf(::ConfigCacheDataStore)
|
||||||
singleOf(::SettingsDataStore)
|
singleOf(::SettingsDataStore)
|
||||||
|
|
@ -108,7 +92,7 @@ val dataModule = module {
|
||||||
authApi = get(),
|
authApi = get(),
|
||||||
userApi = get(),
|
userApi = get(),
|
||||||
tokenManager = 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.data.db.entity.ConversationEntity
|
||||||
import com.librechat.android.core.model.Conversation
|
import com.librechat.android.core.model.Conversation
|
||||||
import com.librechat.android.core.model.EModelEndpoint
|
import com.librechat.android.core.model.EModelEndpoint
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
@ -51,14 +53,14 @@ fun ConversationEntity.toModel(): Conversation = Conversation(
|
||||||
fun List<ConversationEntity>.toModels(): List<Conversation> = map { it.toModel() }
|
fun List<ConversationEntity>.toModels(): List<Conversation> = map { it.toModel() }
|
||||||
|
|
||||||
private fun parseTimestamp(dateString: String?): Long {
|
private fun parseTimestamp(dateString: String?): Long {
|
||||||
if (dateString == null) return System.currentTimeMillis()
|
if (dateString == null) return Clock.System.now().toEpochMilliseconds()
|
||||||
return try {
|
return try {
|
||||||
java.time.Instant.parse(dateString).toEpochMilli()
|
Instant.parse(dateString).toEpochMilliseconds()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
System.currentTimeMillis()
|
Clock.System.now().toEpochMilliseconds()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatTimestamp(epochMillis: Long): String {
|
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.FileReference
|
||||||
import com.librechat.android.core.model.Message
|
import com.librechat.android.core.model.Message
|
||||||
import com.librechat.android.core.model.MessageContentPart
|
import com.librechat.android.core.model.MessageContentPart
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
@ -71,14 +73,14 @@ fun MessageEntity.toModel(): Message = Message(
|
||||||
fun List<MessageEntity>.toModels(): List<Message> = map { it.toModel() }
|
fun List<MessageEntity>.toModels(): List<Message> = map { it.toModel() }
|
||||||
|
|
||||||
private fun parseTimestamp(dateString: String?): Long {
|
private fun parseTimestamp(dateString: String?): Long {
|
||||||
if (dateString == null) return System.currentTimeMillis()
|
if (dateString == null) return Clock.System.now().toEpochMilliseconds()
|
||||||
return try {
|
return try {
|
||||||
java.time.Instant.parse(dateString).toEpochMilli()
|
Instant.parse(dateString).toEpochMilliseconds()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
System.currentTimeMillis()
|
Clock.System.now().toEpochMilliseconds()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatTimestamp(epochMillis: Long): String {
|
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
|
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.Result
|
||||||
import com.librechat.android.core.common.result.safeApiCall
|
import com.librechat.android.core.common.result.safeApiCall
|
||||||
import com.librechat.android.core.model.LoginOutcome
|
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.AuthApi
|
||||||
import com.librechat.android.core.network.api.UserApi
|
import com.librechat.android.core.network.api.UserApi
|
||||||
import com.librechat.android.core.network.client.TokenManager
|
import com.librechat.android.core.network.client.TokenManager
|
||||||
import timber.log.Timber
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class AuthRepositoryImpl(
|
class AuthRepositoryImpl(
|
||||||
private val authApi: AuthApi,
|
private val authApi: AuthApi,
|
||||||
private val userApi: UserApi,
|
private val userApi: UserApi,
|
||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
private val context: Context,
|
private val sessionCacheCleaner: SessionCacheCleaner,
|
||||||
) : AuthRepository {
|
) : AuthRepository {
|
||||||
|
|
||||||
override suspend fun login(email: String, password: String): Result<LoginOutcome> {
|
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> {
|
override suspend fun verifyTwoFactor(tempToken: String, code: String): Result<User> {
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
val result = authApi.verifyTempToken(tempToken = tempToken, totpCode = code)
|
val result = authApi.verifyTempToken(tempToken = tempToken, totpCode = code)
|
||||||
|
|
@ -88,28 +81,11 @@ class AuthRepositoryImpl(
|
||||||
authApi.logout()
|
authApi.logout()
|
||||||
} finally {
|
} finally {
|
||||||
tokenManager.clearTokens()
|
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 {
|
override suspend fun isLoggedIn(): Boolean {
|
||||||
return tokenManager.getAccessToken() != null
|
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.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.serialization.SerializationException
|
import kotlinx.serialization.SerializationException
|
||||||
import timber.log.Timber
|
import co.touchlab.kermit.Logger
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
class ConfigRepositoryImpl(
|
class ConfigRepositoryImpl(
|
||||||
private val configApi: ConfigApi,
|
private val configApi: ConfigApi,
|
||||||
|
|
@ -51,10 +50,8 @@ class ConfigRepositoryImpl(
|
||||||
Result.Error(e, message)
|
Result.Error(e, message)
|
||||||
} catch (e: HttpRequestTimeoutException) {
|
} catch (e: HttpRequestTimeoutException) {
|
||||||
Result.Error(e, "Connection timed out. Check the URL and try again.")
|
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) {
|
} 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) {
|
if (result is Result.Error) {
|
||||||
val cached = _startupConfig.value
|
val cached = _startupConfig.value
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
Timber.d("Using cached startup config (network unavailable)")
|
Logger.d { "Using cached startup config (network unavailable)" }
|
||||||
return Result.Success(cached)
|
return Result.Success(cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +107,7 @@ class ConfigRepositoryImpl(
|
||||||
if (result is Result.Error) {
|
if (result is Result.Error) {
|
||||||
val cached = _endpointConfigs.value
|
val cached = _endpointConfigs.value
|
||||||
if (cached.isNotEmpty()) {
|
if (cached.isNotEmpty()) {
|
||||||
Timber.d("Using cached endpoint configs (network unavailable)")
|
Logger.d { "Using cached endpoint configs (network unavailable)" }
|
||||||
return Result.Success(cached)
|
return Result.Success(cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +131,7 @@ class ConfigRepositoryImpl(
|
||||||
if (result is Result.Error) {
|
if (result is Result.Error) {
|
||||||
val cached = _availableModels.value
|
val cached = _availableModels.value
|
||||||
if (cached.isNotEmpty()) {
|
if (cached.isNotEmpty()) {
|
||||||
Timber.d("Using cached models (network unavailable)")
|
Logger.d { "Using cached models (network unavailable)" }
|
||||||
return Result.Success(cached)
|
return Result.Success(cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -167,14 +164,14 @@ class ConfigRepositoryImpl(
|
||||||
?: BackendVersion.extractVersionFromFooter(config?.customFooter)
|
?: BackendVersion.extractVersionFromFooter(config?.customFooter)
|
||||||
|
|
||||||
if (detectedVersion != null) {
|
if (detectedVersion != null) {
|
||||||
Timber.d("Backend version detected: %s (supported: %s)", detectedVersion, supported)
|
Logger.d { "Backend version detected: $detectedVersion (supported: $supported)" }
|
||||||
VersionCheckResult(
|
VersionCheckResult(
|
||||||
backendVersion = detectedVersion,
|
backendVersion = detectedVersion,
|
||||||
supportedVersion = supported,
|
supportedVersion = supported,
|
||||||
isCompatible = BackendVersion.isCompatible(supported, detectedVersion),
|
isCompatible = BackendVersion.isCompatible(supported, detectedVersion),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Timber.d("Backend version could not be determined, skipping check")
|
Logger.d { "Backend version could not be determined, skipping check" }
|
||||||
VersionCheckResult(
|
VersionCheckResult(
|
||||||
backendVersion = null,
|
backendVersion = null,
|
||||||
supportedVersion = supported,
|
supportedVersion = supported,
|
||||||
|
|
@ -11,7 +11,8 @@ import com.librechat.android.core.model.request.ForkConversationRequest
|
||||||
import com.librechat.android.core.network.api.ConversationsApi
|
import com.librechat.android.core.network.api.ConversationsApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import timber.log.Timber
|
import kotlin.time.Clock
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
|
||||||
class ConversationRepositoryImpl(
|
class ConversationRepositoryImpl(
|
||||||
private val conversationsApi: ConversationsApi,
|
private val conversationsApi: ConversationsApi,
|
||||||
|
|
@ -66,7 +67,7 @@ class ConversationRepositoryImpl(
|
||||||
override suspend fun updateTitle(id: String, title: String): Result<Conversation> {
|
override suspend fun updateTitle(id: String, title: String): Result<Conversation> {
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
val updated = conversationsApi.updateTitle(id, title)
|
val updated = conversationsApi.updateTitle(id, title)
|
||||||
conversationDao.updateTitle(id, title, System.currentTimeMillis())
|
conversationDao.updateTitle(id, title, Clock.System.now().toEpochMilliseconds())
|
||||||
updated
|
updated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +76,7 @@ class ConversationRepositoryImpl(
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
val response = conversationsApi.generateTitle(conversationId)
|
val response = conversationsApi.generateTitle(conversationId)
|
||||||
// Update the local cache with the generated title
|
// 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
|
response.title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +84,7 @@ class ConversationRepositoryImpl(
|
||||||
override suspend fun archive(id: String, isArchived: Boolean): Result<Conversation> {
|
override suspend fun archive(id: String, isArchived: Boolean): Result<Conversation> {
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
val updated = conversationsApi.archive(id, isArchived)
|
val updated = conversationsApi.archive(id, isArchived)
|
||||||
conversationDao.updateArchived(id, isArchived, System.currentTimeMillis())
|
conversationDao.updateArchived(id, isArchived, Clock.System.now().toEpochMilliseconds())
|
||||||
updated
|
updated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +132,7 @@ class ConversationRepositoryImpl(
|
||||||
|
|
||||||
override suspend fun importConversation(jsonContent: String): Result<Conversation> {
|
override suspend fun importConversation(jsonContent: String): Result<Conversation> {
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
val fileBytes = jsonContent.toByteArray(Charsets.UTF_8)
|
val fileBytes = jsonContent.encodeToByteArray()
|
||||||
val imported = conversationsApi.importConversations(
|
val imported = conversationsApi.importConversations(
|
||||||
fileBytes = fileBytes,
|
fileBytes = fileBytes,
|
||||||
filename = "conversation.json",
|
filename = "conversation.json",
|
||||||
|
|
@ -161,7 +162,7 @@ class ConversationRepositoryImpl(
|
||||||
val entities = response.conversations.map { it.toEntity() }
|
val entities = response.conversations.map { it.toEntity() }
|
||||||
conversationDao.upsertAll(entities)
|
conversationDao.upsertAll(entities)
|
||||||
} catch (e: Exception) {
|
} 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