Migrate DI from Hilt to Koin 4.1

This commit is contained in:
Garfie 2026-03-28 09:03:14 -05:00
parent d30988f8b4
commit e4effcf40f
149 changed files with 1065 additions and 716 deletions

View file

@ -5,7 +5,7 @@ Native Android client for LibreChat. Connects to existing LibreChat backend serv
## Tech Stack ## Tech Stack
- **UI**: Jetpack Compose + Compose Navigation - **UI**: Jetpack Compose + Compose Navigation
- **DI**: Hilt - **DI**: Koin (KMP-ready)
- **Network**: Ktor Client (OkHttp engine) - **Network**: Ktor Client (OkHttp engine)
- **Serialization**: Kotlinx Serialization - **Serialization**: Kotlinx Serialization
- **Local Storage**: Room (cache), DataStore (prefs), EncryptedSharedPreferences (tokens) - **Local Storage**: Room (cache), DataStore (prefs), EncryptedSharedPreferences (tokens)
@ -39,6 +39,14 @@ Each module has its own `CLAUDE.md` with specific guidance.
- Room is a read-through cache; server is source of truth - Room is a read-through cache; server is source of truth
- Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin) - Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin)
## Adding a New Feature Module
1. Create the module directory under `feature/`
2. Apply the `librechat.android.feature` convention plugin in `build.gradle.kts` — this auto-applies Koin + Compose deps
3. Create `di/<Feature>Module.kt` with a Koin `module { }` containing `viewModelOf(::YourViewModel)` definitions
4. Register the module in `LibreChatApplication.kt`'s `startKoin { modules(...) }` list
5. Use `koinViewModel()` in screen composables to inject ViewModels
## Backend Quirks ## Backend Quirks
- Mutation endpoints wrap body in `arg` field: `{ "arg": { ... } }` - Mutation endpoints wrap body in `arg` field: `{ "arg": { ... } }`

View file

@ -1,6 +1,6 @@
# App Module # App Module
Single Activity architecture. `MainActivity` is the sole entry point, annotated `@AndroidEntryPoint`. Single Activity architecture. `MainActivity` is the sole entry point.
## Navigation ## Navigation
@ -50,7 +50,7 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
## Dependencies ## Dependencies
This module depends on all `:core:*` and all `:feature:*` modules. This module depends on all `:core:*` and all `:feature:*` modules.
It applies convention plugins: `librechat.android.application`, `librechat.android.compose`, `librechat.android.hilt`. It applies convention plugins: `librechat.android.application`, `librechat.android.compose`, `librechat.android.koin`.
### Server-Synced Favorites ### Server-Synced Favorites
- `NavHostViewModel` exposes `favorites: StateFlow<Set<String>>` and `toggleFavorite()` - `NavHostViewModel` exposes `favorites: StateFlow<Set<String>>` and `toggleFavorite()`

View file

@ -1,7 +1,7 @@
plugins { plugins {
id("librechat.android.application") id("librechat.android.application")
id("librechat.android.compose") id("librechat.android.compose")
id("librechat.android.hilt") id("librechat.android.koin")
} }
android { android {
@ -31,7 +31,9 @@ dependencies {
implementation(libs.activity.compose) implementation(libs.activity.compose)
implementation(libs.navigation.compose) implementation(libs.navigation.compose)
implementation(libs.hilt.navigation.compose) implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
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.coil.compose)

View file

@ -15,3 +15,6 @@
# Keep Room entities # Keep Room entities
-keep class com.librechat.android.core.data.db.entity.** { *; } -keep class com.librechat.android.core.data.db.entity.** { *; }
# Koin - keep ViewModel constructors for reflection-based instantiation
-keep class * extends androidx.lifecycle.ViewModel { <init>(...); }

View file

@ -5,37 +5,61 @@ import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import com.librechat.android.core.common.di.commonModule
import com.librechat.android.core.data.di.dataModule
import com.librechat.android.core.network.client.TokenManager import com.librechat.android.core.network.client.TokenManager
import dagger.hilt.EntryPoint import com.librechat.android.core.network.di.networkModule
import dagger.hilt.InstallIn import com.librechat.android.feature.agents.di.agentsModule
import dagger.hilt.android.EntryPointAccessors import com.librechat.android.feature.auth.di.authModule
import dagger.hilt.android.HiltAndroidApp import com.librechat.android.feature.chat.di.chatModule
import dagger.hilt.components.SingletonComponent import com.librechat.android.feature.conversations.di.conversationsModule
import com.librechat.android.feature.files.di.filesModule
import com.librechat.android.feature.settings.di.settingsModule
import com.librechat.android.navigation.appModule
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import timber.log.Timber import timber.log.Timber
@HiltAndroidApp
class LibreChatApplication : Application(), ImageLoaderFactory { class LibreChatApplication : Application(), ImageLoaderFactory {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ImageLoaderEntryPoint {
fun tokenManager(): TokenManager
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
} }
try {
startKoin {
if (BuildConfig.DEBUG) {
androidLogger(org.koin.core.logger.Level.DEBUG)
}
androidContext(this@LibreChatApplication)
allowOverride(false)
modules(
commonModule,
networkModule,
dataModule,
appModule,
authModule,
chatModule,
conversationsModule,
settingsModule,
agentsModule,
filesModule,
)
}
} catch (e: Exception) {
Timber.e(e, "Koin initialization failed")
throw e // Always rethrow — DI failure is unrecoverable
}
} }
override fun newImageLoader(): ImageLoader { override fun newImageLoader(): ImageLoader {
val entryPoint = EntryPointAccessors.fromApplication( val tokenManager: TokenManager = get()
this, ImageLoaderEntryPoint::class.java
)
val tokenManager = entryPoint.tokenManager()
val okHttpClient = OkHttpClient.Builder() val okHttpClient = OkHttpClient.Builder()
.addInterceptor { chain -> .addInterceptor { chain ->

View file

@ -38,16 +38,14 @@ import com.librechat.android.core.ui.theme.LibreChatTheme
import com.librechat.android.feature.chat.ShareIntentConsumer 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 dagger.hilt.android.AndroidEntryPoint import org.koin.android.ext.android.inject
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject lateinit var connectivityObserver: ConnectivityObserver private val connectivityObserver: ConnectivityObserver by inject()
@Inject lateinit var themeDataStore: ThemeDataStore private val themeDataStore: ThemeDataStore by inject()
private var deepLinkUri by mutableStateOf<android.net.Uri?>(null) private var deepLinkUri by mutableStateOf<android.net.Uri?>(null)

View file

@ -0,0 +1,8 @@
package com.librechat.android.navigation
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val appModule = module {
viewModelOf(::NavHostViewModel)
}

View file

@ -32,7 +32,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.hilt.navigation.compose.hiltViewModel import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@ -77,7 +77,7 @@ fun LibreChatNavHost(
deepLinkUri: Uri? = null, deepLinkUri: Uri? = null,
onDeepLinkConsumed: () -> Unit = {}, onDeepLinkConsumed: () -> Unit = {},
shareNavigationTrigger: Int = 0, shareNavigationTrigger: Int = 0,
navHostViewModel: NavHostViewModel = hiltViewModel(), navHostViewModel: NavHostViewModel = koinViewModel(),
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()

View file

@ -19,7 +19,6 @@ import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -33,7 +32,6 @@ 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 timber.log.Timber
import javax.inject.Inject
/** /**
* Combined UI state for the drawer sidebar. A single emission replaces * Combined UI state for the drawer sidebar. A single emission replaces
@ -59,8 +57,7 @@ data class VersionMismatchState(
val backendVersion: String, val backendVersion: String,
) )
@HiltViewModel class NavHostViewModel(
class NavHostViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val bannerRepository: BannerRepository, private val bannerRepository: BannerRepository,
private val configRepository: ConfigRepository, private val configRepository: ConfigRepository,

View file

@ -0,0 +1,180 @@
package com.librechat.android
import com.librechat.android.core.common.di.commonModule
import com.librechat.android.core.common.network.ConnectivityObserver
import com.librechat.android.core.data.datastore.ConfigCacheDataStore
import com.librechat.android.core.data.datastore.ServerDataStore
import com.librechat.android.core.data.datastore.SettingsDataStore
import com.librechat.android.core.data.datastore.ThemeDataStore
import com.librechat.android.core.data.di.dataModule
import com.librechat.android.core.data.repository.AgentRepository
import com.librechat.android.core.data.repository.ApiKeyRepository
import com.librechat.android.core.data.repository.AuthRepository
import com.librechat.android.core.data.repository.BalanceRepository
import com.librechat.android.core.data.repository.BannerRepository
import com.librechat.android.core.data.repository.ChatRepository
import com.librechat.android.core.data.repository.ConfigRepository
import com.librechat.android.core.data.repository.ConversationRepository
import com.librechat.android.core.data.repository.DraftRepository
import com.librechat.android.core.data.repository.FileRepository
import com.librechat.android.core.data.repository.KeyRepository
import com.librechat.android.core.data.repository.McpRepository
import com.librechat.android.core.data.repository.MemoryRepository
import com.librechat.android.core.data.repository.MessageRepository
import com.librechat.android.core.data.repository.PresetRepository
import com.librechat.android.core.data.repository.PromptRepository
import com.librechat.android.core.data.repository.SearchRepository
import com.librechat.android.core.data.repository.ShareRepository
import com.librechat.android.core.data.repository.SpeechRepository
import com.librechat.android.core.data.repository.TagRepository
import com.librechat.android.core.data.repository.UserRepository
import com.librechat.android.core.network.api.AgentsApi
import com.librechat.android.core.network.api.ApiKeysApi
import com.librechat.android.core.network.api.AuthApi
import com.librechat.android.core.network.api.BalanceApi
import com.librechat.android.core.network.api.BannerApi
import com.librechat.android.core.network.api.ChatApi
import com.librechat.android.core.network.api.ConfigApi
import com.librechat.android.core.network.api.ConversationsApi
import com.librechat.android.core.network.api.FilesApi
import com.librechat.android.core.network.api.FilesExtApi
import com.librechat.android.core.network.api.KeysApi
import com.librechat.android.core.network.api.McpApi
import com.librechat.android.core.network.api.MemoriesApi
import com.librechat.android.core.network.api.MessagesApi
import com.librechat.android.core.network.api.PresetsApi
import com.librechat.android.core.network.api.PromptsApi
import com.librechat.android.core.network.api.SearchApi
import com.librechat.android.core.network.api.ShareApi
import com.librechat.android.core.network.api.SpeechApi
import com.librechat.android.core.network.api.TagsApi
import com.librechat.android.core.network.api.UserApi
import com.librechat.android.core.network.client.SecureTokenStorage
import com.librechat.android.core.network.client.ServerUrlProvider
import com.librechat.android.core.network.client.TokenManager
import com.librechat.android.core.network.di.networkModule
import com.librechat.android.core.network.sse.SseClient
import com.librechat.android.feature.agents.di.agentsModule
import com.librechat.android.feature.auth.di.authModule
import com.librechat.android.feature.chat.di.chatModule
import com.librechat.android.feature.conversations.di.conversationsModule
import com.librechat.android.feature.files.di.filesModule
import com.librechat.android.feature.settings.di.settingsModule
import com.librechat.android.navigation.appModule
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import org.junit.Test
import org.koin.core.module.Module
import org.koin.test.verify.verify
import kotlin.reflect.KClass
class KoinGraphVerificationTest {
private val allModules: List<Module> = listOf(
commonModule,
networkModule,
dataModule,
appModule,
authModule,
chatModule,
conversationsModule,
settingsModule,
agentsModule,
filesModule,
)
/**
* Integration-level Koin graph verification.
*
* Koin's verify() operates per-module and cannot resolve definitions
* from other modules. This test whitelists all cross-module and
* framework types so every module is verified in a single test class.
* If a type is renamed or removed, this test will catch it.
*/
@OptIn(org.koin.core.annotation.KoinExperimentalAPI::class)
@Test
fun verifyFullKoinGraph() {
val extraTypes = mutableListOf<KClass<*>>(
// Android framework
android.content.Context::class,
android.app.Application::class,
androidx.lifecycle.SavedStateHandle::class,
// core:common provides
CoroutineDispatcher::class,
CoroutineScope::class,
ConnectivityObserver::class,
// core:network provides
TokenManager::class,
SecureTokenStorage::class,
ServerUrlProvider::class,
SseClient::class,
AgentsApi::class,
ApiKeysApi::class,
AuthApi::class,
BalanceApi::class,
BannerApi::class,
ChatApi::class,
ConfigApi::class,
ConversationsApi::class,
FilesApi::class,
FilesExtApi::class,
KeysApi::class,
McpApi::class,
MemoriesApi::class,
MessagesApi::class,
PresetsApi::class,
PromptsApi::class,
SearchApi::class,
ShareApi::class,
SpeechApi::class,
TagsApi::class,
UserApi::class,
// core:data provides
ConfigCacheDataStore::class,
ServerDataStore::class,
SettingsDataStore::class,
ThemeDataStore::class,
AgentRepository::class,
ApiKeyRepository::class,
AuthRepository::class,
BalanceRepository::class,
BannerRepository::class,
ChatRepository::class,
ConfigRepository::class,
ConversationRepository::class,
DraftRepository::class,
FileRepository::class,
KeyRepository::class,
McpRepository::class,
MemoryRepository::class,
MessageRepository::class,
PresetRepository::class,
PromptRepository::class,
SearchRepository::class,
ShareRepository::class,
SpeechRepository::class,
TagRepository::class,
UserRepository::class,
// Wrappers/DSL types that verify can't resolve via constructor
kotlin.Lazy::class,
java.io.File::class,
)
// Types whose libraries aren't on the app test classpath (transitive
// implementation deps). Resolve via reflection at runtime.
val reflectionTypes = listOf(
"io.ktor.client.HttpClient",
"io.ktor.client.engine.HttpClientEngine",
"io.ktor.client.engine.HttpClientEngineConfig",
"kotlinx.serialization.json.Json",
"androidx.datastore.core.DataStore",
)
reflectionTypes.forEach { className ->
extraTypes.add(Class.forName(className).kotlin)
}
allModules.forEach { module ->
module.verify(extraTypes = extraTypes)
}
}
}

View file

@ -9,8 +9,8 @@ Convention plugins that apply consistent Gradle configuration across all modules
| `librechat.android.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 35, minSdk 26, JDK 17 | | `librechat.android.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 35, minSdk 26, JDK 17 |
| `librechat.android.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config | | `librechat.android.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config |
| `librechat.android.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 | | `librechat.android.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 |
| `librechat.android.hilt` | `AndroidHiltConventionPlugin` | Hilt + KSP annotation processing | | `librechat.android.koin` | `AndroidKoinConventionPlugin` | Koin core + Android dependencies |
| `librechat.android.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + hilt + serialization | | `librechat.android.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization |
| `librechat.android.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config | | `librechat.android.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config |
| `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency | | `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency |
@ -28,10 +28,10 @@ the `with` result instead of `Unit`, which causes a Gradle error.
## Version Catalog ## Version Catalog
All dependency versions live in `gradle/libs.versions.toml`. Never hardcode versions in All dependency versions live in `gradle/libs.versions.toml`. Never hardcode versions in
build.gradle.kts files. Use `libs.` accessors (e.g., `libs.hilt.android`, `libs.ktor.client.core`). build.gradle.kts files. Use `libs.` accessors (e.g., `libs.koin.android`, `libs.ktor.client.core`).
## Feature Module Convention ## Feature Module Convention
Feature modules use `id("librechat.android.feature")` which auto-applies library, compose, Feature modules use `id("librechat.android.feature")` which auto-applies library, compose,
hilt, and serialization. They only need to add feature-specific dependencies. koin, and serialization. They only need to add feature-specific dependencies.
Feature modules depend on `:core:*` only, never on other feature modules. Feature modules depend on `:core:*` only, never on other feature modules.

View file

@ -24,10 +24,6 @@ gradlePlugin {
id = "librechat.android.compose" id = "librechat.android.compose"
implementationClass = "AndroidComposeConventionPlugin" implementationClass = "AndroidComposeConventionPlugin"
} }
register("androidHilt") {
id = "librechat.android.hilt"
implementationClass = "AndroidHiltConventionPlugin"
}
register("androidFeature") { register("androidFeature") {
id = "librechat.android.feature" id = "librechat.android.feature"
implementationClass = "AndroidFeatureConventionPlugin" implementationClass = "AndroidFeatureConventionPlugin"
@ -36,6 +32,10 @@ gradlePlugin {
id = "librechat.android.room" id = "librechat.android.room"
implementationClass = "AndroidRoomConventionPlugin" implementationClass = "AndroidRoomConventionPlugin"
} }
register("androidKoin") {
id = "librechat.android.koin"
implementationClass = "AndroidKoinConventionPlugin"
}
register("kotlinSerialization") { register("kotlinSerialization") {
id = "librechat.kotlin.serialization" id = "librechat.kotlin.serialization"
implementationClass = "KotlinSerializationConventionPlugin" implementationClass = "KotlinSerializationConventionPlugin"

View file

@ -7,14 +7,16 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
with(target) { with(target) {
pluginManager.apply("librechat.android.library") pluginManager.apply("librechat.android.library")
pluginManager.apply("librechat.android.compose") pluginManager.apply("librechat.android.compose")
pluginManager.apply("librechat.android.hilt") pluginManager.apply("librechat.android.koin")
dependencies { dependencies {
add("implementation", project(":core:ui")) add("implementation", project(":core:ui"))
add("implementation", project(":core:data")) add("implementation", project(":core:data"))
add("implementation", project(":core:model")) add("implementation", project(":core:model"))
add("implementation", project(":core:common")) add("implementation", project(":core:common"))
add("implementation", libs.findLibrary("hilt-navigation-compose").get()) add("implementation", libs.findLibrary("koin-compose").get())
add("implementation", libs.findLibrary("koin-compose-viewmodel").get())
add("implementation", libs.findLibrary("koin-compose-viewmodel-navigation").get())
add("implementation", libs.findLibrary("navigation-compose").get()) add("implementation", libs.findLibrary("navigation-compose").get())
add("implementation", libs.findBundle("lifecycle").get()) add("implementation", libs.findBundle("lifecycle").get())
} }

View file

@ -2,15 +2,14 @@ import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
class AndroidHiltConventionPlugin : Plugin<Project> { class AndroidKoinConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
with(target) { with(target) {
pluginManager.apply("com.google.dagger.hilt.android")
pluginManager.apply("com.google.devtools.ksp")
dependencies { dependencies {
add("implementation", libs.findLibrary("hilt-android").get()) add("implementation", platform(libs.findLibrary("koin-bom").get()))
add("ksp", libs.findLibrary("hilt-compiler").get()) add("implementation", libs.findLibrary("koin-core").get())
add("implementation", libs.findLibrary("koin-android").get())
add("testImplementation", libs.findLibrary("koin-test").get())
} }
} }
} }

View file

@ -5,6 +5,5 @@ plugins {
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.hilt) apply false
alias(libs.plugins.room) apply false alias(libs.plugins.room) apply false
} }

View file

@ -5,8 +5,7 @@ Pure Kotlin utilities shared by all modules. This is the lowest layer -- no othe
## What This Module Provides ## What This Module Provides
- **Result sealed class** (`result/Result.kt`): `Success<T>`, `Error(exception, message)`, `Loading`. Used by repositories and ViewModels to propagate outcomes. - **Result sealed class** (`result/Result.kt`): `Success<T>`, `Error(exception, message)`, `Loading`. Used by repositories and ViewModels to propagate outcomes.
- **Dispatcher DI** (`di/DispatcherModule.kt`): `@Dispatcher(IO)`, `@Dispatcher(Default)`, `@Dispatcher(Main)` qualifiers for Hilt injection. Always inject dispatchers -- never hardcode `Dispatchers.IO`. - **Dispatcher & Scope DI** (`di/CommonModule.kt`): Named Koin qualifiers (`named("io")`, `named("default")`, `named("main")`) for dispatchers and `named("applicationScope")` for coroutine scope. Always inject dispatchers -- never hardcode `Dispatchers.IO`.
- **CoroutineScope DI** (`di/CoroutineScopeModule.kt`): `@ApplicationScope` for work that outlives ViewModels.
- **Extensions** (`extensions/`): `StringExt`, `DateExt`, `FlowExt` (includes `retryWithBackoff`, `throttleFirst`). - **Extensions** (`extensions/`): `StringExt`, `DateExt`, `FlowExt` (includes `retryWithBackoff`, `throttleFirst`).
- **ConnectivityObserver**: Wraps Android `ConnectivityManager.NetworkCallback` to detect network changes. Used by SSE reconnection logic. - **ConnectivityObserver**: Wraps Android `ConnectivityManager.NetworkCallback` to detect network changes. Used by SSE reconnection logic.
@ -35,7 +34,7 @@ This lives here (not in `:core:network`) because repositories in `:core:data` ca
## Rules ## Rules
- **Pure Kotlin preferred.** Minimal Android dependencies (only what ConnectivityObserver and Hilt require). - **Pure Kotlin preferred.** Minimal Android dependencies (only what ConnectivityObserver and Koin require).
- **No network or data dependencies.** This module must not depend on `:core:network`, `:core:data`, or `:core:model`. - **No network or data dependencies.** This module must not depend on `:core:network`, `:core:data`, or `:core:model`.
- Dependencies: `coroutines-core`, `coroutines-android`, Hilt. - Dependencies: `coroutines-core`, `coroutines-android`, Koin.
- Convention plugin: `librechat.android.library` + `librechat.android.hilt`. - Convention plugin: `librechat.android.library` + `librechat.android.koin`.

View file

@ -1,6 +1,6 @@
plugins { plugins {
id("librechat.android.library") id("librechat.android.library")
id("librechat.android.hilt") id("librechat.android.koin")
} }
android { android {

View file

@ -0,0 +1,26 @@
package com.librechat.android.core.common.di
import com.librechat.android.core.common.network.ConnectivityObserver
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val commonModule = module {
// Dispatchers
single<CoroutineDispatcher>(KoinQualifiers.IO) { Dispatchers.IO }
single<CoroutineDispatcher>(KoinQualifiers.Default) { Dispatchers.Default }
single<CoroutineDispatcher>(KoinQualifiers.Main) { Dispatchers.Main }
// Note: This scope lives for the entire app process lifetime.
// Coroutines launched here persist across logout/login cycles.
// If session-scoped work is needed, use a dedicated session scope instead.
single<CoroutineScope>(KoinQualifiers.ApplicationScope) {
CoroutineScope(SupervisorJob() + get<CoroutineDispatcher>(KoinQualifiers.Default))
}
// Connectivity
single { ConnectivityObserver(androidContext()) }
}

View file

@ -1,26 +0,0 @@
package com.librechat.android.core.common.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class ApplicationScope
@Module
@InstallIn(SingletonComponent::class)
object CoroutineScopeModule {
@Provides
@Singleton
@ApplicationScope
fun provideApplicationScope(
@Dispatcher(LibreChatDispatchers.Default) dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
}

View file

@ -1,35 +0,0 @@
package com.librechat.android.core.common.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Dispatcher(val dispatcher: LibreChatDispatchers)
enum class LibreChatDispatchers {
IO,
Default,
Main,
}
@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {
@Provides
@Dispatcher(LibreChatDispatchers.IO)
fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Dispatcher(LibreChatDispatchers.Default)
fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@Provides
@Dispatcher(LibreChatDispatchers.Main)
fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
}

View file

@ -0,0 +1,12 @@
package com.librechat.android.core.common.di
import org.koin.core.qualifier.named
object KoinQualifiers {
val IO = named("io")
val Default = named("default")
val Main = named("main")
val ApplicationScope = named("applicationScope")
val Streaming = named("streaming")
val Refresh = named("refresh")
}

View file

@ -6,17 +6,13 @@ import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow 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
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class ConnectivityObserver(
class ConnectivityObserver @Inject constructor( private val context: Context,
@ApplicationContext private val context: Context,
) { ) {
// Permission is declared in app module's AndroidManifest.xml // Permission is declared in app module's AndroidManifest.xml
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")

View file

@ -0,0 +1,16 @@
package com.librechat.android.core.common.di
import org.junit.Test
import org.koin.test.verify.verify
class CommonModuleVerificationTest {
@Test
fun verifyCommonModule() {
commonModule.verify(
extraTypes = listOf(
android.content.Context::class,
android.app.Application::class,
),
)
}
}

View file

@ -24,7 +24,7 @@ interface ConversationRepository {
} }
``` ```
All impls are `@Inject constructor(api, dao, mapper, @Dispatcher(IO) dispatcher)`. All impls take constructor parameters (api, dao, mapper, dispatcher) wired via Koin named qualifiers.
### Read-Through Cache ### Read-Through Cache
@ -46,16 +46,16 @@ Wrap `EncryptedSharedPreferences` in try/catch -- some OEM devices have broken K
### DataModule Bindings ### DataModule Bindings
`DataModule` is a Hilt `@Module` that `@Binds` repository interfaces to their implementations and `@Provides` the Room database, DAOs, and DataStore instances. `DataModule.kt` defines a Koin `module { }` that binds repository interfaces to their implementations via `singleOf(::Impl) bind Interface::class` and provides the Room database, DAOs, and DataStore instances.
## Room TypeConverters ## Room TypeConverters
`Converters.kt` handles JSON serialization for complex Room fields: `List<String>`, `MessageContentPart` lists, `Feedback`, `FileReference` lists, etc. Uses the same `Json` instance from the Hilt graph. `Converters.kt` handles JSON serialization for complex Room fields: `List<String>`, `MessageContentPart` lists, `Feedback`, `FileReference` lists, etc. Uses the same `Json` instance from the Koin graph.
## Rules ## Rules
- Dependencies: `:core:network`, `:core:model`, `:core:common`, Room, DataStore, security-crypto, Ktor, kotlinx-serialization, Timber, Hilt. - Dependencies: `:core:network`, `:core:model`, `:core:common`, Room, DataStore, security-crypto, Ktor, kotlinx-serialization, Timber, Koin.
- Convention plugins: `librechat.android.library` + `librechat.android.hilt` + `librechat.android.room` + `librechat.kotlin.serialization`. - Convention plugins: `librechat.android.library` + `librechat.android.koin` + `librechat.android.room` + `librechat.kotlin.serialization`.
- Repositories must use `safeApiCall` from `:core:common` for all network calls. - Repositories must use `safeApiCall` from `:core:common` for all network calls.
- Entities are internal to this module -- feature modules work with domain models from `:core:model`. - Entities are internal to this module -- feature modules work with domain models from `:core:model`.
- All DAO read methods that the UI observes should return `Flow`, not suspend functions. - All DAO read methods that the UI observes should return `Flow`, not suspend functions.
@ -63,7 +63,7 @@ Wrap `EncryptedSharedPreferences` in try/catch -- some OEM devices have broken K
### New Repositories (Round 2) ### New Repositories (Round 2)
- `MemoryRepository` / `MemoryRepositoryImpl` — wraps `MemoriesApi` for memory CRUD + preferences - `MemoryRepository` / `MemoryRepositoryImpl` — wraps `MemoriesApi` for memory CRUD + preferences
- `McpRepository` / `McpRepositoryImpl` — wraps `McpApi` for server CRUD, tools, connection status, reinitialize - `McpRepository` / `McpRepositoryImpl` — wraps `McpApi` for server CRUD, tools, connection status, reinitialize
- Both bound in `DataModule.kt` via `@Binds` - Both bound in `DataModule.kt` via `singleOf`
- Both use `safeApiCall` — no local caching (server is sole source of truth for memories and MCP) - Both use `safeApiCall` — no local caching (server is sole source of truth for memories and MCP)
- **Gotcha**: Memory delete/update use `key` as identifier (not a separate ID field) - **Gotcha**: Memory delete/update use `key` as identifier (not a separate ID field)
- **Gotcha**: MCP server operations use `serverName` as identifier - **Gotcha**: MCP server operations use `serverName` as identifier
@ -71,4 +71,4 @@ Wrap `EncryptedSharedPreferences` in try/catch -- some OEM devices have broken K
### New Repositories (Round 3) ### New Repositories (Round 3)
- `BannerRepository` / `BannerRepositoryImpl` — wraps `BannerApi` for fetching server banners - `BannerRepository` / `BannerRepositoryImpl` — wraps `BannerApi` for fetching server banners
- `AgentRepository.getAgentsPaginated()` — server-side paginated agent fetch, maps response to `PaginatedAgents` domain model - `AgentRepository.getAgentsPaginated()` — server-side paginated agent fetch, maps response to `PaginatedAgents` domain model
- Both bound in `DataModule.kt` via `@Binds`, both use `safeApiCall`, no local caching - Both bound in `DataModule.kt` via `singleOf`, both use `safeApiCall`, no local caching

View file

@ -1,6 +1,6 @@
plugins { plugins {
id("librechat.android.library") id("librechat.android.library")
id("librechat.android.hilt") id("librechat.android.koin")
id("librechat.android.room") id("librechat.android.room")
id("librechat.kotlin.serialization") id("librechat.kotlin.serialization")
} }

View file

@ -12,11 +12,8 @@ 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 timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class ConfigCacheDataStore(
class ConfigCacheDataStore @Inject constructor(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
private val json: Json, private val json: Json,
) { ) {

View file

@ -11,11 +11,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class ServerDataStore(
class ServerDataStore @Inject constructor(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
) : ServerUrlProvider { ) : ServerUrlProvider {

View file

@ -9,8 +9,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class LatexRenderer { enum class LatexRenderer {
NATIVE, KATEX; NATIVE, KATEX;
@ -46,8 +44,7 @@ enum class ChatFontSize {
} }
} }
@Singleton class SettingsDataStore(
class SettingsDataStore @Inject constructor(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
) { ) {
val latexRenderer: Flow<LatexRenderer> = dataStore.data.map { prefs -> val latexRenderer: Flow<LatexRenderer> = dataStore.data.map { prefs ->

View file

@ -6,15 +6,12 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class ThemeMode { enum class ThemeMode {
SYSTEM, LIGHT, DARK SYSTEM, LIGHT, DARK
} }
@Singleton class ThemeDataStore(
class ThemeDataStore @Inject constructor(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
) { ) {

View file

@ -8,8 +8,6 @@ import com.librechat.android.core.model.response.RefreshResponse
import com.librechat.android.core.network.client.CookieHelper import com.librechat.android.core.network.client.CookieHelper
import com.librechat.android.core.network.client.SecureTokenStorage import com.librechat.android.core.network.client.SecureTokenStorage
import com.librechat.android.core.network.client.TokenManager import com.librechat.android.core.network.client.TokenManager
import com.librechat.android.core.network.di.RefreshClient
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.ClientRequestException
@ -25,13 +23,10 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class TokenDataStore(
class TokenDataStore @Inject constructor( context: Context,
@ApplicationContext context: Context, private val refreshClient: Lazy<HttpClient>,
@RefreshClient private val refreshClient: dagger.Lazy<HttpClient>,
) : TokenManager, SecureTokenStorage { ) : TokenManager, SecureTokenStorage {
private val masterKey: MasterKey = MasterKey.Builder(context) private val masterKey: MasterKey = MasterKey.Builder(context)
@ -81,7 +76,7 @@ class TokenDataStore @Inject constructor(
// The LibreChat backend reads from req.cookies.refreshToken first, // The LibreChat backend reads from req.cookies.refreshToken first,
// falling back to req.body.refreshToken. Sending both ensures // falling back to req.body.refreshToken. Sending both ensures
// compatibility across backend versions. // compatibility across backend versions.
val httpResponse: HttpResponse = refreshClient.get() val httpResponse: HttpResponse = refreshClient.value
.post("/api/auth/refresh") { .post("/api/auth/refresh") {
header("Cookie", "refreshToken=$storedRefreshToken") header("Cookie", "refreshToken=$storedRefreshToken")
setBody(mapOf("refreshToken" to storedRefreshToken)) setBody(mapOf("refreshToken" to storedRefreshToken))

View file

@ -4,22 +4,24 @@ import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import androidx.room.Room
import com.librechat.android.core.common.di.KoinQualifiers
import com.librechat.android.core.data.datastore.ConfigCacheDataStore
import com.librechat.android.core.data.datastore.ServerDataStore import com.librechat.android.core.data.datastore.ServerDataStore
import com.librechat.android.core.data.datastore.SettingsDataStore
import com.librechat.android.core.data.datastore.ThemeDataStore
import com.librechat.android.core.data.datastore.TokenDataStore import com.librechat.android.core.data.datastore.TokenDataStore
import com.librechat.android.core.data.db.LibreChatDatabase
import com.librechat.android.core.data.repository.AgentRepository import com.librechat.android.core.data.repository.AgentRepository
import com.librechat.android.core.data.repository.AgentRepositoryImpl import com.librechat.android.core.data.repository.AgentRepositoryImpl
import com.librechat.android.core.data.repository.ApiKeyRepository import com.librechat.android.core.data.repository.ApiKeyRepository
import com.librechat.android.core.data.repository.ApiKeyRepositoryImpl import com.librechat.android.core.data.repository.ApiKeyRepositoryImpl
import com.librechat.android.core.data.repository.AuthRepository import com.librechat.android.core.data.repository.AuthRepository
import com.librechat.android.core.data.repository.AuthRepositoryImpl
import com.librechat.android.core.data.repository.BalanceRepository import com.librechat.android.core.data.repository.BalanceRepository
import com.librechat.android.core.data.repository.BalanceRepositoryImpl import com.librechat.android.core.data.repository.BalanceRepositoryImpl
import com.librechat.android.core.data.repository.BannerRepository import com.librechat.android.core.data.repository.BannerRepository
import com.librechat.android.core.data.repository.BannerRepositoryImpl import com.librechat.android.core.data.repository.BannerRepositoryImpl
import com.librechat.android.core.data.repository.SearchRepository
import com.librechat.android.core.data.repository.SearchRepositoryImpl
import com.librechat.android.core.data.repository.TagRepository
import com.librechat.android.core.data.repository.TagRepositoryImpl
import com.librechat.android.core.data.repository.AuthRepositoryImpl
import com.librechat.android.core.data.repository.ChatRepository import com.librechat.android.core.data.repository.ChatRepository
import com.librechat.android.core.data.repository.ChatRepositoryImpl import com.librechat.android.core.data.repository.ChatRepositoryImpl
import com.librechat.android.core.data.repository.ConfigRepository import com.librechat.android.core.data.repository.ConfigRepository
@ -42,132 +44,108 @@ import com.librechat.android.core.data.repository.PresetRepository
import com.librechat.android.core.data.repository.PresetRepositoryImpl import com.librechat.android.core.data.repository.PresetRepositoryImpl
import com.librechat.android.core.data.repository.PromptRepository 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.SearchRepositoryImpl
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
import com.librechat.android.core.data.repository.SpeechRepositoryImpl import com.librechat.android.core.data.repository.SpeechRepositoryImpl
import com.librechat.android.core.data.repository.TagRepository
import com.librechat.android.core.data.repository.TagRepositoryImpl
import com.librechat.android.core.data.repository.UserRepository import com.librechat.android.core.data.repository.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.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 com.librechat.android.core.network.client.TokenManager
import dagger.Binds import io.ktor.client.HttpClient
import dagger.Module import org.koin.android.ext.koin.androidContext
import dagger.Provides import org.koin.core.module.dsl.singleOf
import dagger.hilt.InstallIn import org.koin.dsl.bind
import dagger.hilt.android.qualifiers.ApplicationContext import org.koin.dsl.binds
import dagger.hilt.components.SingletonComponent import org.koin.dsl.module
import javax.inject.Singleton
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore( private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "librechat_settings", name = "librechat_settings",
) )
@Module val dataModule = module {
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds // --- Database + DAOs ---
@Singleton
abstract fun bindTokenManager(impl: TokenDataStore): TokenManager
@Binds single {
@Singleton Room.databaseBuilder(androidContext(), LibreChatDatabase::class.java, "librechat.db").build()
abstract fun bindSecureTokenStorage(impl: TokenDataStore): SecureTokenStorage
@Binds
@Singleton
abstract fun bindServerUrlProvider(impl: ServerDataStore): ServerUrlProvider
@Binds
@Singleton
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
@Binds
@Singleton
abstract fun bindBalanceRepository(impl: BalanceRepositoryImpl): BalanceRepository
@Binds
@Singleton
abstract fun bindConfigRepository(impl: ConfigRepositoryImpl): ConfigRepository
@Binds
@Singleton
abstract fun bindConversationRepository(impl: ConversationRepositoryImpl): ConversationRepository
@Binds
@Singleton
abstract fun bindDraftRepository(impl: DraftRepositoryImpl): DraftRepository
@Binds
@Singleton
abstract fun bindMessageRepository(impl: MessageRepositoryImpl): MessageRepository
@Binds
@Singleton
abstract fun bindChatRepository(impl: ChatRepositoryImpl): ChatRepository
@Binds
@Singleton
abstract fun bindAgentRepository(impl: AgentRepositoryImpl): AgentRepository
@Binds
@Singleton
abstract fun bindTagRepository(impl: TagRepositoryImpl): TagRepository
@Binds
@Singleton
abstract fun bindSearchRepository(impl: SearchRepositoryImpl): SearchRepository
@Binds
@Singleton
abstract fun bindFileRepository(impl: FileRepositoryImpl): FileRepository
@Binds
@Singleton
abstract fun bindKeyRepository(impl: KeyRepositoryImpl): KeyRepository
@Binds
@Singleton
abstract fun bindApiKeyRepository(impl: ApiKeyRepositoryImpl): ApiKeyRepository
@Binds
@Singleton
abstract fun bindMcpRepository(impl: McpRepositoryImpl): McpRepository
@Binds
@Singleton
abstract fun bindMemoryRepository(impl: MemoryRepositoryImpl): MemoryRepository
@Binds
@Singleton
abstract fun bindPresetRepository(impl: PresetRepositoryImpl): PresetRepository
@Binds
@Singleton
abstract fun bindPromptRepository(impl: PromptRepositoryImpl): PromptRepository
@Binds
@Singleton
abstract fun bindShareRepository(impl: ShareRepositoryImpl): ShareRepository
@Binds
@Singleton
abstract fun bindSpeechRepository(impl: SpeechRepositoryImpl): SpeechRepository
@Binds
@Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
@Binds
@Singleton
abstract fun bindBannerRepository(impl: BannerRepositoryImpl): BannerRepository
companion object {
@Provides
@Singleton
fun provideDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.settingsDataStore
} }
single { get<LibreChatDatabase>().conversationDao() }
single { get<LibreChatDatabase>().messageDao() }
single { get<LibreChatDatabase>().fileDao() }
single { get<LibreChatDatabase>().agentDao() }
single { get<LibreChatDatabase>().presetDao() }
single { get<LibreChatDatabase>().conversationTagDao() }
single { get<LibreChatDatabase>().draftDao() }
// --- DataStore ---
single<DataStore<Preferences>> { androidContext().settingsDataStore }
// --- Datastores ---
single {
TokenDataStore(
context = androidContext(),
refreshClient = lazy(LazyThreadSafetyMode.NONE) { get<HttpClient>(KoinQualifiers.Refresh) },
)
} binds arrayOf(TokenManager::class, SecureTokenStorage::class)
singleOf(::ServerDataStore) bind ServerUrlProvider::class
singleOf(::ThemeDataStore)
singleOf(::ConfigCacheDataStore)
singleOf(::SettingsDataStore)
// --- Repositories (special wiring) ---
single<AuthRepository> {
AuthRepositoryImpl(
authApi = get(),
userApi = get(),
tokenManager = get(),
context = androidContext(),
)
}
single<ChatRepository> {
ChatRepositoryImpl(
chatApi = get(),
sseClient = get(),
sseHttpClient = get(KoinQualifiers.Streaming),
connectivityObserver = get(),
)
}
single<DraftRepository> {
DraftRepositoryImpl(
draftDao = get(),
ioDispatcher = get(KoinQualifiers.IO),
)
}
// --- Repositories (simple auto-wiring) ---
singleOf(::BalanceRepositoryImpl) bind BalanceRepository::class
singleOf(::ConfigRepositoryImpl) bind ConfigRepository::class
singleOf(::ConversationRepositoryImpl) bind ConversationRepository::class
singleOf(::MessageRepositoryImpl) bind MessageRepository::class
singleOf(::FileRepositoryImpl) bind FileRepository::class
singleOf(::AgentRepositoryImpl) bind AgentRepository::class
singleOf(::TagRepositoryImpl) bind TagRepository::class
singleOf(::SearchRepositoryImpl) bind SearchRepository::class
singleOf(::KeyRepositoryImpl) bind KeyRepository::class
singleOf(::ApiKeyRepositoryImpl) bind ApiKeyRepository::class
singleOf(::McpRepositoryImpl) bind McpRepository::class
singleOf(::MemoryRepositoryImpl) bind MemoryRepository::class
singleOf(::PresetRepositoryImpl) bind PresetRepository::class
singleOf(::PromptRepositoryImpl) bind PromptRepository::class
singleOf(::ShareRepositoryImpl) bind ShareRepository::class
singleOf(::SpeechRepositoryImpl) bind SpeechRepository::class
singleOf(::UserRepositoryImpl) bind UserRepository::class
singleOf(::BannerRepositoryImpl) bind BannerRepository::class
} }

View file

@ -1,40 +0,0 @@
package com.librechat.android.core.data.di
import android.content.Context
import androidx.room.Room
import com.librechat.android.core.data.db.LibreChatDatabase
import com.librechat.android.core.data.db.dao.AgentDao
import com.librechat.android.core.data.db.dao.ConversationDao
import com.librechat.android.core.data.db.dao.ConversationTagDao
import com.librechat.android.core.data.db.dao.DraftDao
import com.librechat.android.core.data.db.dao.FileDao
import com.librechat.android.core.data.db.dao.MessageDao
import com.librechat.android.core.data.db.dao.PresetDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): LibreChatDatabase =
Room.databaseBuilder(
context,
LibreChatDatabase::class.java,
"librechat.db",
).build()
@Provides fun provideConversationDao(db: LibreChatDatabase): ConversationDao = db.conversationDao()
@Provides fun provideMessageDao(db: LibreChatDatabase): MessageDao = db.messageDao()
@Provides fun provideFileDao(db: LibreChatDatabase): FileDao = db.fileDao()
@Provides fun provideAgentDao(db: LibreChatDatabase): AgentDao = db.agentDao()
@Provides fun providePresetDao(db: LibreChatDatabase): PresetDao = db.presetDao()
@Provides fun provideConversationTagDao(db: LibreChatDatabase): ConversationTagDao = db.conversationTagDao()
@Provides fun provideDraftDao(db: LibreChatDatabase): DraftDao = db.draftDao()
}

View file

@ -18,11 +18,8 @@ import com.librechat.android.core.model.request.RevertAgentRequest
import com.librechat.android.core.model.request.ToolCallRequest import com.librechat.android.core.model.request.ToolCallRequest
import com.librechat.android.core.model.request.UpdateAgentRequest import com.librechat.android.core.model.request.UpdateAgentRequest
import com.librechat.android.core.network.api.AgentsApi import com.librechat.android.core.network.api.AgentsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class AgentRepositoryImpl(
class AgentRepositoryImpl @Inject constructor(
private val agentsApi: AgentsApi, private val agentsApi: AgentsApi,
) : AgentRepository { ) : AgentRepository {

View file

@ -5,11 +5,8 @@ import com.librechat.android.core.common.result.safeApiCall
import com.librechat.android.core.model.ApiKey import com.librechat.android.core.model.ApiKey
import com.librechat.android.core.model.request.CreateApiKeyRequest import com.librechat.android.core.model.request.CreateApiKeyRequest
import com.librechat.android.core.network.api.ApiKeysApi import com.librechat.android.core.network.api.ApiKeysApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class ApiKeyRepositoryImpl(
class ApiKeyRepositoryImpl @Inject constructor(
private val apiKeysApi: ApiKeysApi, private val apiKeysApi: ApiKeysApi,
) : ApiKeyRepository { ) : ApiKeyRepository {

View file

@ -9,18 +9,14 @@ 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 dagger.hilt.android.qualifiers.ApplicationContext
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class AuthRepositoryImpl(
class AuthRepositoryImpl @Inject constructor(
private val authApi: AuthApi, private val authApi: AuthApi,
private val userApi: UserApi, private val userApi: UserApi,
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
@ApplicationContext private val context: Context, private val context: Context,
) : AuthRepository { ) : AuthRepository {
override suspend fun login(email: String, password: String): Result<LoginOutcome> { override suspend fun login(email: String, password: String): Result<LoginOutcome> {

View file

@ -4,11 +4,8 @@ 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.Balance import com.librechat.android.core.model.Balance
import com.librechat.android.core.network.api.BalanceApi import com.librechat.android.core.network.api.BalanceApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class BalanceRepositoryImpl(
class BalanceRepositoryImpl @Inject constructor(
private val balanceApi: BalanceApi, private val balanceApi: BalanceApi,
) : BalanceRepository { ) : BalanceRepository {

View file

@ -4,11 +4,8 @@ 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.Banner import com.librechat.android.core.model.Banner
import com.librechat.android.core.network.api.BannerApi import com.librechat.android.core.network.api.BannerApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class BannerRepositoryImpl(
class BannerRepositoryImpl @Inject constructor(
private val bannerApi: BannerApi, private val bannerApi: BannerApi,
) : BannerRepository { ) : BannerRepository {

View file

@ -11,20 +11,16 @@ import com.librechat.android.core.model.request.ChatRequest
import com.librechat.android.core.model.request.EphemeralAgent import com.librechat.android.core.model.request.EphemeralAgent
import com.librechat.android.core.model.response.ChatStatusResponse import com.librechat.android.core.model.response.ChatStatusResponse
import com.librechat.android.core.network.api.ChatApi import com.librechat.android.core.network.api.ChatApi
import com.librechat.android.core.network.di.StreamingClient
import com.librechat.android.core.network.sse.SseClient import com.librechat.android.core.network.sse.SseClient
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class ChatRepositoryImpl(
class ChatRepositoryImpl @Inject constructor(
private val chatApi: ChatApi, private val chatApi: ChatApi,
private val sseClient: SseClient, private val sseClient: SseClient,
@StreamingClient private val sseHttpClient: HttpClient, private val sseHttpClient: HttpClient,
private val connectivityObserver: ConnectivityObserver, private val connectivityObserver: ConnectivityObserver,
) : ChatRepository { ) : ChatRepository {

View file

@ -15,11 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import timber.log.Timber import timber.log.Timber
import java.io.IOException import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class ConfigRepositoryImpl(
class ConfigRepositoryImpl @Inject constructor(
private val configApi: ConfigApi, private val configApi: ConfigApi,
private val configCache: ConfigCacheDataStore, private val configCache: ConfigCacheDataStore,
) : ConfigRepository { ) : ConfigRepository {

View file

@ -12,11 +12,8 @@ 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 timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class ConversationRepositoryImpl(
class ConversationRepositoryImpl @Inject constructor(
private val conversationsApi: ConversationsApi, private val conversationsApi: ConversationsApi,
private val conversationDao: ConversationDao, private val conversationDao: ConversationDao,
) : ConversationRepository { ) : ConversationRepository {

View file

@ -1,7 +1,5 @@
package com.librechat.android.core.data.repository package com.librechat.android.core.data.repository
import com.librechat.android.core.common.di.Dispatcher
import com.librechat.android.core.common.di.LibreChatDispatchers
import com.librechat.android.core.data.db.dao.DraftDao import com.librechat.android.core.data.db.dao.DraftDao
import com.librechat.android.core.data.db.entity.DraftEntity import com.librechat.android.core.data.db.entity.DraftEntity
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@ -13,13 +11,10 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class DraftRepositoryImpl(
class DraftRepositoryImpl @Inject constructor(
private val draftDao: DraftDao, private val draftDao: DraftDao,
@Dispatcher(LibreChatDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher,
) : DraftRepository { ) : DraftRepository {
private val cache = ConcurrentHashMap<String, String>() private val cache = ConcurrentHashMap<String, String>()

View file

@ -7,11 +7,8 @@ import com.librechat.android.core.model.request.DeleteFileEntry
import com.librechat.android.core.model.request.DeleteFilesRequest import com.librechat.android.core.model.request.DeleteFilesRequest
import com.librechat.android.core.network.api.FilesApi import com.librechat.android.core.network.api.FilesApi
import com.librechat.android.core.network.api.FilesExtApi import com.librechat.android.core.network.api.FilesExtApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class FileRepositoryImpl(
class FileRepositoryImpl @Inject constructor(
private val filesApi: FilesApi, private val filesApi: FilesApi,
private val filesExtApi: FilesExtApi, private val filesExtApi: FilesExtApi,
) : FileRepository { ) : FileRepository {

View file

@ -5,11 +5,8 @@ import com.librechat.android.core.common.result.safeApiCall
import com.librechat.android.core.model.UserKey import com.librechat.android.core.model.UserKey
import com.librechat.android.core.model.request.UpdateKeyRequest import com.librechat.android.core.model.request.UpdateKeyRequest
import com.librechat.android.core.network.api.KeysApi import com.librechat.android.core.network.api.KeysApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class KeyRepositoryImpl(
class KeyRepositoryImpl @Inject constructor(
private val keysApi: KeysApi, private val keysApi: KeysApi,
) : KeyRepository { ) : KeyRepository {

View file

@ -10,11 +10,8 @@ import com.librechat.android.core.model.mcp.McpServerStatus
import com.librechat.android.core.model.mcp.McpServerType import com.librechat.android.core.model.mcp.McpServerType
import com.librechat.android.core.model.mcp.McpTool import com.librechat.android.core.model.mcp.McpTool
import com.librechat.android.core.network.api.McpApi import com.librechat.android.core.network.api.McpApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class McpRepositoryImpl(
class McpRepositoryImpl @Inject constructor(
private val mcpApi: McpApi, private val mcpApi: McpApi,
) : McpRepository { ) : McpRepository {

View file

@ -8,11 +8,8 @@ import com.librechat.android.core.model.request.CreateMemoryRequest
import com.librechat.android.core.model.request.UpdateMemoryPreferencesRequest import com.librechat.android.core.model.request.UpdateMemoryPreferencesRequest
import com.librechat.android.core.model.request.UpdateMemoryRequest import com.librechat.android.core.model.request.UpdateMemoryRequest
import com.librechat.android.core.network.api.MemoriesApi import com.librechat.android.core.network.api.MemoriesApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class MemoryRepositoryImpl(
class MemoryRepositoryImpl @Inject constructor(
private val memoriesApi: MemoriesApi, private val memoriesApi: MemoriesApi,
) : MemoryRepository { ) : MemoryRepository {

View file

@ -13,11 +13,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class MessageRepositoryImpl(
class MessageRepositoryImpl @Inject constructor(
private val messagesApi: MessagesApi, private val messagesApi: MessagesApi,
private val messageDao: MessageDao, private val messageDao: MessageDao,
) : MessageRepository { ) : MessageRepository {

View file

@ -4,11 +4,8 @@ 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.Preset import com.librechat.android.core.model.Preset
import com.librechat.android.core.network.api.PresetsApi import com.librechat.android.core.network.api.PresetsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class PresetRepositoryImpl(
class PresetRepositoryImpl @Inject constructor(
private val presetsApi: PresetsApi, private val presetsApi: PresetsApi,
) : PresetRepository { ) : PresetRepository {

View file

@ -10,11 +10,8 @@ import com.librechat.android.core.model.request.UpdatePromptGroupRequest
import com.librechat.android.core.model.request.UpdatePromptTagRequest import com.librechat.android.core.model.request.UpdatePromptTagRequest
import com.librechat.android.core.model.response.PromptGroupListResponse import com.librechat.android.core.model.response.PromptGroupListResponse
import com.librechat.android.core.network.api.PromptsApi import com.librechat.android.core.network.api.PromptsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class PromptRepositoryImpl(
class PromptRepositoryImpl @Inject constructor(
private val promptsApi: PromptsApi, private val promptsApi: PromptsApi,
) : PromptRepository { ) : PromptRepository {

View file

@ -4,11 +4,8 @@ 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.Conversation import com.librechat.android.core.model.Conversation
import com.librechat.android.core.network.api.ConversationsApi import com.librechat.android.core.network.api.ConversationsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class SearchRepositoryImpl(
class SearchRepositoryImpl @Inject constructor(
private val conversationsApi: ConversationsApi, private val conversationsApi: ConversationsApi,
) : SearchRepository { ) : SearchRepository {

View file

@ -7,11 +7,8 @@ import com.librechat.android.core.model.response.ShareLinkCheckResponse
import com.librechat.android.core.model.response.SharedLinksResponse import com.librechat.android.core.model.response.SharedLinksResponse
import com.librechat.android.core.network.api.ShareApi import com.librechat.android.core.network.api.ShareApi
import com.librechat.android.core.network.client.ServerUrlProvider import com.librechat.android.core.network.client.ServerUrlProvider
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class ShareRepositoryImpl(
class ShareRepositoryImpl @Inject constructor(
private val shareApi: ShareApi, private val shareApi: ShareApi,
private val serverUrlProvider: ServerUrlProvider, private val serverUrlProvider: ServerUrlProvider,
) : ShareRepository { ) : ShareRepository {

View file

@ -7,11 +7,8 @@ import com.librechat.android.core.model.speech.SpeechToTextResponse
import com.librechat.android.core.model.speech.TextToSpeechRequest import com.librechat.android.core.model.speech.TextToSpeechRequest
import com.librechat.android.core.model.speech.TtsVoice import com.librechat.android.core.model.speech.TtsVoice
import com.librechat.android.core.network.api.SpeechApi import com.librechat.android.core.network.api.SpeechApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class SpeechRepositoryImpl(
class SpeechRepositoryImpl @Inject constructor(
private val speechApi: SpeechApi, private val speechApi: SpeechApi,
) : SpeechRepository { ) : SpeechRepository {

View file

@ -4,11 +4,8 @@ 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.ConversationTag import com.librechat.android.core.model.ConversationTag
import com.librechat.android.core.network.api.TagsApi import com.librechat.android.core.network.api.TagsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class TagRepositoryImpl(
class TagRepositoryImpl @Inject constructor(
private val tagsApi: TagsApi, private val tagsApi: TagsApi,
) : TagRepository { ) : TagRepository {

View file

@ -8,11 +8,8 @@ import com.librechat.android.core.model.request.ResendVerificationRequest
import com.librechat.android.core.model.request.VerifyEmailRequest import com.librechat.android.core.model.request.VerifyEmailRequest
import com.librechat.android.core.model.response.TermsResponse import com.librechat.android.core.model.response.TermsResponse
import com.librechat.android.core.network.api.UserApi import com.librechat.android.core.network.api.UserApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class UserRepositoryImpl(
class UserRepositoryImpl @Inject constructor(
private val userApi: UserApi, private val userApi: UserApi,
) : UserRepository { ) : UserRepository {

View file

@ -0,0 +1,45 @@
package com.librechat.android.core.data.di
import org.junit.Test
import org.koin.test.verify.verify
class DataModuleVerificationTest {
@Test
fun verifyDataModule() {
dataModule.verify(
extraTypes = listOf(
android.content.Context::class,
android.app.Application::class,
androidx.lifecycle.SavedStateHandle::class,
kotlin.Lazy::class,
io.ktor.client.HttpClient::class,
kotlinx.serialization.json.Json::class,
kotlinx.coroutines.CoroutineDispatcher::class,
com.librechat.android.core.common.network.ConnectivityObserver::class,
com.librechat.android.core.network.sse.SseClient::class,
com.librechat.android.core.network.api.AuthApi::class,
com.librechat.android.core.network.api.UserApi::class,
com.librechat.android.core.network.api.ChatApi::class,
com.librechat.android.core.network.api.ConversationsApi::class,
com.librechat.android.core.network.api.MessagesApi::class,
com.librechat.android.core.network.api.FilesApi::class,
com.librechat.android.core.network.api.FilesExtApi::class,
com.librechat.android.core.network.api.AgentsApi::class,
com.librechat.android.core.network.api.PresetsApi::class,
com.librechat.android.core.network.api.PromptsApi::class,
com.librechat.android.core.network.api.TagsApi::class,
com.librechat.android.core.network.api.ShareApi::class,
com.librechat.android.core.network.api.ConfigApi::class,
com.librechat.android.core.network.api.BalanceApi::class,
com.librechat.android.core.network.api.SearchApi::class,
com.librechat.android.core.network.api.KeysApi::class,
com.librechat.android.core.network.api.ApiKeysApi::class,
com.librechat.android.core.network.api.McpApi::class,
com.librechat.android.core.network.api.MemoriesApi::class,
com.librechat.android.core.network.api.SpeechApi::class,
com.librechat.android.core.network.api.BannerApi::class,
androidx.datastore.core.DataStore::class,
),
)
}
}

View file

@ -4,11 +4,11 @@ Ktor HttpClient, API service classes, SSE streaming client, auth interceptor. Al
## What This Module Provides ## What This Module Provides
- **Ktor HttpClient factory** (`di/NetworkModule.kt`): Provides singleton `HttpClient(OkHttp)` with ContentNegotiation, Logging, HttpTimeout, HttpRequestRetry, and AuthInterceptorPlugin. - **Ktor HttpClient factory** (`di/NetworkModule.kt`): Provides singleton `HttpClient(OkHttp)` with ContentNegotiation, Logging, HttpTimeout, HttpRequestRetry, and AuthInterceptorPlugin via Koin module.
- **AuthInterceptorPlugin** (`client/AuthInterceptor.kt`): Custom Ktor plugin that injects `Authorization: Bearer` on outgoing requests and retries on 401 after refreshing tokens. Skips auth endpoints (`auth/login`, `auth/register`, `auth/refresh`, etc.). - **AuthInterceptorPlugin** (`client/AuthInterceptor.kt`): Custom Ktor plugin that injects `Authorization: Bearer` on outgoing requests and retries on 401 after refreshing tokens. Skips auth endpoints (`auth/login`, `auth/register`, `auth/refresh`, etc.).
- **TokenManager interface** (`client/TokenManager.kt`): `getAccessToken()`, `setTokens()`, `refreshAccessToken()` (Mutex-guarded), `clearTokens()`, `sessionExpiredFlow`. Implemented in `:core:data`. - **TokenManager interface** (`client/TokenManager.kt`): `getAccessToken()`, `setTokens()`, `refreshAccessToken()` (Mutex-guarded), `clearTokens()`, `sessionExpiredFlow`. Implemented in `:core:data`.
- **ServerUrlProvider interface** (`client/ServerUrlProvider.kt`): Resolves the user-configured base URL. Implemented in `:core:data`. - **ServerUrlProvider interface** (`client/ServerUrlProvider.kt`): Resolves the user-configured base URL. Implemented in `:core:data`.
- **API services** (`api/`): One class per domain -- `AuthApi`, `ConversationsApi`, `MessagesApi`, `ChatStreamApi`, `FilesApi`, `AgentsApi`, `PresetsApi`, `PromptsApi`, `TagsApi`, `ShareApi`, `ConfigApi`, `EndpointsApi`, `BalanceApi`, `UserApi`, `SearchApi`. Each is `@Inject constructor(client: HttpClient)`. - **API services** (`api/`): One class per domain -- `AuthApi`, `ConversationsApi`, `MessagesApi`, `ChatStreamApi`, `FilesApi`, `AgentsApi`, `PresetsApi`, `PromptsApi`, `TagsApi`, `ShareApi`, `ConfigApi`, `EndpointsApi`, `BalanceApi`, `UserApi`, `SearchApi`. Each takes `HttpClient` as a constructor parameter, wired via Koin.
- **SSE client** (`sse/`): `SseClient`, `SseEvent`, `SseEventParser`, `SseConnectionManager`. - **SSE client** (`sse/`): `SseClient`, `SseEvent`, `SseEventParser`, `SseConnectionManager`.
- **DTO mappers** (`mapper/`): Convert network DTOs to domain models from `:core:model`. - **DTO mappers** (`mapper/`): Convert network DTOs to domain models from `:core:model`.
@ -40,7 +40,7 @@ On reconnection, append `?resume=true` to get a `sync` event with `runSteps[]` +
## Rules ## Rules
- Dependencies: `:core:model`, `:core:common`, Ktor bundles, kotlinx-serialization, Timber, Hilt. - Dependencies: `:core:model`, `:core:common`, Ktor bundles, kotlinx-serialization, Timber, Koin.
- Convention plugins: `librechat.android.library` + `librechat.android.hilt` + `librechat.kotlin.serialization`. - Convention plugins: `librechat.android.library` + `librechat.android.koin` + `librechat.kotlin.serialization`.
- API services must not contain business logic -- they are thin HTTP wrappers. - API services must not contain business logic -- they are thin HTTP wrappers.
- All `arg`-wrapped endpoints must match the backend pattern: `setBody(mapOf("arg" to mapOf(...)))`. - All `arg`-wrapped endpoints must match the backend pattern: `setBody(mapOf("arg" to mapOf(...)))`.

View file

@ -1,6 +1,6 @@
plugins { plugins {
id("librechat.android.library") id("librechat.android.library")
id("librechat.android.hilt") id("librechat.android.koin")
id("librechat.kotlin.serialization") id("librechat.kotlin.serialization")
} }

View file

@ -29,9 +29,8 @@ import io.ktor.http.Headers
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.path import io.ktor.http.path
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
class AgentsApi @Inject constructor( class AgentsApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {

View file

@ -11,9 +11,8 @@ import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.path import io.ktor.http.path
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import javax.inject.Inject
class ApiKeysApi @Inject constructor( class ApiKeysApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
private val lenientJson = Json { private val lenientJson = Json {

View file

@ -21,7 +21,6 @@ import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.path import io.ktor.http.path
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import javax.inject.Inject
data class LoginResult( data class LoginResult(
val response: LoginResponse, val response: LoginResponse,
@ -34,7 +33,7 @@ data class RefreshResult(
val newRefreshToken: String?, val newRefreshToken: String?,
) )
class AuthApi @Inject constructor( class AuthApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun login(email: String, password: String): LoginResult { suspend fun login(email: String, password: String): LoginResult {

View file

@ -5,9 +5,8 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class BalanceApi @Inject constructor( class BalanceApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getBalance(): Balance = suspend fun getBalance(): Balance =

View file

@ -5,9 +5,8 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class BannerApi @Inject constructor( class BannerApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getBanners(): List<Banner> = suspend fun getBanners(): List<Banner> =

View file

@ -12,9 +12,8 @@ import io.ktor.client.request.get
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class ChatApi @Inject constructor( class ChatApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun startChat(endpoint: String, request: ChatRequest): ChatStartResponse = suspend fun startChat(endpoint: String, request: ChatRequest): ChatStartResponse =

View file

@ -9,9 +9,8 @@ import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.path import io.ktor.http.path
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import javax.inject.Inject
class ConfigApi @Inject constructor( class ConfigApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getStartupConfig(): StartupConfig = suspend fun getStartupConfig(): StartupConfig =

View file

@ -24,9 +24,8 @@ import io.ktor.client.request.setBody
import io.ktor.http.Headers import io.ktor.http.Headers
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class ConversationsApi @Inject constructor( class ConversationsApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getConversations( suspend fun getConversations(

View file

@ -15,9 +15,8 @@ import io.ktor.http.HttpHeaders
import io.ktor.http.path import io.ktor.http.path
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject
class FilesApi @Inject constructor( class FilesApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getFiles(): List<FileObject> = suspend fun getFiles(): List<FileObject> =

View file

@ -5,9 +5,8 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class FilesExtApi @Inject constructor( class FilesExtApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getAgentFiles(agentId: String): List<FileObject> = suspend fun getAgentFiles(agentId: String): List<FileObject> =

View file

@ -11,9 +11,8 @@ import io.ktor.client.request.put
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.encodeURLPathPart import io.ktor.http.encodeURLPathPart
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class KeysApi @Inject constructor( class KeysApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getKeyExpiry(): List<UserKey> = suspend fun getKeyExpiry(): List<UserKey> =

View file

@ -26,9 +26,8 @@ import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Inject
class McpApi @Inject constructor( class McpApi constructor(
private val client: HttpClient, private val client: HttpClient,
private val json: Json, private val json: Json,
) { ) {

View file

@ -14,9 +14,8 @@ import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.encodeURLPathPart import io.ktor.http.encodeURLPathPart
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class MemoriesApi @Inject constructor( class MemoriesApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {

View file

@ -16,9 +16,8 @@ import io.ktor.client.request.post
import io.ktor.client.request.put import io.ktor.client.request.put
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class MessagesApi @Inject constructor( class MessagesApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getMessages(conversationId: String): List<Message> = suspend fun getMessages(conversationId: String): List<Message> =

View file

@ -8,9 +8,8 @@ import io.ktor.client.request.get
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class PresetsApi @Inject constructor( class PresetsApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getPresets(): List<Preset> = suspend fun getPresets(): List<Preset> =

View file

@ -17,9 +17,8 @@ import io.ktor.client.request.patch
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class PromptsApi @Inject constructor( class PromptsApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getPromptGroups( suspend fun getPromptGroups(

View file

@ -4,9 +4,8 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class SearchApi @Inject constructor( class SearchApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun checkSearchEnabled(): Boolean = suspend fun checkSearchEnabled(): Boolean =

View file

@ -13,9 +13,8 @@ import io.ktor.client.request.patch
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class ShareApi @Inject constructor( class ShareApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getSharedLinks(cursor: String? = null): SharedLinksResponse = suspend fun getSharedLinks(cursor: String? = null): SharedLinksResponse =

View file

@ -16,9 +16,8 @@ import io.ktor.http.Headers
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class SpeechApi @Inject constructor( class SpeechApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun speechToText(audioData: ByteArray, mimeType: String): SpeechToTextResponse { suspend fun speechToText(audioData: ByteArray, mimeType: String): SpeechToTextResponse {

View file

@ -12,9 +12,8 @@ import io.ktor.client.request.post
import io.ktor.client.request.put import io.ktor.client.request.put
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.path import io.ktor.http.path
import javax.inject.Inject
class TagsApi @Inject constructor( class TagsApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getTags(): List<ConversationTag> = suspend fun getTags(): List<ConversationTag> =

View file

@ -18,7 +18,6 @@ import io.ktor.http.Headers
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.path import io.ktor.http.path
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import javax.inject.Inject
@Serializable @Serializable
data class UserUpdateRequest( data class UserUpdateRequest(
@ -36,7 +35,7 @@ data class UpdatePluginsRequest(
val plugins: List<String>, val plugins: List<String>,
) )
class UserApi @Inject constructor( class UserApi constructor(
private val client: HttpClient, private val client: HttpClient,
) { ) {
suspend fun getUser(): User = suspend fun getUser(): User =

View file

@ -1,15 +1,32 @@
package com.librechat.android.core.network.di package com.librechat.android.core.network.di
import android.content.Context import com.librechat.android.core.common.di.KoinQualifiers
import com.librechat.android.core.network.api.AgentsApi
import com.librechat.android.core.network.api.ApiKeysApi
import com.librechat.android.core.network.api.AuthApi
import com.librechat.android.core.network.api.BalanceApi
import com.librechat.android.core.network.api.BannerApi
import com.librechat.android.core.network.api.ChatApi
import com.librechat.android.core.network.api.ConfigApi
import com.librechat.android.core.network.api.ConversationsApi
import com.librechat.android.core.network.api.FilesApi
import com.librechat.android.core.network.api.FilesExtApi
import com.librechat.android.core.network.api.KeysApi
import com.librechat.android.core.network.api.McpApi
import com.librechat.android.core.network.api.MemoriesApi
import com.librechat.android.core.network.api.MessagesApi
import com.librechat.android.core.network.api.PresetsApi
import com.librechat.android.core.network.api.PromptsApi
import com.librechat.android.core.network.api.SearchApi
import com.librechat.android.core.network.api.ShareApi
import com.librechat.android.core.network.api.SpeechApi
import com.librechat.android.core.network.api.TagsApi
import com.librechat.android.core.network.api.UserApi
import com.librechat.android.core.network.client.AuthInterceptorPlugin
import com.librechat.android.core.network.client.LibreChatHttpClient import com.librechat.android.core.network.client.LibreChatHttpClient
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 com.librechat.android.core.network.client.TokenManager
import dagger.Module import com.librechat.android.core.network.sse.SseClient
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import com.librechat.android.core.network.client.AuthInterceptorPlugin
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
@ -21,87 +38,98 @@ import io.ktor.http.contentType
import io.ktor.http.takeFrom import io.ktor.http.takeFrom
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import javax.inject.Singleton import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
@Module val networkModule = module {
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides single {
@Singleton Json {
fun provideJson(): Json = Json { ignoreUnknownKeys = true
ignoreUnknownKeys = true isLenient = true
isLenient = true encodeDefaults = false
encodeDefaults = false explicitNulls = false
explicitNulls = false coerceInputValues = true
coerceInputValues = true }
} }
@Provides single {
@Singleton LibreChatHttpClient.create(
fun provideHttpClient( json = get(),
@ApplicationContext context: Context, tokenManager = get(),
json: Json, serverUrlProvider = get(),
tokenManager: TokenManager, cacheDir = androidContext().cacheDir,
serverUrlProvider: ServerUrlProvider, )
): HttpClient = LibreChatHttpClient.create( }
json = json,
tokenManager = tokenManager,
serverUrlProvider = serverUrlProvider,
cacheDir = context.cacheDir,
)
/** single(KoinQualifiers.Streaming) {
* Minimal HttpClient for SSE streaming. No ContentNegotiation (avoids val tokenManager = get<TokenManager>()
* response body buffering), infinite request/socket timeouts (streams val serverUrlProvider = get<ServerUrlProvider>()
* can run for minutes), and no response validator (SSE errors are HttpClient(OkHttp) {
* handled by SseClient's retry logic). install(AuthInterceptorPlugin) {
*/ this.tokenManager = tokenManager
@Provides }
@Singleton install(HttpTimeout) {
@StreamingClient connectTimeoutMillis = 10_000
fun provideSseClient( requestTimeoutMillis = Long.MAX_VALUE
tokenManager: TokenManager, socketTimeoutMillis = Long.MAX_VALUE
serverUrlProvider: ServerUrlProvider, }
): HttpClient = HttpClient(OkHttp) { defaultRequest {
install(AuthInterceptorPlugin) { val baseUrl = serverUrlProvider.getBaseUrl()
this.tokenManager = tokenManager if (baseUrl.isNotEmpty()) {
} url.takeFrom(baseUrl)
install(HttpTimeout) { }
connectTimeoutMillis = 10_000 headers.append(
requestTimeoutMillis = Long.MAX_VALUE HttpHeaders.UserAgent,
socketTimeoutMillis = Long.MAX_VALUE "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36",
} )
defaultRequest {
val baseUrl = serverUrlProvider.getBaseUrl()
if (baseUrl.isNotEmpty()) {
url.takeFrom(baseUrl)
} }
headers.append(
HttpHeaders.UserAgent,
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36",
)
} }
} }
@Provides single(KoinQualifiers.Refresh) {
@Singleton val json = get<Json>()
@RefreshClient val serverUrlProvider = get<ServerUrlProvider>()
fun provideRefreshClient( HttpClient(OkHttp) {
json: Json, install(ContentNegotiation) { json(json) }
serverUrlProvider: ServerUrlProvider, install(HttpTimeout) {
): HttpClient = HttpClient(OkHttp) { requestTimeoutMillis = 15_000
install(ContentNegotiation) { json(json) } connectTimeoutMillis = 10_000
install(HttpTimeout) { }
requestTimeoutMillis = 15_000 defaultRequest {
connectTimeoutMillis = 10_000 val baseUrl = serverUrlProvider.getBaseUrl()
} if (baseUrl.isNotEmpty()) {
defaultRequest { url.takeFrom(baseUrl)
val baseUrl = serverUrlProvider.getBaseUrl() }
if (baseUrl.isNotEmpty()) { contentType(ContentType.Application.Json)
url.takeFrom(baseUrl)
} }
contentType(ContentType.Application.Json)
} }
} }
// SSE
singleOf(::SseClient)
// API services
singleOf(::AgentsApi)
singleOf(::ApiKeysApi)
singleOf(::AuthApi)
singleOf(::BalanceApi)
singleOf(::BannerApi)
singleOf(::ChatApi)
singleOf(::ConfigApi)
singleOf(::ConversationsApi)
singleOf(::FilesApi)
singleOf(::FilesExtApi)
singleOf(::KeysApi)
singleOf(::McpApi)
singleOf(::MemoriesApi)
singleOf(::MessagesApi)
singleOf(::PresetsApi)
singleOf(::PromptsApi)
singleOf(::SearchApi)
singleOf(::ShareApi)
singleOf(::SpeechApi)
singleOf(::TagsApi)
singleOf(::UserApi)
} }

View file

@ -1,7 +0,0 @@
package com.librechat.android.core.network.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RefreshClient

View file

@ -1,7 +0,0 @@
package com.librechat.android.core.network.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class StreamingClient

View file

@ -16,10 +16,9 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
import kotlin.math.min import kotlin.math.min
class SseClient @Inject constructor( class SseClient(
private val json: Json, private val json: Json,
) { ) {
private val mapper = SseEventMapper(json) private val mapper = SseEventMapper(json)

View file

@ -0,0 +1,19 @@
package com.librechat.android.core.network.di
import org.junit.Test
import org.koin.test.verify.verify
class NetworkModuleVerificationTest {
@Test
fun verifyNetworkModule() {
networkModule.verify(
extraTypes = listOf(
android.content.Context::class,
android.app.Application::class,
io.ktor.client.engine.HttpClientEngine::class,
com.librechat.android.core.network.client.TokenManager::class,
com.librechat.android.core.network.client.ServerUrlProvider::class,
),
)
}
}

View file

@ -43,7 +43,7 @@ Material 3 theme and shared Compose components used across all feature modules.
- All components must be stateless or hoist state to the caller. - All components must be stateless or hoist state to the caller.
- Dependencies: `:core:model`, `:core:common`, Coil for image loading, Compose libraries. - Dependencies: `:core:model`, `:core:common`, Coil for image loading, Compose libraries.
- Convention plugins: `librechat.android.library` + `librechat.android.compose`. - Convention plugins: `librechat.android.library` + `librechat.android.compose`.
- No Hilt in this module (no `@Inject`, no `@Module`). - No DI in this module (no Koin modules, no injected classes).
- Use `@Preview` annotations on all components for Android Studio preview support. - Use `@Preview` annotations on all components for Android Studio preview support.
- During SSE streaming, buffer markdown re-renders to ~100ms intervals to avoid frame drops. - During SSE streaming, buffer markdown re-renders to ~100ms intervals to avoid frame drops.

View file

@ -0,0 +1,23 @@
package com.librechat.android.feature.agents.di
import com.librechat.android.feature.agents.viewmodel.AgentDetailViewModel
import com.librechat.android.feature.agents.viewmodel.AgentEditorViewModel
import com.librechat.android.feature.agents.viewmodel.AgentMarketplaceViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val agentsModule = module {
viewModelOf(::AgentMarketplaceViewModel)
viewModelOf(::AgentDetailViewModel)
viewModel {
AgentEditorViewModel(
savedStateHandle = get(),
agentRepository = get(),
configRepository = get(),
mcpRepository = get(),
appContext = androidContext(),
)
}
}

View file

@ -45,7 +45,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.core.ui.R as CoreUiR import com.librechat.android.core.ui.R as CoreUiR
import com.librechat.android.core.ui.components.AvatarImage import com.librechat.android.core.ui.components.AvatarImage
@ -65,7 +65,7 @@ fun AgentDetailScreen(
onEdit: (String) -> Unit, onEdit: (String) -> Unit,
onDuplicated: (String) -> Unit, onDuplicated: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AgentDetailViewModel = hiltViewModel(), viewModel: AgentDetailViewModel = koinViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()

View file

@ -49,7 +49,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.ErrorBanner
import com.librechat.android.core.ui.components.LoadingIndicator import com.librechat.android.core.ui.components.LoadingIndicator
@ -79,7 +79,7 @@ fun AgentEditorScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onDeleted: () -> Unit = onBack, onDeleted: () -> Unit = onBack,
onDuplicated: (String) -> Unit = onSaved, onDuplicated: (String) -> Unit = onSaved,
viewModel: AgentEditorViewModel = hiltViewModel(), viewModel: AgentEditorViewModel = koinViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showToolDialog by rememberSaveable { mutableStateOf(false) } var showToolDialog by rememberSaveable { mutableStateOf(false) }

View file

@ -43,7 +43,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.core.ui.R as CoreUiR import com.librechat.android.core.ui.R as CoreUiR
import com.librechat.android.core.ui.components.AvatarImage import com.librechat.android.core.ui.components.AvatarImage
@ -62,7 +62,7 @@ fun AgentMarketplaceScreen(
onAgentClick: (String) -> Unit, onAgentClick: (String) -> Unit,
onCreateAgent: () -> Unit, onCreateAgent: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AgentMarketplaceViewModel = hiltViewModel(), viewModel: AgentMarketplaceViewModel = koinViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()

View file

@ -9,7 +9,6 @@ import com.librechat.android.core.data.datastore.ServerDataStore
import com.librechat.android.core.data.repository.AgentRepository import com.librechat.android.core.data.repository.AgentRepository
import com.librechat.android.core.model.Agent import com.librechat.android.core.model.Agent
import com.librechat.android.feature.agents.AgentDetailDisplayData import com.librechat.android.feature.agents.AgentDetailDisplayData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@ -17,7 +16,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@Immutable @Immutable
data class AgentDetailUiState( data class AgentDetailUiState(
@ -34,8 +32,7 @@ sealed interface AgentDetailEvent {
data class Duplicated(val agentId: String) : AgentDetailEvent data class Duplicated(val agentId: String) : AgentDetailEvent
} }
@HiltViewModel class AgentDetailViewModel(
class AgentDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val agentRepository: AgentRepository, private val agentRepository: AgentRepository,
private val serverDataStore: ServerDataStore, private val serverDataStore: ServerDataStore,

View file

@ -34,8 +34,6 @@ import com.librechat.android.feature.agents.components.AgentVersion
import com.librechat.android.feature.agents.components.AgentVisibility import com.librechat.android.feature.agents.components.AgentVisibility
import com.librechat.android.feature.agents.components.ModelOption import com.librechat.android.feature.agents.components.ModelOption
import com.librechat.android.feature.agents.components.SupportContactState import com.librechat.android.feature.agents.components.SupportContactState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@ -50,7 +48,6 @@ import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@Immutable @Immutable
data class AgentEditorUiState( data class AgentEditorUiState(
@ -106,13 +103,12 @@ sealed interface AgentEditorEvent {
data object DeleteSuccess : AgentEditorEvent data object DeleteSuccess : AgentEditorEvent
} }
@HiltViewModel class AgentEditorViewModel(
class AgentEditorViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val agentRepository: AgentRepository, private val agentRepository: AgentRepository,
private val configRepository: ConfigRepository, private val configRepository: ConfigRepository,
private val mcpRepository: McpRepository, private val mcpRepository: McpRepository,
@ApplicationContext private val appContext: Context, private val appContext: Context,
) : ViewModel() { ) : ViewModel() {
private val editAgentId: String? = savedStateHandle["agentId"] private val editAgentId: String? = savedStateHandle["agentId"]

View file

@ -8,14 +8,12 @@ import com.librechat.android.core.data.datastore.ServerDataStore
import com.librechat.android.core.data.repository.AgentRepository import com.librechat.android.core.data.repository.AgentRepository
import com.librechat.android.core.model.Agent import com.librechat.android.core.model.Agent
import com.librechat.android.feature.agents.AgentCardDisplayData import com.librechat.android.feature.agents.AgentCardDisplayData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow 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.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@Immutable @Immutable
data class AgentMarketplaceUiState( data class AgentMarketplaceUiState(
@ -32,8 +30,7 @@ data class AgentMarketplaceUiState(
val currentPage: Int = 1, val currentPage: Int = 1,
) )
@HiltViewModel class AgentMarketplaceViewModel(
class AgentMarketplaceViewModel @Inject constructor(
private val agentRepository: AgentRepository, private val agentRepository: AgentRepository,
private val serverDataStore: ServerDataStore, private val serverDataStore: ServerDataStore,
) : ViewModel() { ) : ViewModel() {

View file

@ -0,0 +1,21 @@
package com.librechat.android.feature.agents.di
import org.junit.Test
import org.koin.test.verify.verify
class AgentsModuleVerificationTest {
@Test
fun verifyAgentsModule() {
agentsModule.verify(
extraTypes = listOf(
android.content.Context::class,
android.app.Application::class,
androidx.lifecycle.SavedStateHandle::class,
com.librechat.android.core.data.repository.AgentRepository::class,
com.librechat.android.core.data.repository.ConfigRepository::class,
com.librechat.android.core.data.repository.McpRepository::class,
com.librechat.android.core.data.datastore.ServerDataStore::class,
),
)
}
}

View file

@ -0,0 +1,27 @@
package com.librechat.android.feature.auth.di
import com.librechat.android.feature.auth.oauth.OAuthManager
import com.librechat.android.feature.auth.viewmodel.ForgotPasswordViewModel
import com.librechat.android.feature.auth.viewmodel.LoginViewModel
import com.librechat.android.feature.auth.viewmodel.RegisterViewModel
import com.librechat.android.feature.auth.viewmodel.ResetPasswordViewModel
import com.librechat.android.feature.auth.viewmodel.ServerUrlViewModel
import com.librechat.android.feature.auth.viewmodel.TermsViewModel
import com.librechat.android.feature.auth.viewmodel.TwoFactorViewModel
import com.librechat.android.feature.auth.viewmodel.VerifyEmailViewModel
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val authModule = module {
singleOf(::OAuthManager)
viewModelOf(::ServerUrlViewModel)
viewModelOf(::LoginViewModel)
viewModelOf(::RegisterViewModel)
viewModelOf(::VerifyEmailViewModel)
viewModelOf(::ForgotPasswordViewModel)
viewModelOf(::ResetPasswordViewModel)
viewModelOf(::TwoFactorViewModel)
viewModelOf(::TermsViewModel)
}

View file

@ -5,9 +5,7 @@ import android.net.Uri
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import com.librechat.android.core.data.datastore.ServerDataStore import com.librechat.android.core.data.datastore.ServerDataStore
import javax.inject.Inject class OAuthManager(
class OAuthManager @Inject constructor(
private val serverDataStore: ServerDataStore, private val serverDataStore: ServerDataStore,
) { ) {
/** /**

View file

@ -26,7 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.feature.auth.viewmodel.ForgotPasswordViewModel import com.librechat.android.feature.auth.viewmodel.ForgotPasswordViewModel
import com.librechat.android.feature.auth.R import com.librechat.android.feature.auth.R
@ -37,7 +37,7 @@ import androidx.compose.ui.res.stringResource
fun ForgotPasswordScreen( fun ForgotPasswordScreen(
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ForgotPasswordViewModel = hiltViewModel(), viewModel: ForgotPasswordViewModel = koinViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()

View file

@ -26,7 +26,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -41,7 +41,7 @@ fun LoginScreen(
onNavigateToForgotPassword: () -> Unit = {}, onNavigateToForgotPassword: () -> Unit = {},
onNavigateToTwoFactor: (String) -> Unit = {}, onNavigateToTwoFactor: (String) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LoginViewModel = hiltViewModel(), viewModel: LoginViewModel = koinViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current

View file

@ -24,7 +24,7 @@ import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.feature.auth.viewmodel.RegisterViewModel import com.librechat.android.feature.auth.viewmodel.RegisterViewModel
import com.librechat.android.feature.auth.R import com.librechat.android.feature.auth.R
@ -35,7 +35,7 @@ fun RegisterScreen(
onRegistered: () -> Unit, onRegistered: () -> Unit,
onNavigateToLogin: () -> Unit, onNavigateToLogin: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: RegisterViewModel = hiltViewModel(), viewModel: RegisterViewModel = koinViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()

View file

@ -27,7 +27,7 @@ import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.feature.auth.viewmodel.ResetPasswordViewModel import com.librechat.android.feature.auth.viewmodel.ResetPasswordViewModel
import com.librechat.android.feature.auth.R import com.librechat.android.feature.auth.R
@ -39,7 +39,7 @@ fun ResetPasswordScreen(
onResetComplete: () -> Unit, onResetComplete: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ResetPasswordViewModel = hiltViewModel(), viewModel: ResetPasswordViewModel = koinViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()

View file

@ -22,7 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.feature.auth.viewmodel.ServerUrlViewModel import com.librechat.android.feature.auth.viewmodel.ServerUrlViewModel
import com.librechat.android.feature.auth.R import com.librechat.android.feature.auth.R
@ -32,7 +32,7 @@ import androidx.compose.ui.res.stringResource
fun ServerUrlScreen( fun ServerUrlScreen(
onServerValidated: () -> Unit, onServerValidated: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ServerUrlViewModel = hiltViewModel(), viewModel: ServerUrlViewModel = koinViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()

View file

@ -30,7 +30,7 @@ import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.feature.auth.viewmodel.TermsViewModel import com.librechat.android.feature.auth.viewmodel.TermsViewModel
import com.librechat.android.feature.auth.R import com.librechat.android.feature.auth.R
@ -43,7 +43,7 @@ fun TermsScreen(
onAccepted: () -> Unit, onAccepted: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: TermsViewModel = hiltViewModel(), viewModel: TermsViewModel = koinViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()

View file

@ -36,7 +36,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.feature.auth.viewmodel.TwoFactorViewModel import com.librechat.android.feature.auth.viewmodel.TwoFactorViewModel
import com.librechat.android.feature.auth.R import com.librechat.android.feature.auth.R
@ -48,7 +48,7 @@ fun TwoFactorScreen(
onVerified: () -> Unit, onVerified: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: TwoFactorViewModel = hiltViewModel(), viewModel: TwoFactorViewModel = koinViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()

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