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
- **UI**: Jetpack Compose + Compose Navigation
- **DI**: Hilt
- **DI**: Koin (KMP-ready)
- **Network**: Ktor Client (OkHttp engine)
- **Serialization**: Kotlinx Serialization
- **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
- 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
- Mutation endpoints wrap body in `arg` field: `{ "arg": { ... } }`

View file

@ -1,6 +1,6 @@
# App Module
Single Activity architecture. `MainActivity` is the sole entry point, annotated `@AndroidEntryPoint`.
Single Activity architecture. `MainActivity` is the sole entry point.
## Navigation
@ -50,7 +50,7 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
## Dependencies
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
- `NavHostViewModel` exposes `favorites: StateFlow<Set<String>>` and `toggleFavorite()`

View file

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

View file

@ -15,3 +15,6 @@
# Keep Room entities
-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.disk.DiskCache
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 dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent
import com.librechat.android.core.network.di.networkModule
import com.librechat.android.feature.agents.di.agentsModule
import com.librechat.android.feature.auth.di.authModule
import com.librechat.android.feature.chat.di.chatModule
import com.librechat.android.feature.conversations.di.conversationsModule
import com.librechat.android.feature.files.di.filesModule
import com.librechat.android.feature.settings.di.settingsModule
import com.librechat.android.navigation.appModule
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import 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
@HiltAndroidApp
class LibreChatApplication : Application(), ImageLoaderFactory {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ImageLoaderEntryPoint {
fun tokenManager(): TokenManager
}
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
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 {
val entryPoint = EntryPointAccessors.fromApplication(
this, ImageLoaderEntryPoint::class.java
)
val tokenManager = entryPoint.tokenManager()
val tokenManager: TokenManager = get()
val okHttpClient = OkHttpClient.Builder()
.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.SharedContent
import com.librechat.android.navigation.LibreChatNavHost
import dagger.hilt.android.AndroidEntryPoint
import org.koin.android.ext.android.inject
import timber.log.Timber
import javax.inject.Inject
import androidx.compose.ui.res.stringResource
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var connectivityObserver: ConnectivityObserver
@Inject lateinit var themeDataStore: ThemeDataStore
private val connectivityObserver: ConnectivityObserver by inject()
private val themeDataStore: ThemeDataStore by inject()
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.ui.Modifier
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.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
@ -77,7 +77,7 @@ fun LibreChatNavHost(
deepLinkUri: Uri? = null,
onDeepLinkConsumed: () -> Unit = {},
shareNavigationTrigger: Int = 0,
navHostViewModel: NavHostViewModel = hiltViewModel(),
navHostViewModel: NavHostViewModel = koinViewModel(),
) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()

View file

@ -19,7 +19,6 @@ import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@ -33,7 +32,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject
/**
* Combined UI state for the drawer sidebar. A single emission replaces
@ -59,8 +57,7 @@ data class VersionMismatchState(
val backendVersion: String,
)
@HiltViewModel
class NavHostViewModel @Inject constructor(
class NavHostViewModel(
private val authRepository: AuthRepository,
private val bannerRepository: BannerRepository,
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.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config |
| `librechat.android.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 |
| `librechat.android.hilt` | `AndroidHiltConventionPlugin` | Hilt + KSP annotation processing |
| `librechat.android.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + hilt + serialization |
| `librechat.android.koin` | `AndroidKoinConventionPlugin` | Koin core + Android dependencies |
| `librechat.android.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization |
| `librechat.android.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config |
| `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
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 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.

View file

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

View file

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

View file

@ -2,15 +2,14 @@ import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class AndroidHiltConventionPlugin : Plugin<Project> {
class AndroidKoinConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.google.dagger.hilt.android")
pluginManager.apply("com.google.devtools.ksp")
dependencies {
add("implementation", libs.findLibrary("hilt-android").get())
add("ksp", libs.findLibrary("hilt-compiler").get())
add("implementation", platform(libs.findLibrary("koin-bom").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.serialization) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) 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
- **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`.
- **CoroutineScope DI** (`di/CoroutineScopeModule.kt`): `@ApplicationScope` for work that outlives ViewModels.
- **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`.
- **Extensions** (`extensions/`): `StringExt`, `DateExt`, `FlowExt` (includes `retryWithBackoff`, `throttleFirst`).
- **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
- **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`.
- Dependencies: `coroutines-core`, `coroutines-android`, Hilt.
- Convention plugin: `librechat.android.library` + `librechat.android.hilt`.
- Dependencies: `coroutines-core`, `coroutines-android`, Koin.
- Convention plugin: `librechat.android.library` + `librechat.android.koin`.

View file

@ -1,6 +1,6 @@
plugins {
id("librechat.android.library")
id("librechat.android.hilt")
id("librechat.android.koin")
}
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.NetworkCapabilities
import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConnectivityObserver @Inject constructor(
@ApplicationContext private val context: Context,
class ConnectivityObserver(
private val context: Context,
) {
// Permission is declared in app module's AndroidManifest.xml
@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
@ -46,16 +46,16 @@ Wrap `EncryptedSharedPreferences` in try/catch -- some OEM devices have broken K
### 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
`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
- Dependencies: `:core:network`, `:core:model`, `:core:common`, Room, DataStore, security-crypto, Ktor, kotlinx-serialization, Timber, Hilt.
- Convention plugins: `librechat.android.library` + `librechat.android.hilt` + `librechat.android.room` + `librechat.kotlin.serialization`.
- Dependencies: `:core:network`, `:core:model`, `:core:common`, Room, DataStore, security-crypto, Ktor, kotlinx-serialization, Timber, Koin.
- 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.
- 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.
@ -63,7 +63,7 @@ Wrap `EncryptedSharedPreferences` in try/catch -- some OEM devices have broken K
### New Repositories (Round 2)
- `MemoryRepository` / `MemoryRepositoryImpl` — wraps `MemoriesApi` for memory CRUD + preferences
- `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)
- **Gotcha**: Memory delete/update use `key` as identifier (not a separate ID field)
- **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)
- `BannerRepository` / `BannerRepositoryImpl` — wraps `BannerApi` for fetching server banners
- `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 {
id("librechat.android.library")
id("librechat.android.hilt")
id("librechat.android.koin")
id("librechat.android.room")
id("librechat.kotlin.serialization")
}

View file

@ -12,11 +12,8 @@ import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConfigCacheDataStore @Inject constructor(
class ConfigCacheDataStore(
private val dataStore: DataStore<Preferences>,
private val json: Json,
) {

View file

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

View file

@ -9,8 +9,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class LatexRenderer {
NATIVE, KATEX;
@ -46,8 +44,7 @@ enum class ChatFontSize {
}
}
@Singleton
class SettingsDataStore @Inject constructor(
class SettingsDataStore(
private val dataStore: DataStore<Preferences>,
) {
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class ThemeMode {
SYSTEM, LIGHT, DARK
}
@Singleton
class ThemeDataStore @Inject constructor(
class ThemeDataStore(
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.SecureTokenStorage
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.call.body
import io.ktor.client.plugins.ClientRequestException
@ -25,13 +23,10 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TokenDataStore @Inject constructor(
@ApplicationContext context: Context,
@RefreshClient private val refreshClient: dagger.Lazy<HttpClient>,
class TokenDataStore(
context: Context,
private val refreshClient: Lazy<HttpClient>,
) : TokenManager, SecureTokenStorage {
private val masterKey: MasterKey = MasterKey.Builder(context)
@ -81,7 +76,7 @@ class TokenDataStore @Inject constructor(
// The LibreChat backend reads from req.cookies.refreshToken first,
// falling back to req.body.refreshToken. Sending both ensures
// compatibility across backend versions.
val httpResponse: HttpResponse = refreshClient.get()
val httpResponse: HttpResponse = refreshClient.value
.post("/api/auth/refresh") {
header("Cookie", "refreshToken=$storedRefreshToken")
setBody(mapOf("refreshToken" to storedRefreshToken))

View file

@ -4,22 +4,24 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import androidx.room.Room
import com.librechat.android.core.common.di.KoinQualifiers
import com.librechat.android.core.data.datastore.ConfigCacheDataStore
import com.librechat.android.core.data.datastore.ServerDataStore
import com.librechat.android.core.data.datastore.SettingsDataStore
import com.librechat.android.core.data.datastore.ThemeDataStore
import com.librechat.android.core.data.datastore.TokenDataStore
import com.librechat.android.core.data.db.LibreChatDatabase
import com.librechat.android.core.data.repository.AgentRepository
import com.librechat.android.core.data.repository.AgentRepositoryImpl
import com.librechat.android.core.data.repository.ApiKeyRepository
import com.librechat.android.core.data.repository.ApiKeyRepositoryImpl
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.BalanceRepositoryImpl
import com.librechat.android.core.data.repository.BannerRepository
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.ChatRepositoryImpl
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.PromptRepository
import com.librechat.android.core.data.repository.PromptRepositoryImpl
import com.librechat.android.core.data.repository.SearchRepository
import com.librechat.android.core.data.repository.SearchRepositoryImpl
import com.librechat.android.core.data.repository.ShareRepository
import com.librechat.android.core.data.repository.ShareRepositoryImpl
import com.librechat.android.core.data.repository.SpeechRepository
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.UserRepositoryImpl
import com.librechat.android.core.network.client.SecureTokenStorage
import com.librechat.android.core.network.client.ServerUrlProvider
import com.librechat.android.core.network.client.TokenManager
import dagger.Binds
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
import io.ktor.client.HttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.binds
import org.koin.dsl.module
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "librechat_settings",
)
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
val dataModule = module {
@Binds
@Singleton
abstract fun bindTokenManager(impl: TokenDataStore): TokenManager
// --- Database + DAOs ---
@Binds
@Singleton
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 {
Room.databaseBuilder(androidContext(), LibreChatDatabase::class.java, "librechat.db").build()
}
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.UpdateAgentRequest
import com.librechat.android.core.network.api.AgentsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AgentRepositoryImpl @Inject constructor(
class AgentRepositoryImpl(
private val agentsApi: AgentsApi,
) : 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.request.CreateApiKeyRequest
import com.librechat.android.core.network.api.ApiKeysApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ApiKeyRepositoryImpl @Inject constructor(
class ApiKeyRepositoryImpl(
private val apiKeysApi: ApiKeysApi,
) : 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.UserApi
import com.librechat.android.core.network.client.TokenManager
import dagger.hilt.android.qualifiers.ApplicationContext
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthRepositoryImpl @Inject constructor(
class AuthRepositoryImpl(
private val authApi: AuthApi,
private val userApi: UserApi,
private val tokenManager: TokenManager,
@ApplicationContext private val context: Context,
private val context: Context,
) : AuthRepository {
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.model.Balance
import com.librechat.android.core.network.api.BalanceApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BalanceRepositoryImpl @Inject constructor(
class BalanceRepositoryImpl(
private val balanceApi: BalanceApi,
) : 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.model.Banner
import com.librechat.android.core.network.api.BannerApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BannerRepositoryImpl @Inject constructor(
class BannerRepositoryImpl(
private val bannerApi: BannerApi,
) : 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.response.ChatStatusResponse
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 io.ktor.client.HttpClient
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChatRepositoryImpl @Inject constructor(
class ChatRepositoryImpl(
private val chatApi: ChatApi,
private val sseClient: SseClient,
@StreamingClient private val sseHttpClient: HttpClient,
private val sseHttpClient: HttpClient,
private val connectivityObserver: ConnectivityObserver,
) : ChatRepository {

View file

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

View file

@ -12,11 +12,8 @@ import com.librechat.android.core.network.api.ConversationsApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConversationRepositoryImpl @Inject constructor(
class ConversationRepositoryImpl(
private val conversationsApi: ConversationsApi,
private val conversationDao: ConversationDao,
) : ConversationRepository {

View file

@ -1,7 +1,5 @@
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.entity.DraftEntity
import kotlinx.coroutines.CoroutineDispatcher
@ -13,13 +11,10 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DraftRepositoryImpl @Inject constructor(
class DraftRepositoryImpl(
private val draftDao: DraftDao,
@Dispatcher(LibreChatDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
private val ioDispatcher: CoroutineDispatcher,
) : DraftRepository {
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.network.api.FilesApi
import com.librechat.android.core.network.api.FilesExtApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FileRepositoryImpl @Inject constructor(
class FileRepositoryImpl(
private val filesApi: FilesApi,
private val filesExtApi: FilesExtApi,
) : 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.request.UpdateKeyRequest
import com.librechat.android.core.network.api.KeysApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class KeyRepositoryImpl @Inject constructor(
class KeyRepositoryImpl(
private val keysApi: KeysApi,
) : 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.McpTool
import com.librechat.android.core.network.api.McpApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class McpRepositoryImpl @Inject constructor(
class McpRepositoryImpl(
private val mcpApi: McpApi,
) : 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.UpdateMemoryRequest
import com.librechat.android.core.network.api.MemoriesApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MemoryRepositoryImpl @Inject constructor(
class MemoryRepositoryImpl(
private val memoriesApi: MemoriesApi,
) : MemoryRepository {

View file

@ -13,11 +13,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MessageRepositoryImpl @Inject constructor(
class MessageRepositoryImpl(
private val messagesApi: MessagesApi,
private val messageDao: MessageDao,
) : 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.model.Preset
import com.librechat.android.core.network.api.PresetsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PresetRepositoryImpl @Inject constructor(
class PresetRepositoryImpl(
private val presetsApi: PresetsApi,
) : 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.response.PromptGroupListResponse
import com.librechat.android.core.network.api.PromptsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PromptRepositoryImpl @Inject constructor(
class PromptRepositoryImpl(
private val promptsApi: PromptsApi,
) : 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.model.Conversation
import com.librechat.android.core.network.api.ConversationsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SearchRepositoryImpl @Inject constructor(
class SearchRepositoryImpl(
private val conversationsApi: ConversationsApi,
) : 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.network.api.ShareApi
import com.librechat.android.core.network.client.ServerUrlProvider
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ShareRepositoryImpl @Inject constructor(
class ShareRepositoryImpl(
private val shareApi: ShareApi,
private val serverUrlProvider: ServerUrlProvider,
) : 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.TtsVoice
import com.librechat.android.core.network.api.SpeechApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SpeechRepositoryImpl @Inject constructor(
class SpeechRepositoryImpl(
private val speechApi: SpeechApi,
) : 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.model.ConversationTag
import com.librechat.android.core.network.api.TagsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TagRepositoryImpl @Inject constructor(
class TagRepositoryImpl(
private val tagsApi: TagsApi,
) : 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.response.TermsResponse
import com.librechat.android.core.network.api.UserApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UserRepositoryImpl @Inject constructor(
class UserRepositoryImpl(
private val userApi: UserApi,
) : 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
- **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.).
- **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`.
- **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`.
- **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
- Dependencies: `:core:model`, `:core:common`, Ktor bundles, kotlinx-serialization, Timber, Hilt.
- Convention plugins: `librechat.android.library` + `librechat.android.hilt` + `librechat.kotlin.serialization`.
- Dependencies: `:core:model`, `:core:common`, Ktor bundles, kotlinx-serialization, Timber, Koin.
- Convention plugins: `librechat.android.library` + `librechat.android.koin` + `librechat.kotlin.serialization`.
- 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(...)))`.

View file

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

View file

@ -29,9 +29,8 @@ import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.path
import timber.log.Timber
import javax.inject.Inject
class AgentsApi @Inject constructor(
class AgentsApi constructor(
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.http.path
import kotlinx.serialization.json.Json
import javax.inject.Inject
class ApiKeysApi @Inject constructor(
class ApiKeysApi constructor(
private val client: HttpClient,
) {
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.http.path
import kotlinx.serialization.Serializable
import javax.inject.Inject
data class LoginResult(
val response: LoginResponse,
@ -34,7 +33,7 @@ data class RefreshResult(
val newRefreshToken: String?,
)
class AuthApi @Inject constructor(
class AuthApi constructor(
private val client: HttpClient,
) {
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.request.get
import io.ktor.http.path
import javax.inject.Inject
class BalanceApi @Inject constructor(
class BalanceApi constructor(
private val client: HttpClient,
) {
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.request.get
import io.ktor.http.path
import javax.inject.Inject
class BannerApi @Inject constructor(
class BannerApi constructor(
private val client: HttpClient,
) {
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.setBody
import io.ktor.http.path
import javax.inject.Inject
class ChatApi @Inject constructor(
class ChatApi constructor(
private val client: HttpClient,
) {
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.http.path
import kotlinx.serialization.json.Json
import javax.inject.Inject
class ConfigApi @Inject constructor(
class ConfigApi constructor(
private val client: HttpClient,
) {
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.HttpHeaders
import io.ktor.http.path
import javax.inject.Inject
class ConversationsApi @Inject constructor(
class ConversationsApi constructor(
private val client: HttpClient,
) {
suspend fun getConversations(

View file

@ -15,9 +15,8 @@ import io.ktor.http.HttpHeaders
import io.ktor.http.path
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
class FilesApi @Inject constructor(
class FilesApi constructor(
private val client: HttpClient,
) {
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.request.get
import io.ktor.http.path
import javax.inject.Inject
class FilesExtApi @Inject constructor(
class FilesExtApi constructor(
private val client: HttpClient,
) {
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.http.encodeURLPathPart
import io.ktor.http.path
import javax.inject.Inject
class KeysApi @Inject constructor(
class KeysApi constructor(
private val client: HttpClient,
) {
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.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Inject
class McpApi @Inject constructor(
class McpApi constructor(
private val client: HttpClient,
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.http.encodeURLPathPart
import io.ktor.http.path
import javax.inject.Inject
class MemoriesApi @Inject constructor(
class MemoriesApi constructor(
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.setBody
import io.ktor.http.path
import javax.inject.Inject
class MessagesApi @Inject constructor(
class MessagesApi constructor(
private val client: HttpClient,
) {
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.setBody
import io.ktor.http.path
import javax.inject.Inject
class PresetsApi @Inject constructor(
class PresetsApi constructor(
private val client: HttpClient,
) {
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.setBody
import io.ktor.http.path
import javax.inject.Inject
class PromptsApi @Inject constructor(
class PromptsApi constructor(
private val client: HttpClient,
) {
suspend fun getPromptGroups(

View file

@ -4,9 +4,8 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.http.path
import javax.inject.Inject
class SearchApi @Inject constructor(
class SearchApi constructor(
private val client: HttpClient,
) {
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.setBody
import io.ktor.http.path
import javax.inject.Inject
class ShareApi @Inject constructor(
class ShareApi constructor(
private val client: HttpClient,
) {
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.contentType
import io.ktor.http.path
import javax.inject.Inject
class SpeechApi @Inject constructor(
class SpeechApi constructor(
private val client: HttpClient,
) {
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.setBody
import io.ktor.http.path
import javax.inject.Inject
class TagsApi @Inject constructor(
class TagsApi constructor(
private val client: HttpClient,
) {
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.path
import kotlinx.serialization.Serializable
import javax.inject.Inject
@Serializable
data class UserUpdateRequest(
@ -36,7 +35,7 @@ data class UpdatePluginsRequest(
val plugins: List<String>,
)
class UserApi @Inject constructor(
class UserApi constructor(
private val client: HttpClient,
) {
suspend fun getUser(): User =

View file

@ -1,15 +1,32 @@
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.ServerUrlProvider
import com.librechat.android.core.network.client.TokenManager
import dagger.Module
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 com.librechat.android.core.network.sse.SseClient
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
@ -21,87 +38,98 @@ import io.ktor.http.contentType
import io.ktor.http.takeFrom
import io.ktor.serialization.kotlinx.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
@InstallIn(SingletonComponent::class)
object NetworkModule {
val networkModule = module {
@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
explicitNulls = false
coerceInputValues = true
single {
Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
explicitNulls = false
coerceInputValues = true
}
}
@Provides
@Singleton
fun provideHttpClient(
@ApplicationContext context: Context,
json: Json,
tokenManager: TokenManager,
serverUrlProvider: ServerUrlProvider,
): HttpClient = LibreChatHttpClient.create(
json = json,
tokenManager = tokenManager,
serverUrlProvider = serverUrlProvider,
cacheDir = context.cacheDir,
)
single {
LibreChatHttpClient.create(
json = get(),
tokenManager = get(),
serverUrlProvider = get(),
cacheDir = androidContext().cacheDir,
)
}
/**
* Minimal HttpClient for SSE streaming. No ContentNegotiation (avoids
* response body buffering), infinite request/socket timeouts (streams
* can run for minutes), and no response validator (SSE errors are
* handled by SseClient's retry logic).
*/
@Provides
@Singleton
@StreamingClient
fun provideSseClient(
tokenManager: TokenManager,
serverUrlProvider: ServerUrlProvider,
): HttpClient = HttpClient(OkHttp) {
install(AuthInterceptorPlugin) {
this.tokenManager = tokenManager
}
install(HttpTimeout) {
connectTimeoutMillis = 10_000
requestTimeoutMillis = Long.MAX_VALUE
socketTimeoutMillis = Long.MAX_VALUE
}
defaultRequest {
val baseUrl = serverUrlProvider.getBaseUrl()
if (baseUrl.isNotEmpty()) {
url.takeFrom(baseUrl)
single(KoinQualifiers.Streaming) {
val tokenManager = get<TokenManager>()
val serverUrlProvider = get<ServerUrlProvider>()
HttpClient(OkHttp) {
install(AuthInterceptorPlugin) {
this.tokenManager = tokenManager
}
install(HttpTimeout) {
connectTimeoutMillis = 10_000
requestTimeoutMillis = Long.MAX_VALUE
socketTimeoutMillis = Long.MAX_VALUE
}
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",
)
}
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
@Singleton
@RefreshClient
fun provideRefreshClient(
json: Json,
serverUrlProvider: ServerUrlProvider,
): HttpClient = HttpClient(OkHttp) {
install(ContentNegotiation) { json(json) }
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
}
defaultRequest {
val baseUrl = serverUrlProvider.getBaseUrl()
if (baseUrl.isNotEmpty()) {
url.takeFrom(baseUrl)
single(KoinQualifiers.Refresh) {
val json = get<Json>()
val serverUrlProvider = get<ServerUrlProvider>()
HttpClient(OkHttp) {
install(ContentNegotiation) { json(json) }
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
}
defaultRequest {
val baseUrl = serverUrlProvider.getBaseUrl()
if (baseUrl.isNotEmpty()) {
url.takeFrom(baseUrl)
}
contentType(ContentType.Application.Json)
}
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.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject
import kotlin.math.min
class SseClient @Inject constructor(
class SseClient(
private val json: 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.
- Dependencies: `:core:model`, `:core:common`, Coil for image loading, Compose libraries.
- 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.
- 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.semantics
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.core.ui.R as CoreUiR
import com.librechat.android.core.ui.components.AvatarImage
@ -65,7 +65,7 @@ fun AgentDetailScreen(
onEdit: (String) -> Unit,
onDuplicated: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: AgentDetailViewModel = hiltViewModel(),
viewModel: AgentDetailViewModel = koinViewModel(),
) {
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.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.core.ui.components.ErrorBanner
import com.librechat.android.core.ui.components.LoadingIndicator
@ -79,7 +79,7 @@ fun AgentEditorScreen(
modifier: Modifier = Modifier,
onDeleted: () -> Unit = onBack,
onDuplicated: (String) -> Unit = onSaved,
viewModel: AgentEditorViewModel = hiltViewModel(),
viewModel: AgentEditorViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.core.ui.R as CoreUiR
import com.librechat.android.core.ui.components.AvatarImage
@ -62,7 +62,7 @@ fun AgentMarketplaceScreen(
onAgentClick: (String) -> Unit,
onCreateAgent: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AgentMarketplaceViewModel = hiltViewModel(),
viewModel: AgentMarketplaceViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
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.model.Agent
import com.librechat.android.feature.agents.AgentDetailDisplayData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@ -17,7 +16,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@Immutable
data class AgentDetailUiState(
@ -34,8 +32,7 @@ sealed interface AgentDetailEvent {
data class Duplicated(val agentId: String) : AgentDetailEvent
}
@HiltViewModel
class AgentDetailViewModel @Inject constructor(
class AgentDetailViewModel(
savedStateHandle: SavedStateHandle,
private val agentRepository: AgentRepository,
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.ModelOption
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.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@ -50,7 +48,6 @@ import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import timber.log.Timber
import javax.inject.Inject
@Immutable
data class AgentEditorUiState(
@ -106,13 +103,12 @@ sealed interface AgentEditorEvent {
data object DeleteSuccess : AgentEditorEvent
}
@HiltViewModel
class AgentEditorViewModel @Inject constructor(
class AgentEditorViewModel(
savedStateHandle: SavedStateHandle,
private val agentRepository: AgentRepository,
private val configRepository: ConfigRepository,
private val mcpRepository: McpRepository,
@ApplicationContext private val appContext: Context,
private val appContext: Context,
) : ViewModel() {
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.model.Agent
import com.librechat.android.feature.agents.AgentCardDisplayData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@Immutable
data class AgentMarketplaceUiState(
@ -32,8 +30,7 @@ data class AgentMarketplaceUiState(
val currentPage: Int = 1,
)
@HiltViewModel
class AgentMarketplaceViewModel @Inject constructor(
class AgentMarketplaceViewModel(
private val agentRepository: AgentRepository,
private val serverDataStore: ServerDataStore,
) : 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 androidx.browser.customtabs.CustomTabsIntent
import com.librechat.android.core.data.datastore.ServerDataStore
import javax.inject.Inject
class OAuthManager @Inject constructor(
class OAuthManager(
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.semantics
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.feature.auth.viewmodel.ForgotPasswordViewModel
import com.librechat.android.feature.auth.R
@ -37,7 +37,7 @@ import androidx.compose.ui.res.stringResource
fun ForgotPasswordScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ForgotPasswordViewModel = hiltViewModel(),
viewModel: ForgotPasswordViewModel = koinViewModel(),
) {
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.style.TextAlign
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.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -41,7 +41,7 @@ fun LoginScreen(
onNavigateToForgotPassword: () -> Unit = {},
onNavigateToTwoFactor: (String) -> Unit = {},
modifier: Modifier = Modifier,
viewModel: LoginViewModel = hiltViewModel(),
viewModel: LoginViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
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.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.feature.auth.viewmodel.RegisterViewModel
import com.librechat.android.feature.auth.R
@ -35,7 +35,7 @@ fun RegisterScreen(
onRegistered: () -> Unit,
onNavigateToLogin: () -> Unit,
modifier: Modifier = Modifier,
viewModel: RegisterViewModel = hiltViewModel(),
viewModel: RegisterViewModel = koinViewModel(),
) {
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.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.feature.auth.viewmodel.ResetPasswordViewModel
import com.librechat.android.feature.auth.R
@ -39,7 +39,7 @@ fun ResetPasswordScreen(
onResetComplete: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ResetPasswordViewModel = hiltViewModel(),
viewModel: ResetPasswordViewModel = koinViewModel(),
) {
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.semantics
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.feature.auth.viewmodel.ServerUrlViewModel
import com.librechat.android.feature.auth.R
@ -32,7 +32,7 @@ import androidx.compose.ui.res.stringResource
fun ServerUrlScreen(
onServerValidated: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ServerUrlViewModel = hiltViewModel(),
viewModel: ServerUrlViewModel = koinViewModel(),
) {
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.feature.auth.viewmodel.TermsViewModel
import com.librechat.android.feature.auth.R
@ -43,7 +43,7 @@ fun TermsScreen(
onAccepted: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: TermsViewModel = hiltViewModel(),
viewModel: TermsViewModel = koinViewModel(),
) {
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.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import org.koin.compose.viewmodel.koinViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.librechat.android.feature.auth.viewmodel.TwoFactorViewModel
import com.librechat.android.feature.auth.R
@ -48,7 +48,7 @@ fun TwoFactorScreen(
onVerified: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: TwoFactorViewModel = hiltViewModel(),
viewModel: TwoFactorViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

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