diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml deleted file mode 100644 index 98ad0eb..0000000 --- a/.github/workflows/android.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Android CI - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - - - name: Cache Gradle packages - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle- - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Lint - run: ./gradlew lint - - - name: Unit Tests - run: ./gradlew test - - - name: Build Debug APK - run: ./gradlew assembleDebug - - - name: Upload APK - uses: actions/upload-artifact@v4 - with: - name: debug-apk - path: app/build/outputs/apk/debug/app-debug.apk diff --git a/CLAUDE.md b/CLAUDE.md index 739452e..c6b64a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,5 +61,3 @@ Each module has its own `CLAUDE.md` with specific guidance. - **`UPSTREAM_VERSION`** — Tracks which official tag/commit this Android build is based on. Updated by the `/sync-upstream` skill. - **`BackendVersion.kt`** (`core/common/`) — `SUPPORTED_BACKEND_VERSION` constant must match the tag in `UPSTREAM_VERSION` (without `v` prefix). - **`/sync-upstream`** — Claude Code skill to diff upstream releases, identify gaps, propose changes, and implement them with user approval. Uses Agent Teams (investigator, android-expert, implementer, verifier). - -@~/.claude/librechat-android-local.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d169809 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,99 @@ +# Contributing to LibreChat Native KMP + +## Prerequisites + +| Tool | Version | +|------|---------| +| JDK | 17+ | +| Android Studio | Latest stable | +| Xcode | 15+ (iOS development) | +| Gradle | 8.11.1 (via wrapper) | +| Kotlin | 2.1.20 | + +## Getting Started + +```bash +# Clone with submodules (upstream LibreChat server reference) +git clone --recursive https://github.com/anthropics/LibreChat-Android.git +cd LibreChat-Android +``` + +### Android + +```bash +./gradlew assembleDebug +``` + +Open the project in Android Studio and run on an emulator or device. Debug APK output: `app/build/outputs/apk/debug/app-debug.apk`. + +### iOS + +```bash +# Build the shared KMP framework +./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 +``` + +Open `iosApp/iosApp.xcodeproj` in Xcode and run on an iOS simulator. The shared framework must be rebuilt whenever shared code changes. + +## Project Structure + +The project is a Kotlin Multiplatform (KMP) app with shared business logic and platform-specific UI layers. + +### Modules + +``` +app/ -> Android entry point, adaptive navigation (phone/tablet) +shared/ -> KMP umbrella module (commonMain, androidMain, iosMain) +core/common/ -> Result type, dispatcher DI, coroutine scopes, extensions +core/model/ -> @Serializable data classes (pure Kotlin) +core/network/ -> Ktor client, API services, SSE client, auth interceptor +core/data/ -> Room DB, DataStore, repositories +core/ui/ -> Material 3 theme, shared composables +feature/auth/ -> Login, Register, 2FA, OAuth, Forgot Password +feature/chat/ -> Real-time chat with SSE streaming, message rendering +feature/conversations/ -> Paginated list, CRUD, tags, search, export/import +feature/settings/ -> Account, appearance, about +feature/agents/ -> Agent marketplace with search and categories +feature/files/ -> File upload, management, image viewer +``` + +Source sets: `commonMain` = shared code, `androidMain` / `iosMain` = platform-specific implementations. + +### Convention Plugins + +`build-logic/convention/` contains 13 convention plugins (8 Android + 5 KMP) that standardize module configuration. See `CLAUDE.md` for details. + +## Adding a New Feature Module + +1. Create the module directory under `feature/` +2. Apply the `librechat.kmp.feature` convention plugin in `build.gradle.kts` +3. Create `di/Module.kt` with a Koin `module { }` containing `viewModelOf(::YourViewModel)` definitions +4. Register the module in the application startup Koin configuration +5. Use `koinViewModel()` in screen composables to inject ViewModels + +## Code Style + +- Follow [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html) +- ViewModels live in `commonMain` only — no platform-specific ViewModels +- Handle platform differences via `expect`/`actual` declarations or delegate factories +- Use [Kermit](https://github.com/touchlab/Kermit) for logging (`co.touchlab.kermit.Logger`), not `println`, `Log.d`, or `NSLog` +- Feature modules depend on `:core:*` only, never on each other + +## Testing + +- Unit tests go in `commonTest` or `androidUnitTest` +- Maestro flows for E2E testing (8 existing flows in `.maestro/`) +- Run tests before submitting: + +```bash +./gradlew test +``` + +## Architecture Rules + +- Feature modules depend on `:core:*` only, never on each other +- Unidirectional data flow: UI -> ViewModel -> Repository -> API/Room +- Room is a read-through cache; server is source of truth +- Custom SSE parser over raw `ByteReadChannel` (not Ktor SSE plugin) + +See `CLAUDE.md` for the full architecture overview and backend quirks. diff --git a/DISCOVERY.md b/DISCOVERY.md index e449418..4250cfb 100644 --- a/DISCOVERY.md +++ b/DISCOVERY.md @@ -5,7 +5,7 @@ Build a native Android app (LibreChat-Android) with full feature parity to the L ## Tech Stack (Mandated) - **UI**: Jetpack Compose + Compose Navigation -- **DI**: Hilt +- **DI**: Koin - **Network**: Ktor Client - **Serialization**: Kotlinx Serialization - **Min SDK**: TBD (recommend 26+) diff --git a/README.md b/README.md index 0496a22..58fb002 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,19 @@ Without this setting, the server's violation system may accumulate ban points ag - **Registration** — The app respects your server's registration settings. If registration is disabled server-side, only the login form is shown. +## Requirements + +| Tool | Version | +|------|---------| +| JDK | 17+ | +| Android Studio | Latest stable | +| Xcode | 15+ (iOS only) | +| Gradle | 8.11.1 (via wrapper) | +| Kotlin | 2.1.20 | + ## Building from Source -**Requirements:** Android Studio, JDK 17+ +### Android ```bash ./gradlew assembleDebug @@ -53,14 +63,30 @@ For a release build: ./gradlew assembleRelease ``` +### iOS + +Build the shared KMP framework and open in Xcode: + +```bash +./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 +open iosApp/iosApp.xcodeproj +``` + +Run on an iOS simulator from Xcode. The shared framework must be rebuilt whenever shared code changes. + ### Tech Stack -- Jetpack Compose + Compose Navigation -- Hilt (dependency injection) +- Kotlin Multiplatform (KMP) with shared business logic +- Jetpack Compose (Android) + SwiftUI (iOS) +- Koin (dependency injection) - Ktor Client with OkHttp engine - Kotlinx Serialization - Room (cache), DataStore (preferences), EncryptedSharedPreferences (tokens) -- Kotlin 2.1.0, compileSdk 35, minSdk 26 +- Kotlin 2.1.20, compileSdk 35, minSdk 26 + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style, and PR guidelines. ## License diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4a301b1..9680cd8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -36,8 +36,9 @@ dependencies { implementation(libs.koin.compose.viewmodel.navigation) implementation(libs.compose.material3.wsc) implementation(libs.bundles.lifecycle) - implementation(libs.coil.compose) - implementation(libs.timber) + implementation(libs.coil3.compose) + implementation(libs.coil3.network.ktor) + implementation(libs.kermit) debugImplementation(libs.leakcanary) diff --git a/app/src/main/kotlin/com/librechat/android/LibreChatApplication.kt b/app/src/main/kotlin/com/librechat/android/LibreChatApplication.kt index 143430d..045e3e6 100644 --- a/app/src/main/kotlin/com/librechat/android/LibreChatApplication.kt +++ b/app/src/main/kotlin/com/librechat/android/LibreChatApplication.kt @@ -1,37 +1,35 @@ package com.librechat.android import android.app.Application -import coil.ImageLoader -import coil.ImageLoaderFactory -import coil.disk.DiskCache -import coil.memory.MemoryCache +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.disk.DiskCache +import coil3.disk.directory +import coil3.memory.MemoryCache +import coil3.network.ktor3.KtorNetworkFetcherFactory +import coil3.request.crossfade import com.librechat.android.core.common.di.commonModule import com.librechat.android.core.data.di.dataModule -import com.librechat.android.core.network.client.TokenManager import com.librechat.android.core.network.di.networkModule import com.librechat.android.feature.agents.di.agentsModule import com.librechat.android.feature.auth.di.authModule +import com.librechat.android.feature.auth.di.authPlatformModule import com.librechat.android.feature.chat.di.chatModule import com.librechat.android.feature.conversations.di.conversationsModule import com.librechat.android.feature.files.di.filesModule import com.librechat.android.feature.settings.di.settingsModule import com.librechat.android.navigation.appModule -import kotlinx.coroutines.runBlocking -import okhttp3.OkHttpClient +import io.ktor.client.HttpClient import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin -import timber.log.Timber +import co.touchlab.kermit.Logger -class LibreChatApplication : Application(), ImageLoaderFactory { +class LibreChatApplication : Application(), SingletonImageLoader.Factory { override fun onCreate() { super.onCreate() - if (BuildConfig.DEBUG) { - Timber.plant(Timber.DebugTree()) - } - try { startKoin { if (BuildConfig.DEBUG) { @@ -45,6 +43,7 @@ class LibreChatApplication : Application(), ImageLoaderFactory { dataModule, appModule, authModule, + authPlatformModule, chatModule, conversationsModule, settingsModule, @@ -53,49 +52,21 @@ class LibreChatApplication : Application(), ImageLoaderFactory { ) } } catch (e: Exception) { - Timber.e(e, "Koin initialization failed") + Logger.e(e) { "Koin initialization failed" } throw e // Always rethrow — DI failure is unrecoverable } } - override fun newImageLoader(): ImageLoader { - val tokenManager: TokenManager = get() + override fun newImageLoader(context: coil3.PlatformContext): ImageLoader { + val httpClient: HttpClient = get() - val okHttpClient = OkHttpClient.Builder() - .addInterceptor { chain -> - val token = runBlocking { tokenManager.getAccessToken() } - val requestBuilder = chain.request().newBuilder() - // The backend's uaParser middleware (ua-parser-js) rejects requests - // without a recognized browser User-Agent with 403. This must match - // the UA string used by the Ktor HttpClient. - .header( - "User-Agent", - "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + - "(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36", - ) - if (token != null) { - requestBuilder.addHeader("Authorization", "Bearer $token") - } - val request = requestBuilder.build() - Timber.d("Coil image request: %s", request.url) - val response = chain.proceed(request) - if (!response.isSuccessful) { - Timber.w( - "Coil image request failed: %s -> %d %s", - request.url, - response.code, - response.message, - ) - } - response + return ImageLoader.Builder(context) + .components { + add(KtorNetworkFetcherFactory(httpClient)) } - .build() - - return ImageLoader.Builder(this) - .okHttpClient(okHttpClient) .memoryCache { - MemoryCache.Builder(this) - .maxSizePercent(0.25) + MemoryCache.Builder() + .maxSizePercent(context, 0.25) .build() } .diskCache { diff --git a/app/src/main/kotlin/com/librechat/android/MainActivity.kt b/app/src/main/kotlin/com/librechat/android/MainActivity.kt index 4ca8cf6..29a561a 100644 --- a/app/src/main/kotlin/com/librechat/android/MainActivity.kt +++ b/app/src/main/kotlin/com/librechat/android/MainActivity.kt @@ -40,7 +40,7 @@ import com.librechat.android.feature.chat.ShareIntentConsumer import com.librechat.android.feature.chat.SharedContent import com.librechat.android.navigation.LibreChatNavHost import org.koin.android.ext.android.inject -import timber.log.Timber +import co.touchlab.kermit.Logger class MainActivity : ComponentActivity() { @@ -66,7 +66,7 @@ class MainActivity : ComponentActivity() { setContent { val windowSizeClass = calculateWindowSizeClass(this) val isConnected by connectivityObserver.isConnected.collectAsStateWithLifecycle(initialValue = true) - val themeMode by themeDataStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM) + val themeMode by themeDataStore.themeMode.collectAsStateWithLifecycle(initialValue = themeDataStore.initialThemeMode) val darkTheme = when (themeMode) { ThemeMode.LIGHT -> false ThemeMode.DARK -> true @@ -138,7 +138,7 @@ class MainActivity : ComponentActivity() { if (uri.scheme == "librechat" && uri.host in KNOWN_DEEP_LINK_HOSTS) { deepLinkUri = uri } else if (uri.scheme == "librechat") { - Timber.w("Ignoring deep link with unknown host: %s", uri.host) + Logger.w { "Ignoring deep link with unknown host: ${uri.host}" } } } } @@ -164,7 +164,7 @@ class MainActivity : ComponentActivity() { val fileUris = if (sharedUri != null) listOf(sharedUri) else emptyList() if (sharedText != null || fileUris.isNotEmpty()) { - Timber.d("Share intent received: text=%s, uris=%d", sharedText != null, fileUris.size) + Logger.d { "Share intent received: text=${sharedText != null}, uris=${fileUris.size}" } ShareIntentConsumer.setPendingShare( SharedContent(text = sharedText, fileUris = fileUris), ) @@ -181,7 +181,7 @@ class MainActivity : ComponentActivity() { } if (!sharedUris.isNullOrEmpty()) { - Timber.d("Share multiple intent received: uris=%d", sharedUris.size) + Logger.d { "Share multiple intent received: uris=${sharedUris.size}" } ShareIntentConsumer.setPendingShare( SharedContent(fileUris = sharedUris), ) diff --git a/app/src/main/kotlin/com/librechat/android/navigation/LibreChatNavHost.kt b/app/src/main/kotlin/com/librechat/android/navigation/LibreChatNavHost.kt index 1e0ca82..b9969a9 100644 --- a/app/src/main/kotlin/com/librechat/android/navigation/LibreChatNavHost.kt +++ b/app/src/main/kotlin/com/librechat/android/navigation/LibreChatNavHost.kt @@ -60,7 +60,7 @@ import com.librechat.android.feature.settings.navigation.SHARED_LINKS_ROUTE import com.librechat.android.feature.settings.navigation.settingsGraph import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel -import timber.log.Timber +import co.touchlab.kermit.Logger /** Maps a [SettingsCategory] to its corresponding navigation route. */ fun SettingsCategory.toRoute(): String = when (this) { @@ -214,7 +214,7 @@ private fun PhoneLayout( if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { navController.navigateToChat(conversationId) } else { - Timber.w("Ignoring deep link with invalid conversation ID: %s", conversationId) + Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } } } } diff --git a/app/src/main/kotlin/com/librechat/android/navigation/NavHostViewModel.kt b/app/src/main/kotlin/com/librechat/android/navigation/NavHostViewModel.kt index c1c1b23..cfec6c1 100644 --- a/app/src/main/kotlin/com/librechat/android/navigation/NavHostViewModel.kt +++ b/app/src/main/kotlin/com/librechat/android/navigation/NavHostViewModel.kt @@ -25,11 +25,11 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import timber.log.Timber -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoUnit +import co.touchlab.kermit.Logger +import kotlin.time.Clock +import kotlin.time.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime /** * Combined UI state for the drawer sidebar. A single emission replaces @@ -64,7 +64,7 @@ class NavHostViewModel( private val settingsDataStore: com.librechat.android.core.data.datastore.SettingsDataStore, ) : ViewModel() { - private val _isLoggedIn = MutableStateFlow(false) + private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated) val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() /** @@ -227,7 +227,7 @@ class NavHostViewModel( nextCursor = result.data _hasMore.value = result.data != null } else { - Timber.w("Failed to load more conversations") + Logger.w { "Failed to load more conversations" } } _isLoadingMore.value = false } @@ -308,12 +308,12 @@ class NavHostViewModel( viewModelScope.launch { val result = bannerRepository.getBanners() if (result is Result.Success) { - val now = Instant.now() + val now = Clock.System.now() _banners.value = result.data.filter { banner -> val from = banner.displayFrom?.let { runCatching { Instant.parse(it) }.getOrNull() } val to = banner.displayTo?.let { runCatching { Instant.parse(it) }.getOrNull() } - val afterStart = from == null || !now.isBefore(from) - val beforeEnd = to == null || now.isBefore(to) + val afterStart = from == null || now >= from + val beforeEnd = to == null || now < to afterStart && beforeEnd } } @@ -350,11 +350,9 @@ class NavHostViewModel( backendVersion = detectedVersion, ) } else { - Timber.d( - "Version mismatch (backend=%s, supported=%s) but user dismissed for this version", - detectedVersion, - checkResult.supportedVersion, - ) + Logger.d { + "Version mismatch (backend=$detectedVersion, supported=${checkResult.supportedVersion}) but user dismissed for this version" + } } } } @@ -434,17 +432,26 @@ private fun Conversation.toDrawerDisplayData( } private fun Instant.toRelativeTimeString(): String { - val now = Instant.now() - val minutes = ChronoUnit.MINUTES.between(this, now) - val hours = ChronoUnit.HOURS.between(this, now) - val days = ChronoUnit.DAYS.between(this, now) + val now = Clock.System.now() + val duration = now - this + val minutes = duration.inWholeMinutes + val hours = duration.inWholeHours + val days = duration.inWholeDays return when { minutes < 1 -> "Just now" minutes < 60 -> "${minutes}m ago" hours < 24 -> "${hours}h ago" days < 7 -> "${days}d ago" - else -> atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("MMM d")) + else -> { + val date = toLocalDateTime(TimeZone.currentSystemDefault()).date + val monthAbbr = when (date.monthNumber) { + 1 -> "Jan"; 2 -> "Feb"; 3 -> "Mar"; 4 -> "Apr" + 5 -> "May"; 6 -> "Jun"; 7 -> "Jul"; 8 -> "Aug" + 9 -> "Sep"; 10 -> "Oct"; 11 -> "Nov"; 12 -> "Dec" + else -> "" + } + "$monthAbbr ${date.dayOfMonth}" + } } } diff --git a/app/src/main/kotlin/com/librechat/android/navigation/TabletLayout.kt b/app/src/main/kotlin/com/librechat/android/navigation/TabletLayout.kt index f4fc91c..f835e5b 100644 --- a/app/src/main/kotlin/com/librechat/android/navigation/TabletLayout.kt +++ b/app/src/main/kotlin/com/librechat/android/navigation/TabletLayout.kt @@ -58,7 +58,7 @@ import com.librechat.android.feature.settings.navigation.SETTINGS_TABBED_ROUTE import com.librechat.android.feature.settings.navigation.SHARED_LINKS_ROUTE import com.librechat.android.feature.settings.navigation.settingsGraph import kotlinx.coroutines.launch -import timber.log.Timber +import co.touchlab.kermit.Logger private val SidebarWidth = 320.dp @@ -109,7 +109,7 @@ fun TabletLayout( if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { navController.navigateToChat(conversationId) } else { - Timber.w("Ignoring deep link with invalid conversation ID: %s", conversationId) + Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } } } } diff --git a/app/src/test/kotlin/com/librechat/android/KoinGraphVerificationTest.kt b/app/src/test/kotlin/com/librechat/android/KoinGraphVerificationTest.kt index 1993f1a..5fb02fc 100644 --- a/app/src/test/kotlin/com/librechat/android/KoinGraphVerificationTest.kt +++ b/app/src/test/kotlin/com/librechat/android/KoinGraphVerificationTest.kt @@ -56,9 +56,11 @@ import com.librechat.android.core.network.di.networkModule import com.librechat.android.core.network.sse.SseClient import com.librechat.android.feature.agents.di.agentsModule import com.librechat.android.feature.auth.di.authModule +import com.librechat.android.feature.auth.oauth.OAuthLauncher import com.librechat.android.feature.chat.di.chatModule import com.librechat.android.feature.conversations.di.conversationsModule import com.librechat.android.feature.files.di.filesModule +import com.librechat.android.feature.files.platform.FileReader import com.librechat.android.feature.settings.di.settingsModule import com.librechat.android.navigation.appModule import kotlinx.coroutines.CoroutineDispatcher @@ -155,6 +157,10 @@ class KoinGraphVerificationTest { SpeechRepository::class, TagRepository::class, UserRepository::class, + // feature:auth platform provides + OAuthLauncher::class, + // feature:files platform provides + FileReader::class, // Wrappers/DSL types that verify can't resolve via constructor kotlin.Lazy::class, java.io.File::class, diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index bf89854..24e2402 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -6,6 +6,7 @@ dependencies { compileOnly(libs.android.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.compose.gradlePlugin) + compileOnly(libs.compose.multiplatform.gradlePlugin) compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.room.gradlePlugin) compileOnly(libs.detekt.gradlePlugin) @@ -46,5 +47,25 @@ gradlePlugin { id = "librechat.android.detekt" implementationClass = "AndroidDetektConventionPlugin" } + register("kmpLibrary") { + id = "librechat.kmp.library" + implementationClass = "KmpLibraryConventionPlugin" + } + register("kmpCompose") { + id = "librechat.kmp.compose" + implementationClass = "KmpComposeConventionPlugin" + } + register("kmpKoin") { + id = "librechat.kmp.koin" + implementationClass = "KmpKoinConventionPlugin" + } + register("kmpRoom") { + id = "librechat.kmp.room" + implementationClass = "KmpRoomConventionPlugin" + } + register("kmpFeature") { + id = "librechat.kmp.feature" + implementationClass = "KmpFeatureConventionPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 8731638..318f5d6 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -2,6 +2,8 @@ import com.android.build.api.dsl.ApplicationExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile class AndroidApplicationConventionPlugin : Plugin { override fun apply(target: Project) { @@ -11,8 +13,14 @@ class AndroidApplicationConventionPlugin : Plugin { pluginManager.apply("librechat.android.detekt") pluginManager.apply("org.jetbrains.kotlinx.kover") + tasks.withType().configureEach { + compilerOptions { + freeCompilerArgs.addAll("-opt-in=kotlin.time.ExperimentalTime") + } + } + extensions.configure { - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 26 targetSdk = 35 diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 6af25b3..b969642 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -2,6 +2,8 @@ import com.android.build.gradle.LibraryExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile class AndroidLibraryConventionPlugin : Plugin { override fun apply(target: Project) { @@ -11,8 +13,14 @@ class AndroidLibraryConventionPlugin : Plugin { pluginManager.apply("librechat.android.detekt") pluginManager.apply("org.jetbrains.kotlinx.kover") + tasks.withType().configureEach { + compilerOptions { + freeCompilerArgs.addAll("-opt-in=kotlin.time.ExperimentalTime") + } + } + extensions.configure { - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 26 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/build-logic/convention/src/main/kotlin/KmpComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpComposeConventionPlugin.kt new file mode 100644 index 0000000..cbfcbf1 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/KmpComposeConventionPlugin.kt @@ -0,0 +1,32 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType +import org.jetbrains.compose.ComposeExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +class KmpComposeConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("org.jetbrains.compose") + pluginManager.apply("org.jetbrains.kotlin.plugin.compose") + + val compose = extensions.getByType().dependencies + + extensions.configure { + sourceSets.commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + implementation(compose.ui) + implementation(compose.animation) + @Suppress("DEPRECATION") + implementation(compose.components.resources) + @Suppress("DEPRECATION") + implementation(compose.components.uiToolingPreview) + } + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt new file mode 100644 index 0000000..16d2efa --- /dev/null +++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt @@ -0,0 +1,34 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +class KmpFeatureConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("librechat.kmp.library") + pluginManager.apply("librechat.kmp.compose") + pluginManager.apply("librechat.kmp.koin") + pluginManager.apply("librechat.kotlin.serialization") + + extensions.configure { + sourceSets.commonMain.dependencies { + implementation(project(":core:ui")) + implementation(project(":core:data")) + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(libs.findLibrary("koin-compose").get()) + implementation(libs.findLibrary("koin-compose-viewmodel").get()) + implementation(libs.findLibrary("koin-compose-viewmodel-navigation").get()) + implementation(libs.findLibrary("navigation-compose-kmp").get()) + implementation(libs.findLibrary("lifecycle-runtime-compose-kmp").get()) + implementation(libs.findLibrary("lifecycle-viewmodel-compose-kmp").get()) + } + } + } + } +} + +private val Project.libs + get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") diff --git a/build-logic/convention/src/main/kotlin/KmpKoinConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpKoinConventionPlugin.kt new file mode 100644 index 0000000..94ecbd9 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/KmpKoinConventionPlugin.kt @@ -0,0 +1,23 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +class KmpKoinConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + extensions.configure { + sourceSets.commonMain.dependencies { + implementation(libs.findLibrary("koin-core").get()) + } + sourceSets.androidMain.dependencies { + implementation(libs.findLibrary("koin-android").get()) + } + } + } + } +} + +private val Project.libs + get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt new file mode 100644 index 0000000..1bf00e5 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt @@ -0,0 +1,69 @@ +import com.android.build.gradle.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +class KmpLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.library") + pluginManager.apply("org.jetbrains.kotlin.multiplatform") + pluginManager.apply("librechat.android.detekt") + pluginManager.apply("org.jetbrains.kotlinx.kover") + + extensions.configure { + compilerOptions { + freeCompilerArgs.addAll( + "-opt-in=kotlin.time.ExperimentalTime", + ) + } + androidTarget { + compilations.configureEach { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + } + } + iosArm64() + iosSimulatorArm64() + + sourceSets.commonTest.dependencies { + implementation(kotlin("test")) + } + sourceSets.named("androidUnitTest").configure { + dependencies { + implementation(libs.findBundle("testing").get()) + } + } + } + + extensions.configure { + compileSdk = 36 + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17 + targetCompatibility = org.gradle.api.JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + lint { + disable += "NullSafeMutableLiveData" + } + testOptions { + unitTests.isReturnDefaultValues = true + } + } + + dependencies.add("coreLibraryDesugaring", libs.findLibrary("desugar-jdk").get()) + } + } +} + +private val Project.libs + get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") diff --git a/build-logic/convention/src/main/kotlin/KmpRoomConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpRoomConventionPlugin.kt new file mode 100644 index 0000000..8833206 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/KmpRoomConventionPlugin.kt @@ -0,0 +1,36 @@ +import androidx.room.gradle.RoomExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class KmpRoomConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("androidx.room") + pluginManager.apply("com.google.devtools.ksp") + + extensions.configure { + schemaDirectory("$projectDir/schemas") + } + + extensions.configure { + sourceSets.commonMain.dependencies { + implementation(libs.findLibrary("room-runtime").get()) + implementation(libs.findLibrary("sqlite-bundled").get()) + } + } + + dependencies { + add("kspCommonMainMetadata", libs.findLibrary("room-compiler").get()) + add("kspAndroid", libs.findLibrary("room-compiler").get()) + add("kspIosArm64", libs.findLibrary("room-compiler").get()) + add("kspIosSimulatorArm64", libs.findLibrary("room-compiler").get()) + } + } + } +} + +private val Project.libs + get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") diff --git a/build.gradle.kts b/build.gradle.kts index 25f3f8b..58d389f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,10 +2,13 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.room) apply false + alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.skie) apply false alias(libs.plugins.detekt) apply false alias(libs.plugins.kover) } diff --git a/compose-stability.conf b/compose-stability.conf index 3164ae6..ce20484 100644 --- a/compose-stability.conf +++ b/compose-stability.conf @@ -3,40 +3,11 @@ // enabling skip optimizations for composable parameters of these types. // See: https://developer.android.com/develop/ui/compose/performance/stability/fix#configuration-file -// Core model classes (all are data classes with val-only fields, never mutated after creation) -com.librechat.android.core.model.Message -com.librechat.android.core.model.Conversation -com.librechat.android.core.model.MessageContentPart -com.librechat.android.core.model.ToolCall -com.librechat.android.core.model.ToolCallFunction -com.librechat.android.core.model.FileObject -com.librechat.android.core.model.FileReference -com.librechat.android.core.model.Attachment -com.librechat.android.core.model.Feedback -com.librechat.android.core.model.EndpointConfig -com.librechat.android.core.model.Preset -com.librechat.android.core.model.ConversationTag -com.librechat.android.core.model.Agent -com.librechat.android.core.model.PromptGroup -com.librechat.android.core.model.Prompt -com.librechat.android.core.model.mcp.McpServer -com.librechat.android.core.model.mcp.McpTool -com.librechat.android.core.model.mcp.McpApiKeyConfig -com.librechat.android.core.model.mcp.McpOAuthConfig -com.librechat.android.core.model.mcp.McpConnectionStatusResponse -com.librechat.android.core.model.mcp.McpServerStatus -com.librechat.android.core.model.Banner -com.librechat.android.core.model.speech.TtsVoice -com.librechat.android.core.model.ApiKey -com.librechat.android.core.model.SharedLink -com.librechat.android.core.model.AgentTool -com.librechat.android.core.model.AgentAction -com.librechat.android.core.model.AgentCategory -com.librechat.android.core.model.UserFavorite -com.librechat.android.core.model.Memory -com.librechat.android.core.model.StartupConfig -com.librechat.android.core.model.ModelSpecs -com.librechat.android.core.model.InterfaceConfig +// Core model classes — all are data classes with val-only fields, never mutated after creation. +// Wildcard patterns auto-cover new models without manual maintenance. +com.librechat.android.core.model.* +com.librechat.android.core.model.mcp.* +com.librechat.android.core.model.speech.* // Kotlin stdlib collections (we only use immutable instances in practice) kotlin.collections.List diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 0c08f9b..53e4983 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -1,13 +1,24 @@ plugins { - id("librechat.android.library") - id("librechat.android.koin") + id("librechat.kmp.library") + id("librechat.kmp.koin") } android { namespace = "com.librechat.android.core.common" } -dependencies { - implementation(libs.coroutines.core) - implementation(libs.coroutines.android) +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.coroutines.core) + api(libs.kotlinx.datetime) + } + androidMain.dependencies { + implementation(libs.coroutines.android) + implementation(libs.koin.android) + } + named("androidUnitTest").dependencies { + implementation(libs.koin.test) + } + } } diff --git a/core/common/src/androidMain/kotlin/com/librechat/android/core/common/di/CommonPlatformModule.android.kt b/core/common/src/androidMain/kotlin/com/librechat/android/core/common/di/CommonPlatformModule.android.kt new file mode 100644 index 0000000..48a825b --- /dev/null +++ b/core/common/src/androidMain/kotlin/com/librechat/android/core/common/di/CommonPlatformModule.android.kt @@ -0,0 +1,11 @@ +package com.librechat.android.core.common.di + +import com.librechat.android.core.common.network.AndroidConnectivityObserver +import com.librechat.android.core.common.network.ConnectivityObserver +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val commonPlatformModule: Module = module { + single { AndroidConnectivityObserver(androidContext()) } +} diff --git a/core/common/src/androidMain/kotlin/com/librechat/android/core/common/di/Dispatchers.android.kt b/core/common/src/androidMain/kotlin/com/librechat/android/core/common/di/Dispatchers.android.kt new file mode 100644 index 0000000..05e3aea --- /dev/null +++ b/core/common/src/androidMain/kotlin/com/librechat/android/core/common/di/Dispatchers.android.kt @@ -0,0 +1,6 @@ +package com.librechat.android.core.common.di + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO diff --git a/core/common/src/androidMain/kotlin/com/librechat/android/core/common/extensions/DateFormat.android.kt b/core/common/src/androidMain/kotlin/com/librechat/android/core/common/extensions/DateFormat.android.kt new file mode 100644 index 0000000..2b470e7 --- /dev/null +++ b/core/common/src/androidMain/kotlin/com/librechat/android/core/common/extensions/DateFormat.android.kt @@ -0,0 +1,13 @@ +package com.librechat.android.core.common.extensions + +import java.time.Month +import java.time.format.TextStyle +import java.util.Locale + +actual fun formatMonthYear(month: Int, year: Int): String { + val monthName = Month.of(month).getDisplayName(TextStyle.FULL, Locale.getDefault()) + return "$monthName $year" +} + +actual fun formatMonthAbbrev(month: Int): String = + Month.of(month).getDisplayName(TextStyle.SHORT, Locale.getDefault()) diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/network/ConnectivityObserver.kt b/core/common/src/androidMain/kotlin/com/librechat/android/core/common/network/AndroidConnectivityObserver.kt similarity index 93% rename from core/common/src/main/kotlin/com/librechat/android/core/common/network/ConnectivityObserver.kt rename to core/common/src/androidMain/kotlin/com/librechat/android/core/common/network/AndroidConnectivityObserver.kt index 89999f9..d458917 100644 --- a/core/common/src/main/kotlin/com/librechat/android/core/common/network/ConnectivityObserver.kt +++ b/core/common/src/androidMain/kotlin/com/librechat/android/core/common/network/AndroidConnectivityObserver.kt @@ -11,12 +11,12 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged -class ConnectivityObserver( +class AndroidConnectivityObserver( private val context: Context, -) { +) : ConnectivityObserver { // Permission is declared in app module's AndroidManifest.xml @SuppressLint("MissingPermission") - val isConnected: Flow = callbackFlow { + override val isConnected: Flow = callbackFlow { val connectivityManager = context.getSystemService(ConnectivityManager::class.java) // Emit current state diff --git a/core/common/src/test/kotlin/com/librechat/android/core/common/di/CommonModuleVerificationTest.kt b/core/common/src/androidUnitTest/kotlin/com/librechat/android/core/common/di/CommonModuleVerificationTest.kt similarity index 100% rename from core/common/src/test/kotlin/com/librechat/android/core/common/di/CommonModuleVerificationTest.kt rename to core/common/src/androidUnitTest/kotlin/com/librechat/android/core/common/di/CommonModuleVerificationTest.kt diff --git a/core/common/src/androidUnitTest/kotlin/com/librechat/android/core/common/network/ConnectivityObserverContractTest.kt b/core/common/src/androidUnitTest/kotlin/com/librechat/android/core/common/network/ConnectivityObserverContractTest.kt new file mode 100644 index 0000000..91c7e0a --- /dev/null +++ b/core/common/src/androidUnitTest/kotlin/com/librechat/android/core/common/network/ConnectivityObserverContractTest.kt @@ -0,0 +1,91 @@ +package com.librechat.android.core.common.network + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * Contract tests for ConnectivityObserver interface. + * Uses a fake implementation to verify Flow behavior. + */ +class ConnectivityObserverContractTest { + + private class FakeConnectivityObserver(initiallyConnected: Boolean = true) : ConnectivityObserver { + private val _isConnected = MutableStateFlow(initiallyConnected) + override val isConnected: Flow = _isConnected + + fun setConnected(connected: Boolean) { + _isConnected.value = connected + } + } + + @Test + fun `emits initial connected state`() = runTest { + val observer = FakeConnectivityObserver(initiallyConnected = true) + observer.isConnected.test { + assertThat(awaitItem()).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `emits initial disconnected state`() = runTest { + val observer = FakeConnectivityObserver(initiallyConnected = false) + observer.isConnected.test { + assertThat(awaitItem()).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `emits false when connection is lost`() = runTest { + val observer = FakeConnectivityObserver(initiallyConnected = true) + observer.isConnected.test { + assertThat(awaitItem()).isTrue() + observer.setConnected(false) + assertThat(awaitItem()).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `emits true when connection is restored`() = runTest { + val observer = FakeConnectivityObserver(initiallyConnected = false) + observer.isConnected.test { + assertThat(awaitItem()).isFalse() + observer.setConnected(true) + assertThat(awaitItem()).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `deduplicates consecutive identical states`() = runTest { + val observer = FakeConnectivityObserver(initiallyConnected = true) + observer.isConnected.test { + assertThat(awaitItem()).isTrue() + // Setting same value again should not emit + observer.setConnected(true) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `tracks multiple state transitions`() = runTest { + val observer = FakeConnectivityObserver(initiallyConnected = true) + observer.isConnected.test { + assertThat(awaitItem()).isTrue() + observer.setConnected(false) + assertThat(awaitItem()).isFalse() + observer.setConnected(true) + assertThat(awaitItem()).isTrue() + observer.setConnected(false) + assertThat(awaitItem()).isFalse() + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/core/common/src/test/kotlin/com/librechat/android/core/common/test/TestDispatcherRule.kt b/core/common/src/androidUnitTest/kotlin/com/librechat/android/core/common/test/TestDispatcherRule.kt similarity index 100% rename from core/common/src/test/kotlin/com/librechat/android/core/common/test/TestDispatcherRule.kt rename to core/common/src/androidUnitTest/kotlin/com/librechat/android/core/common/test/TestDispatcherRule.kt diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/BackendVersion.kt b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/BackendVersion.kt similarity index 100% rename from core/common/src/main/kotlin/com/librechat/android/core/common/BackendVersion.kt rename to core/common/src/commonMain/kotlin/com/librechat/android/core/common/BackendVersion.kt diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/Constants.kt b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/Constants.kt similarity index 100% rename from core/common/src/main/kotlin/com/librechat/android/core/common/Constants.kt rename to core/common/src/commonMain/kotlin/com/librechat/android/core/common/Constants.kt diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/di/CommonModule.kt b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/di/CommonModule.kt similarity index 80% rename from core/common/src/main/kotlin/com/librechat/android/core/common/di/CommonModule.kt rename to core/common/src/commonMain/kotlin/com/librechat/android/core/common/di/CommonModule.kt index 042c2cc..9115489 100644 --- a/core/common/src/main/kotlin/com/librechat/android/core/common/di/CommonModule.kt +++ b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/di/CommonModule.kt @@ -5,12 +5,17 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import org.koin.android.ext.koin.androidContext +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf import org.koin.dsl.module +expect val commonPlatformModule: Module + val commonModule = module { + includes(commonPlatformModule) + // Dispatchers - single(KoinQualifiers.IO) { Dispatchers.IO } + single(KoinQualifiers.IO) { ioDispatcher } single(KoinQualifiers.Default) { Dispatchers.Default } single(KoinQualifiers.Main) { Dispatchers.Main } @@ -20,7 +25,4 @@ val commonModule = module { single(KoinQualifiers.ApplicationScope) { CoroutineScope(SupervisorJob() + get(KoinQualifiers.Default)) } - - // Connectivity - single { ConnectivityObserver(androidContext()) } } diff --git a/core/common/src/commonMain/kotlin/com/librechat/android/core/common/di/Dispatchers.kt b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/di/Dispatchers.kt new file mode 100644 index 0000000..4904c85 --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/di/Dispatchers.kt @@ -0,0 +1,5 @@ +package com.librechat.android.core.common.di + +import kotlinx.coroutines.CoroutineDispatcher + +expect val ioDispatcher: CoroutineDispatcher diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/di/KoinQualifiers.kt b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/di/KoinQualifiers.kt similarity index 100% rename from core/common/src/main/kotlin/com/librechat/android/core/common/di/KoinQualifiers.kt rename to core/common/src/commonMain/kotlin/com/librechat/android/core/common/di/KoinQualifiers.kt diff --git a/core/common/src/commonMain/kotlin/com/librechat/android/core/common/extensions/DateExt.kt b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/extensions/DateExt.kt new file mode 100644 index 0000000..7421f5a --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/extensions/DateExt.kt @@ -0,0 +1,31 @@ +package com.librechat.android.core.common.extensions + +import kotlin.time.Clock +import kotlin.time.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.daysUntil +import kotlinx.datetime.toLocalDateTime + +fun String.toInstantOrNull(): Instant? = + try { Instant.parse(this) } catch (_: Exception) { null } + +fun Instant.toRelativeDateGroup(): String { + val tz = TimeZone.currentSystemDefault() + val today = Clock.System.now().toLocalDateTime(tz).date + val date = toLocalDateTime(tz).date + val daysBetween = date.daysUntil(today) + return when { + daysBetween == 0 -> "Today" + daysBetween == 1 -> "Yesterday" + daysBetween <= 7 -> "Previous 7 Days" + daysBetween <= 30 -> "Previous 30 Days" + else -> date.formatMonthYear() + } +} + +fun Long.toRelativeDateGroup(): String = + Instant.fromEpochMilliseconds(this).toRelativeDateGroup() + +private fun LocalDate.formatMonthYear(): String = + formatMonthYear(monthNumber, year) diff --git a/core/common/src/commonMain/kotlin/com/librechat/android/core/common/extensions/DateFormat.kt b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/extensions/DateFormat.kt new file mode 100644 index 0000000..e46d006 --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/extensions/DateFormat.kt @@ -0,0 +1,11 @@ +package com.librechat.android.core.common.extensions + +/** + * Returns a locale-aware full month name + year string (e.g. "January 2025" in English). + */ +expect fun formatMonthYear(month: Int, year: Int): String + +/** + * Returns a locale-aware abbreviated month name (e.g. "Jan" in English). + */ +expect fun formatMonthAbbrev(month: Int): String diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/FlowExt.kt b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/extensions/FlowExt.kt similarity index 90% rename from core/common/src/main/kotlin/com/librechat/android/core/common/extensions/FlowExt.kt rename to core/common/src/commonMain/kotlin/com/librechat/android/core/common/extensions/FlowExt.kt index cf18735..d67f658 100644 --- a/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/FlowExt.kt +++ b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/extensions/FlowExt.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.retryWhen +import kotlin.time.Clock import kotlin.math.pow fun Flow.retryWithBackoff( @@ -21,7 +22,7 @@ fun Flow.retryWithBackoff( fun Flow.throttleFirst(periodMillis: Long): Flow = flow { var lastTime = 0L collect { value -> - val currentTime = System.currentTimeMillis() + val currentTime = Clock.System.now().toEpochMilliseconds() if (currentTime - lastTime >= periodMillis) { lastTime = currentTime emit(value) diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/StringExt.kt b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/extensions/StringExt.kt similarity index 100% rename from core/common/src/main/kotlin/com/librechat/android/core/common/extensions/StringExt.kt rename to core/common/src/commonMain/kotlin/com/librechat/android/core/common/extensions/StringExt.kt diff --git a/core/common/src/commonMain/kotlin/com/librechat/android/core/common/network/ConnectivityObserver.kt b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/network/ConnectivityObserver.kt new file mode 100644 index 0000000..f6d4646 --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/network/ConnectivityObserver.kt @@ -0,0 +1,7 @@ +package com.librechat.android.core.common.network + +import kotlinx.coroutines.flow.Flow + +interface ConnectivityObserver { + val isConnected: Flow +} diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/result/ApiException.kt b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/result/ApiException.kt similarity index 100% rename from core/common/src/main/kotlin/com/librechat/android/core/common/result/ApiException.kt rename to core/common/src/commonMain/kotlin/com/librechat/android/core/common/result/ApiException.kt diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/result/Result.kt b/core/common/src/commonMain/kotlin/com/librechat/android/core/common/result/Result.kt similarity index 100% rename from core/common/src/main/kotlin/com/librechat/android/core/common/result/Result.kt rename to core/common/src/commonMain/kotlin/com/librechat/android/core/common/result/Result.kt diff --git a/core/common/src/commonTest/kotlin/com/librechat/android/core/common/result/SafeApiCallTest.kt b/core/common/src/commonTest/kotlin/com/librechat/android/core/common/result/SafeApiCallTest.kt new file mode 100644 index 0000000..c0aad22 --- /dev/null +++ b/core/common/src/commonTest/kotlin/com/librechat/android/core/common/result/SafeApiCallTest.kt @@ -0,0 +1,91 @@ +package com.librechat.android.core.common.result + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.test.fail + +class SafeApiCallTest { + + @Test + fun safeApiCallReturnsSuccessOnSuccessfulBlock() = runTest { + val result = safeApiCall { "hello" } + assertIs>(result) + assertEquals("hello", result.data) + } + + @Test + fun safeApiCallReturnsErrorOnException() = runTest { + val result = safeApiCall { throw IllegalStateException("boom") } + assertIs(result) + assertEquals("boom", result.message) + assertIs(result.exception) + } + + @Test + fun isSuccessReturnsTrueForSuccess() { + val result: Result = Result.Success("data") + assertTrue(result.isSuccess()) + assertFalse(result.isError()) + assertFalse(result.isLoading()) + } + + @Test + fun isErrorReturnsTrueForError() { + val result: Result = Result.Error(message = "fail") + assertTrue(result.isError()) + assertFalse(result.isSuccess()) + } + + @Test + fun isLoadingReturnsTrueForLoading() { + val result: Result = Result.Loading + assertTrue(result.isLoading()) + } + + @Test + fun getOrNullReturnsDataForSuccess() { + val result: Result = Result.Success("data") + assertEquals("data", result.getOrNull()) + } + + @Test + fun getOrNullReturnsNullForError() { + val result: Result = Result.Error(message = "fail") + assertNull(result.getOrNull()) + } + + @Test + fun getOrThrowReturnsDataForSuccess() { + val result: Result = Result.Success("data") + assertEquals("data", result.getOrThrow()) + } + + @Test + fun getOrThrowThrowsForErrorWithException() { + val original = IllegalArgumentException("bad arg") + val result: Result = Result.Error(original, "bad arg") + try { + result.getOrThrow() + fail("Should have thrown") + } catch (e: Exception) { + assertSame(original, e) + } + } + + @Test + fun getOrThrowThrowsForLoading() { + val result: Result = Result.Loading + try { + result.getOrThrow() + fail("Should have thrown") + } catch (e: IllegalStateException) { + assertEquals("Result is still loading", e.message) + } + } +} diff --git a/core/common/src/iosMain/kotlin/com/librechat/android/core/common/di/CommonPlatformModule.ios.kt b/core/common/src/iosMain/kotlin/com/librechat/android/core/common/di/CommonPlatformModule.ios.kt new file mode 100644 index 0000000..a5e12ae --- /dev/null +++ b/core/common/src/iosMain/kotlin/com/librechat/android/core/common/di/CommonPlatformModule.ios.kt @@ -0,0 +1,10 @@ +package com.librechat.android.core.common.di + +import com.librechat.android.core.common.network.ConnectivityObserver +import com.librechat.android.core.common.network.IosConnectivityObserver +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val commonPlatformModule: Module = module { + single { IosConnectivityObserver() } +} diff --git a/core/common/src/iosMain/kotlin/com/librechat/android/core/common/di/Dispatchers.ios.kt b/core/common/src/iosMain/kotlin/com/librechat/android/core/common/di/Dispatchers.ios.kt new file mode 100644 index 0000000..c05b5bb --- /dev/null +++ b/core/common/src/iosMain/kotlin/com/librechat/android/core/common/di/Dispatchers.ios.kt @@ -0,0 +1,6 @@ +package com.librechat.android.core.common.di + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default diff --git a/core/common/src/iosMain/kotlin/com/librechat/android/core/common/extensions/DateFormat.ios.kt b/core/common/src/iosMain/kotlin/com/librechat/android/core/common/extensions/DateFormat.ios.kt new file mode 100644 index 0000000..ab4f1fb --- /dev/null +++ b/core/common/src/iosMain/kotlin/com/librechat/android/core/common/extensions/DateFormat.ios.kt @@ -0,0 +1,33 @@ +package com.librechat.android.core.common.extensions + +import platform.Foundation.NSCalendar +import platform.Foundation.NSDateComponents +import platform.Foundation.NSDateFormatter +import platform.Foundation.NSLocale +import platform.Foundation.currentLocale + +actual fun formatMonthYear(month: Int, year: Int): String { + val formatter = NSDateFormatter() + formatter.locale = NSLocale.currentLocale + formatter.dateFormat = "MMMM yyyy" + val components = NSDateComponents() + components.month = month.toLong() + components.year = year.toLong() + components.day = 1 + val calendar = NSCalendar.currentCalendar + val date = calendar.dateFromComponents(components) ?: return "" + return formatter.stringFromDate(date) +} + +actual fun formatMonthAbbrev(month: Int): String { + val formatter = NSDateFormatter() + formatter.locale = NSLocale.currentLocale + formatter.dateFormat = "MMM" + val components = NSDateComponents() + components.month = month.toLong() + components.year = 2000 + components.day = 1 + val calendar = NSCalendar.currentCalendar + val date = calendar.dateFromComponents(components) ?: return "" + return formatter.stringFromDate(date) +} diff --git a/core/common/src/iosMain/kotlin/com/librechat/android/core/common/network/IosConnectivityObserver.kt b/core/common/src/iosMain/kotlin/com/librechat/android/core/common/network/IosConnectivityObserver.kt new file mode 100644 index 0000000..3246ddf --- /dev/null +++ b/core/common/src/iosMain/kotlin/com/librechat/android/core/common/network/IosConnectivityObserver.kt @@ -0,0 +1,32 @@ +package com.librechat.android.core.common.network + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import platform.Network.nw_path_get_status +import platform.Network.nw_path_monitor_cancel +import platform.Network.nw_path_monitor_create +import platform.Network.nw_path_monitor_set_queue +import platform.Network.nw_path_monitor_set_update_handler +import platform.Network.nw_path_monitor_start +import platform.Network.nw_path_status_satisfied +import platform.darwin.dispatch_queue_create + +@OptIn(ExperimentalForeignApi::class) +class IosConnectivityObserver : ConnectivityObserver { + override val isConnected: Flow = callbackFlow { + val monitor = nw_path_monitor_create() + val queue = dispatch_queue_create("com.librechat.network.monitor", null) + + nw_path_monitor_set_update_handler(monitor) { path -> + val connected = nw_path_get_status(path) == nw_path_status_satisfied + trySend(connected) + } + nw_path_monitor_set_queue(monitor, queue) + nw_path_monitor_start(monitor) + + awaitClose { nw_path_monitor_cancel(monitor) } + }.distinctUntilChanged() +} diff --git a/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/DateExt.kt b/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/DateExt.kt deleted file mode 100644 index 8cb51fa..0000000 --- a/core/common/src/main/kotlin/com/librechat/android/core/common/extensions/DateExt.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.librechat.android.core.common.extensions - -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoUnit - -fun String.toInstantOrNull(): Instant? = - try { Instant.parse(this) } catch (_: Exception) { null } - -fun Instant.toRelativeDateGroup(): String { - val today = LocalDate.now() - val date = atZone(ZoneId.systemDefault()).toLocalDate() - val daysBetween = ChronoUnit.DAYS.between(date, today) - return when { - daysBetween == 0L -> "Today" - daysBetween == 1L -> "Yesterday" - daysBetween <= 7L -> "Previous 7 Days" - daysBetween <= 30L -> "Previous 30 Days" - else -> date.format(DateTimeFormatter.ofPattern("MMMM yyyy")) - } -} - -fun Long.toRelativeDateGroup(): String = - Instant.ofEpochMilli(this).toRelativeDateGroup() diff --git a/core/common/src/test/kotlin/com/librechat/android/core/common/result/SafeApiCallTest.kt b/core/common/src/test/kotlin/com/librechat/android/core/common/result/SafeApiCallTest.kt deleted file mode 100644 index 80459c5..0000000 --- a/core/common/src/test/kotlin/com/librechat/android/core/common/result/SafeApiCallTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.librechat.android.core.common.result - -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class SafeApiCallTest { - - @Test - fun `safeApiCall returns Success on successful block`() = runTest { - val result = safeApiCall { "hello" } - assertThat(result).isInstanceOf(Result.Success::class.java) - assertThat((result as Result.Success).data).isEqualTo("hello") - } - - @Test - fun `safeApiCall returns Error on exception`() = runTest { - val result = safeApiCall { throw IllegalStateException("boom") } - assertThat(result).isInstanceOf(Result.Error::class.java) - val error = result as Result.Error - assertThat(error.message).isEqualTo("boom") - assertThat(error.exception).isInstanceOf(IllegalStateException::class.java) - } - - @Test - fun `isSuccess returns true for Success`() { - val result: Result = Result.Success("data") - assertThat(result.isSuccess()).isTrue() - assertThat(result.isError()).isFalse() - assertThat(result.isLoading()).isFalse() - } - - @Test - fun `isError returns true for Error`() { - val result: Result = Result.Error(message = "fail") - assertThat(result.isError()).isTrue() - assertThat(result.isSuccess()).isFalse() - } - - @Test - fun `isLoading returns true for Loading`() { - val result: Result = Result.Loading - assertThat(result.isLoading()).isTrue() - } - - @Test - fun `getOrNull returns data for Success`() { - val result: Result = Result.Success("data") - assertThat(result.getOrNull()).isEqualTo("data") - } - - @Test - fun `getOrNull returns null for Error`() { - val result: Result = Result.Error(message = "fail") - assertThat(result.getOrNull()).isNull() - } - - @Test - fun `getOrThrow returns data for Success`() { - val result: Result = Result.Success("data") - assertThat(result.getOrThrow()).isEqualTo("data") - } - - @Test - fun `getOrThrow throws for Error with exception`() { - val original = IllegalArgumentException("bad arg") - val result: Result = Result.Error(original, "bad arg") - try { - result.getOrThrow() - assertThat(false).isTrue() // Should not reach here - } catch (e: Exception) { - assertThat(e).isSameInstanceAs(original) - } - } - - @Test - fun `getOrThrow throws for Loading`() { - val result: Result = Result.Loading - try { - result.getOrThrow() - assertThat(false).isTrue() // Should not reach here - } catch (e: IllegalStateException) { - assertThat(e.message).isEqualTo("Result is still loading") - } - } -} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index aae6933..c38c5ce 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -1,7 +1,7 @@ plugins { - id("librechat.android.library") - id("librechat.android.koin") - id("librechat.android.room") + id("librechat.kmp.library") + id("librechat.kmp.koin") + id("librechat.kmp.room") id("librechat.kotlin.serialization") } @@ -9,14 +9,34 @@ android { namespace = "com.librechat.android.core.data" } -dependencies { - implementation(project(":core:network")) - implementation(project(":core:model")) - implementation(project(":core:common")) - implementation(libs.bundles.room) - implementation(libs.datastore.preferences) - implementation(libs.security.crypto) - implementation(libs.bundles.ktor) - implementation(libs.kotlinx.serialization.json) - implementation(libs.timber) +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":core:network")) + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(libs.datastore.preferences) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.logging) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + implementation(libs.kermit) + } + androidMain.dependencies { + implementation(libs.koin.android) + implementation(libs.security.crypto) + } + named("androidUnitTest").dependencies { + implementation(libs.koin.test) + } + named("androidInstrumentedTest").dependencies { + implementation(libs.room.testing) + implementation(libs.truth) + implementation(libs.coroutines.test) + implementation(libs.android.test.runner) + implementation(libs.android.test.ext.junit) + } + } } diff --git a/core/data/src/androidInstrumentedTest/kotlin/com/librechat/android/core/data/db/RoomMigrationTest.kt b/core/data/src/androidInstrumentedTest/kotlin/com/librechat/android/core/data/db/RoomMigrationTest.kt new file mode 100644 index 0000000..9438d08 --- /dev/null +++ b/core/data/src/androidInstrumentedTest/kotlin/com/librechat/android/core/data/db/RoomMigrationTest.kt @@ -0,0 +1,261 @@ +package com.librechat.android.core.data.db + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Validates Room auto-migrations (v1→v2→v3) preserve all entity data + * including complex JSON-serialized fields. + * + * Strategy: Create a v1 database, populate all 6 v1 entity types with + * realistic data (including JSON columns), run migrations to v3, + * verify every row and field survived intact. + */ +@RunWith(AndroidJUnit4::class) +class RoomMigrationTest { + + private val testDbName = "migration-test" + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + LibreChatDatabase::class.java, + ) + + @Test + fun migrateV1ToV3_allEntitiesPreserved() { + // --- Create v1 database with all 6 entity types --- + val db = helper.createDatabase(testDbName, 1) + + // 1. Conversation with JSON tags and modelParams + db.insert( + "conversations", + SQLiteDatabase.CONFLICT_REPLACE, + ContentValues().apply { + put("conversationId", "conv-001") + put("title", "Migration Test Chat") + put("user", "user-001") + put("endpoint", "openAI") + put("endpointType", "custom") + put("model", "gpt-4o") + put("agentId", "agent-001") + put("isArchived", 0) + put("tags", """["important","work"]""") + put("iconURL", "https://example.com/icon.png") + put("greeting", "Hello!") + put("modelParams", """{"temperature":0.7,"maxTokens":4096}""") + put("createdAt", 1700000000000L) + put("updatedAt", 1700001000000L) + put("lastSyncedAt", 1700000500000L) + }, + ) + + // 2. Message with JSON content, files, attachments, feedback, metadata + db.insert( + "messages", + SQLiteDatabase.CONFLICT_REPLACE, + ContentValues().apply { + put("messageId", "msg-001") + put("conversationId", "conv-001") + put("parentMessageId", "msg-000") + put("sender", "Assistant") + put("text", "Hello from migration test!") + put("content", """[{"type":"text","text":"Hello"},{"type":"image_file","image_file":{"file_id":"f1"}}]""") + put("isCreatedByUser", 0) + put("model", "gpt-4o") + put("endpoint", "openAI") + put("iconURL", null as String?) + put("unfinished", 0) + put("error", 0) + put("finishReason", "stop") + put("tokenCount", 42) + put("feedback", """{"rating":"thumbsUp","comment":"Great response"}""") + put("files", """[{"file_id":"f1","filename":"test.png","type":"image/png"}]""") + put("attachments", """[{"type":"file","url":"https://example.com/doc.pdf"}]""") + put("metadata", """{"plugins":[],"finish_reason":"stop"}""") + put("createdAt", 1700000100000L) + put("updatedAt", 1700000200000L) + }, + ) + + // 3. File entity + db.insert( + "files", + SQLiteDatabase.CONFLICT_REPLACE, + ContentValues().apply { + put("fileId", "file-001") + put("user", "user-001") + put("conversationId", "conv-001") + put("messageId", "msg-001") + put("filename", "screenshot.png") + put("filepath", "/uploads/user-001/screenshot.png") + put("type", "image/png") + put("bytes", 102400L) + put("source", "local") + put("width", 1920) + put("height", 1080) + put("createdAt", 1700000100000L) + put("updatedAt", 1700000100000L) + }, + ) + + // 4. Agent with JSON tools and conversationStarters + db.insert( + "agents", + SQLiteDatabase.CONFLICT_REPLACE, + ContentValues().apply { + put("id", "agent-001") + put("name", "Code Helper") + put("description", "Helps with code") + put("avatar", """{"filepath":"/images/agent.png","source":"local"}""") + put("provider", "openAI") + put("model", "gpt-4o") + put("category", "coding") + put("authorName", "test-user") + put("isPromoted", 1) + put("conversationStarters", """["Write a function","Debug this code","Explain this pattern"]""") + put("tools", """["code_interpreter","file_search","dalle"]""") + put("updatedAt", 1700000000000L) + }, + ) + + // 5. Preset with JSON params + // Note: "order" is a SQL reserved word, so use execSQL with backtick-quoted column + db.execSQL( + """INSERT INTO presets (presetId, title, endpoint, model, isDefault, `order`, params, createdAt, updatedAt) + VALUES ('preset-001', 'Creative Writing', 'openAI', 'gpt-4o', 1, 0, + '{"temperature":0.9,"topP":0.95,"maxTokens":8192}', 1700000000000, 1700000000000)""", + ) + + // 6. Conversation tag + db.insert( + "conversation_tags", + SQLiteDatabase.CONFLICT_REPLACE, + ContentValues().apply { + put("tag", "important") + put("user", "user-001") + put("description", "Important conversations") + put("count", 5) + put("position", 0) + put("createdAt", 1700000000000L) + put("updatedAt", 1700000000000L) + }, + ) + + db.close() + + // --- Run migrations v1 → v2 → v3 --- + val migratedDb = helper.runMigrationsAndValidate(testDbName, 3, true) + + // --- Verify all data survived --- + + // Conversations + migratedDb.query("SELECT * FROM conversations WHERE conversationId = 'conv-001'").use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("title"))).isEqualTo("Migration Test Chat") + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("endpoint"))).isEqualTo("openAI") + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("tags"))).isEqualTo("""["important","work"]""") + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("modelParams"))).isEqualTo("""{"temperature":0.7,"maxTokens":4096}""") + assertThat(cursor.getLong(cursor.getColumnIndexOrThrow("createdAt"))).isEqualTo(1700000000000L) + } + + // Messages (with all JSON fields) + migratedDb.query("SELECT * FROM messages WHERE messageId = 'msg-001'").use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("text"))).isEqualTo("Hello from migration test!") + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("content"))).contains("image_file") + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("feedback"))).contains("thumbsUp") + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("files"))).contains("test.png") + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("attachments"))).contains("doc.pdf") + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("metadata"))).contains("finish_reason") + assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("tokenCount"))).isEqualTo(42) + } + + // Files + migratedDb.query("SELECT * FROM files WHERE fileId = 'file-001'").use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("filename"))).isEqualTo("screenshot.png") + assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("width"))).isEqualTo(1920) + assertThat(cursor.getLong(cursor.getColumnIndexOrThrow("bytes"))).isEqualTo(102400L) + } + + // Agents (with JSON fields) + migratedDb.query("SELECT * FROM agents WHERE id = 'agent-001'").use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("name"))).isEqualTo("Code Helper") + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("avatar"))).contains("filepath") + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("tools"))).contains("code_interpreter") + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("conversationStarters"))).contains("Debug this code") + } + + // Presets (with JSON params) + migratedDb.query("SELECT * FROM presets WHERE presetId = 'preset-001'").use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("title"))).isEqualTo("Creative Writing") + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("params"))).contains("temperature") + } + + // Conversation tags + migratedDb.query("SELECT * FROM conversation_tags WHERE tag = 'important'").use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("count"))).isEqualTo(5) + } + + // v1→v2 added drafts table — verify it exists and is writable + migratedDb.execSQL( + "INSERT INTO drafts (conversation_id, text, updated_at) VALUES ('conv-001', 'Draft text', 1700002000000)", + ) + migratedDb.query("SELECT * FROM drafts WHERE conversation_id = 'conv-001'").use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("text"))).isEqualTo("Draft text") + } + + migratedDb.close() + } + + @Test + fun migrateV1ToV3_viaRoomApi_entitiesReadable() { + // Create and populate a v1 database + val db = helper.createDatabase(testDbName, 1) + db.insert( + "conversations", + SQLiteDatabase.CONFLICT_REPLACE, + ContentValues().apply { + put("conversationId", "conv-api-test") + put("title", "Room API Test") + put("user", "user-001") + put("isArchived", 0) + put("tags", "[]") + put("createdAt", 1700000000000L) + put("updatedAt", 1700000000000L) + put("lastSyncedAt", 0L) + }, + ) + db.close() + + // Open with Room (auto-runs migrations) + val context = InstrumentationRegistry.getInstrumentation().targetContext + val roomDb = Room.databaseBuilder( + context, + LibreChatDatabase::class.java, + testDbName, + ).build() + + // Verify we can read the migrated data via DAO + val conversation = kotlinx.coroutines.runBlocking { + roomDb.conversationDao().getById("conv-api-test") + } + assertThat(conversation).isNotNull() + assertThat(conversation!!.title).isEqualTo("Room API Test") + + roomDb.close() + } +} diff --git a/core/data/src/androidMain/kotlin/com/librechat/android/core/data/datastore/TokenDataStore.kt b/core/data/src/androidMain/kotlin/com/librechat/android/core/data/datastore/TokenDataStore.kt new file mode 100644 index 0000000..07e386c --- /dev/null +++ b/core/data/src/androidMain/kotlin/com/librechat/android/core/data/datastore/TokenDataStore.kt @@ -0,0 +1,58 @@ +package com.librechat.android.core.data.datastore + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import io.ktor.client.HttpClient +import java.security.KeyStoreException + +class TokenDataStore( + context: Context, + refreshClient: Lazy, +) : CommonTokenDataStore(refreshClient) { + + private val masterKey: MasterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs: SharedPreferences = EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + init { + initializeTokenCache() + } + + override fun readAccessToken(): String? = prefs.getString(KEY_ACCESS_TOKEN, null) + + override fun readRefreshToken(): String? = prefs.getString(KEY_REFRESH_TOKEN, null) + + override fun writeTokens(accessToken: String, refreshToken: String) { + prefs.edit() + .putString(KEY_ACCESS_TOKEN, accessToken) + .putString(KEY_REFRESH_TOKEN, refreshToken) + .apply() + } + + override fun removeTokens() { + prefs.edit() + .remove(KEY_ACCESS_TOKEN) + .remove(KEY_REFRESH_TOKEN) + .apply() + } + + override fun onKeystoreCorruption() { + removeTokens() + } + + override fun isKeystoreException(e: Exception): Boolean = e is KeyStoreException + + companion object { + private const val PREFS_NAME = "librechat_tokens" + } +} diff --git a/core/data/src/androidMain/kotlin/com/librechat/android/core/data/di/DataPlatformModule.android.kt b/core/data/src/androidMain/kotlin/com/librechat/android/core/data/di/DataPlatformModule.android.kt new file mode 100644 index 0000000..06c00d8 --- /dev/null +++ b/core/data/src/androidMain/kotlin/com/librechat/android/core/data/di/DataPlatformModule.android.kt @@ -0,0 +1,44 @@ +package com.librechat.android.core.data.di + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import androidx.room.Room +import com.librechat.android.core.common.di.KoinQualifiers +import com.librechat.android.core.data.datastore.TokenDataStore +import com.librechat.android.core.data.db.LibreChatDatabase +import com.librechat.android.core.data.repository.CommonSessionCacheCleaner +import com.librechat.android.core.data.repository.SessionCacheCleaner +import com.librechat.android.core.network.client.SecureTokenStorage +import com.librechat.android.core.network.client.TokenManager +import io.ktor.client.HttpClient +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.Module +import org.koin.dsl.binds +import org.koin.dsl.module + +private val android.content.Context.settingsDataStore: DataStore by preferencesDataStore( + name = DATASTORE_FILE_NAME, +) + +actual val dataPlatformModule: Module = module { + + // --- Database --- + single { + Room.databaseBuilder(androidContext(), LibreChatDatabase::class.java, "librechat.db").build() + } + + // --- DataStore --- + single> { androidContext().settingsDataStore } + + // --- Token Storage --- + single { + TokenDataStore( + context = androidContext(), + refreshClient = lazy(LazyThreadSafetyMode.NONE) { get(KoinQualifiers.Refresh) }, + ) + } binds arrayOf(TokenManager::class, SecureTokenStorage::class) + + // --- Session Cache Cleaner --- + single { CommonSessionCacheCleaner(androidContext().cacheDir.absolutePath) } +} diff --git a/core/data/src/androidMain/kotlin/com/librechat/android/core/data/repository/CacheDirectory.android.kt b/core/data/src/androidMain/kotlin/com/librechat/android/core/data/repository/CacheDirectory.android.kt new file mode 100644 index 0000000..da0f69f --- /dev/null +++ b/core/data/src/androidMain/kotlin/com/librechat/android/core/data/repository/CacheDirectory.android.kt @@ -0,0 +1,7 @@ +package com.librechat.android.core.data.repository + +import java.io.File + +internal actual fun deleteDirectoryRecursively(path: String) { + File(path).deleteRecursively() +} diff --git a/core/data/src/androidUnitTest/kotlin/com/librechat/android/core/data/datastore/DataStoreRoundTripTest.kt b/core/data/src/androidUnitTest/kotlin/com/librechat/android/core/data/datastore/DataStoreRoundTripTest.kt new file mode 100644 index 0000000..7114ef4 --- /dev/null +++ b/core/data/src/androidUnitTest/kotlin/com/librechat/android/core/data/datastore/DataStoreRoundTripTest.kt @@ -0,0 +1,237 @@ +package com.librechat.android.core.data.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Verifies DataStore preference round-trips after KMP migration. + * Each test writes preferences via the DataStore class, then reads + * them back and asserts the values match. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DataStoreRoundTripTest { + + @get:Rule + val tmpFolder = TemporaryFolder() + + private val testDispatcher = UnconfinedTestDispatcher() + + private fun createDataStore(name: String): DataStore { + return PreferenceDataStoreFactory.create { + File(tmpFolder.root, "$name.preferences_pb") + } + } + + // --- ServerDataStore --- + + @Test + fun serverDataStore_setAndGetUrl() = runTest(testDispatcher) { + val ds = createDataStore("server") + val store = ServerDataStore( + dataStore = ds, + ioDispatcher = testDispatcher, + ) + + assertThat(store.getBaseUrl()).isEmpty() + + store.setServerUrl("https://chat.example.com") + assertThat(store.getBaseUrl()).isEqualTo("https://chat.example.com") + } + + @Test + fun serverDataStore_trimsTrailingSlash() = runTest(testDispatcher) { + val ds = createDataStore("server-slash") + val store = ServerDataStore( + dataStore = ds, + ioDispatcher = testDispatcher, + ) + + store.setServerUrl("https://chat.example.com/") + assertThat(store.getBaseUrl()).isEqualTo("https://chat.example.com") + } + + @Test + fun serverDataStore_hasServerUrl() = runTest(testDispatcher) { + val ds = createDataStore("server-has") + val store = ServerDataStore( + dataStore = ds, + ioDispatcher = testDispatcher, + ) + + assertThat(store.hasServerUrl().first()).isFalse() + store.setServerUrl("https://chat.example.com") + assertThat(store.hasServerUrl().first()).isTrue() + } + + // --- ThemeDataStore --- + + @Test + fun themeDataStore_defaultIsSystem() = runTest(testDispatcher) { + val ds = createDataStore("theme") + val store = ThemeDataStore(ds) + + assertThat(store.themeMode.first()).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun themeDataStore_roundTrip_allModes() = runTest(testDispatcher) { + val ds = createDataStore("theme-all") + val store = ThemeDataStore(ds) + + store.setThemeMode(ThemeMode.LIGHT) + assertThat(store.themeMode.first()).isEqualTo(ThemeMode.LIGHT) + + store.setThemeMode(ThemeMode.DARK) + assertThat(store.themeMode.first()).isEqualTo(ThemeMode.DARK) + + store.setThemeMode(ThemeMode.SYSTEM) + assertThat(store.themeMode.first()).isEqualTo(ThemeMode.SYSTEM) + } + + // --- SettingsDataStore --- + + @Test + fun settingsDataStore_defaults() = runTest(testDispatcher) { + val ds = createDataStore("settings") + val store = SettingsDataStore(ds) + + assertThat(store.autoScrollEnabled.first()).isTrue() + assertThat(store.showThinkingBlocks.first()).isTrue() + assertThat(store.autoReadEnabled.first()).isFalse() + assertThat(store.dismissKeyboardOnSend.first()).isTrue() + assertThat(store.chatFontSize.first()).isEqualTo(ChatFontSize.MEDIUM) + assertThat(store.latexRenderer.first()).isEqualTo(LatexRenderer.KATEX) + assertThat(store.showAvatars.first()).isTrue() + assertThat(store.showBubbles.first()).isFalse() + } + + @Test + fun settingsDataStore_roundTrip_booleans() = runTest(testDispatcher) { + val ds = createDataStore("settings-bool") + val store = SettingsDataStore(ds) + + store.setAutoScrollEnabled(false) + store.setShowThinkingBlocks(false) + store.setAutoReadEnabled(true) + store.setDismissKeyboardOnSend(false) + store.setShowImageDescriptions(true) + + assertThat(store.autoScrollEnabled.first()).isFalse() + assertThat(store.showThinkingBlocks.first()).isFalse() + assertThat(store.autoReadEnabled.first()).isTrue() + assertThat(store.dismissKeyboardOnSend.first()).isFalse() + assertThat(store.showImageDescriptions.first()).isTrue() + } + + @Test + fun settingsDataStore_roundTrip_enums() = runTest(testDispatcher) { + val ds = createDataStore("settings-enums") + val store = SettingsDataStore(ds) + + store.setLatexRenderer(LatexRenderer.NATIVE) + assertThat(store.latexRenderer.first()).isEqualTo(LatexRenderer.NATIVE) + + store.setChatFontSize(ChatFontSize.LARGE) + assertThat(store.chatFontSize.first()).isEqualTo(ChatFontSize.LARGE) + + store.setChatFontSize(ChatFontSize.SMALL) + assertThat(store.chatFontSize.first()).isEqualTo(ChatFontSize.SMALL) + } + + @Test + fun settingsDataStore_roundTrip_strings() = runTest(testDispatcher) { + val ds = createDataStore("settings-strings") + val store = SettingsDataStore(ds) + + store.setLastUsedModel("anthropic", "claude-3.5-sonnet") + assertThat(store.lastUsedEndpoint.first()).isEqualTo("anthropic") + assertThat(store.lastUsedModel.first()).isEqualTo("claude-3.5-sonnet") + + store.setSelectedVoiceId("voice-en-us-001") + assertThat(store.selectedVoiceId.first()).isEqualTo("voice-en-us-001") + } + + @Test + fun settingsDataStore_roundTrip_floats() = runTest(testDispatcher) { + val ds = createDataStore("settings-floats") + val store = SettingsDataStore(ds) + + store.setTtsSpeechRate(1.5f) + store.setTtsPitch(0.8f) + assertThat(store.ttsSpeechRate.first()).isEqualTo(1.5f) + assertThat(store.ttsPitch.first()).isEqualTo(0.8f) + } + + @Test + fun settingsDataStore_roundTrip_bookmarks() = runTest(testDispatcher) { + val ds = createDataStore("settings-bookmarks") + val store = SettingsDataStore(ds) + + assertThat(store.bookmarkedConversationIds.first()).isEmpty() + + store.toggleBookmark("conv-001") + assertThat(store.bookmarkedConversationIds.first()).containsExactly("conv-001") + + store.toggleBookmark("conv-002") + assertThat(store.bookmarkedConversationIds.first()).containsExactly("conv-001", "conv-002") + + // Toggle off + store.toggleBookmark("conv-001") + assertThat(store.bookmarkedConversationIds.first()).containsExactly("conv-002") + } + + @Test + fun settingsDataStore_roundTrip_mcpServers() = runTest(testDispatcher) { + val ds = createDataStore("settings-mcp") + val store = SettingsDataStore(ds) + + store.setSelectedMcpServers(setOf("server-a", "server-b")) + assertThat(store.selectedMcpServers.first()).containsExactly("server-a", "server-b") + + store.setSelectedMcpServers(emptySet()) + assertThat(store.selectedMcpServers.first()).isEmpty() + } + + // --- ConfigCacheDataStore --- + + @Test + fun configCacheDataStore_roundTrip_availableModels() = runTest(testDispatcher) { + val ds = createDataStore("config-cache") + val json = Json { ignoreUnknownKeys = true; isLenient = true } + val store = ConfigCacheDataStore(ds, json) + + val models = mapOf( + "openAI" to listOf("gpt-4o", "gpt-4o-mini"), + "anthropic" to listOf("claude-3.5-sonnet", "claude-3-haiku"), + ) + + store.saveAvailableModels(models) + val loaded = store.loadAvailableModels() + + assertThat(loaded).isNotNull() + assertThat(loaded!!["openAI"]).containsExactly("gpt-4o", "gpt-4o-mini") + assertThat(loaded["anthropic"]).containsExactly("claude-3.5-sonnet", "claude-3-haiku") + } + + @Test + fun configCacheDataStore_returnsNullWhenEmpty() = runTest(testDispatcher) { + val ds = createDataStore("config-empty") + val json = Json { ignoreUnknownKeys = true } + val store = ConfigCacheDataStore(ds, json) + + assertThat(store.loadStartupConfig()).isNull() + assertThat(store.loadEndpointConfigs()).isNull() + assertThat(store.loadAvailableModels()).isNull() + } +} diff --git a/core/data/src/test/kotlin/com/librechat/android/core/data/di/DataModuleVerificationTest.kt b/core/data/src/androidUnitTest/kotlin/com/librechat/android/core/data/di/DataModuleVerificationTest.kt similarity index 100% rename from core/data/src/test/kotlin/com/librechat/android/core/data/di/DataModuleVerificationTest.kt rename to core/data/src/androidUnitTest/kotlin/com/librechat/android/core/data/di/DataModuleVerificationTest.kt diff --git a/core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/CommonTokenDataStore.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/CommonTokenDataStore.kt new file mode 100644 index 0000000..8fdade4 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/CommonTokenDataStore.kt @@ -0,0 +1,137 @@ +package com.librechat.android.core.data.datastore + +import co.touchlab.kermit.Logger +import com.librechat.android.core.model.response.RefreshResponse +import com.librechat.android.core.network.client.CookieHelper +import com.librechat.android.core.network.client.SecureTokenStorage +import com.librechat.android.core.network.client.TokenManager +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.concurrent.Volatile + +abstract class CommonTokenDataStore( + private val refreshClient: Lazy, +) : TokenManager, SecureTokenStorage { + + @Volatile + private var cachedAccessToken: String? = null + + private var tokenInitialized = false + + /** + * Eagerly load tokens from platform storage into the in-memory cache. + * Must be called from each platform subclass's `init {}` block (not from + * the super constructor, which runs before subclass properties are initialised). + */ + protected fun initializeTokenCache() { + cachedAccessToken = readAccessToken() + tokenInitialized = true + } + + private fun ensureTokenLoaded(): String? { + if (!tokenInitialized) { + cachedAccessToken = readAccessToken() + tokenInitialized = true + } + return cachedAccessToken + } + + private val refreshMutex = Mutex() + + private val _sessionExpired = MutableSharedFlow(extraBufferCapacity = 1) + override val sessionExpiredFlow: SharedFlow = _sessionExpired.asSharedFlow() + + // Platform implementations provide these + protected abstract fun readAccessToken(): String? + protected abstract fun readRefreshToken(): String? + protected abstract fun writeTokens(accessToken: String, refreshToken: String) + protected abstract fun removeTokens() + protected abstract fun onKeystoreCorruption() + + // --- TokenManager --- + + override val isAuthenticated: Boolean + get() = ensureTokenLoaded() != null + + override suspend fun getAccessToken(): String? = ensureTokenLoaded() + + override suspend fun setTokens(accessToken: String, refreshToken: String) { + cachedAccessToken = accessToken + writeTokens(accessToken, refreshToken) + } + + override suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock { + val storedRefreshToken = readRefreshToken() + if (storedRefreshToken.isNullOrBlank()) { + Logger.w { "No refresh token available" } + return false + } + + return try { + val httpResponse: HttpResponse = refreshClient.value + .post("/api/auth/refresh") { + header("Cookie", "refreshToken=$storedRefreshToken") + setBody(mapOf("refreshToken" to storedRefreshToken)) + } + + val body: RefreshResponse = httpResponse.body() + val newRefreshToken = CookieHelper.extractRefreshToken(httpResponse.headers) + ?: storedRefreshToken + + setTokens(body.token, newRefreshToken) + Logger.d { "Token refreshed successfully" } + true + } catch (e: ClientRequestException) { + Logger.w(e) { "Auth error during token refresh (status=${e.response.status})" } + clearTokens() + false + } catch (e: Exception) { + if (isKeystoreException(e)) { + Logger.e(e) { "Keystore corruption—clearing tokens" } + onKeystoreCorruption() + clearTokens() + } else { + Logger.w(e) { "Error during token refresh" } + } + false + } + } + + override suspend fun clearTokens() { + cachedAccessToken = null + removeTokens() + } + + override fun emitSessionExpired() { + _sessionExpired.tryEmit(Unit) + } + + // --- SecureTokenStorage --- + + override suspend fun getRefreshToken(): String? = readRefreshToken() + + override suspend fun storeTokens(accessToken: String, refreshToken: String) { + setTokens(accessToken, refreshToken) + } + + override suspend fun clearAll() { + clearTokens() + } + + protected open fun isKeystoreException(e: Exception): Boolean = false + + companion object { + const val KEY_ACCESS_TOKEN = "access_token" + const val KEY_REFRESH_TOKEN = "refresh_token" + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ConfigCacheDataStore.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/ConfigCacheDataStore.kt similarity index 88% rename from core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ConfigCacheDataStore.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/ConfigCacheDataStore.kt index 6040fe1..7acff97 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ConfigCacheDataStore.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/ConfigCacheDataStore.kt @@ -11,7 +11,7 @@ import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json -import timber.log.Timber +import co.touchlab.kermit.Logger class ConfigCacheDataStore( private val dataStore: DataStore, @@ -23,7 +23,7 @@ class ConfigCacheDataStore( val serialized = json.encodeToString(StartupConfig.serializer(), config) dataStore.edit { prefs -> prefs[KEY_STARTUP_CONFIG] = serialized } } catch (e: Exception) { - Timber.w(e, "Failed to cache startup config") + Logger.w(e) { "Failed to cache startup config" } } } @@ -33,7 +33,7 @@ class ConfigCacheDataStore( val serialized = prefs[KEY_STARTUP_CONFIG] ?: return null json.decodeFromString(StartupConfig.serializer(), serialized) } catch (e: Exception) { - Timber.w(e, "Failed to load cached startup config") + Logger.w(e) { "Failed to load cached startup config" } null } } @@ -44,7 +44,7 @@ class ConfigCacheDataStore( val serialized = json.encodeToString(serializer, endpoints) dataStore.edit { prefs -> prefs[KEY_ENDPOINT_CONFIGS] = serialized } } catch (e: Exception) { - Timber.w(e, "Failed to cache endpoint configs") + Logger.w(e) { "Failed to cache endpoint configs" } } } @@ -55,7 +55,7 @@ class ConfigCacheDataStore( val serializer = MapSerializer(String.serializer(), EndpointConfig.serializer()) json.decodeFromString(serializer, serialized) } catch (e: Exception) { - Timber.w(e, "Failed to load cached endpoint configs") + Logger.w(e) { "Failed to load cached endpoint configs" } null } } @@ -69,7 +69,7 @@ class ConfigCacheDataStore( val serialized = json.encodeToString(serializer, models) dataStore.edit { prefs -> prefs[KEY_AVAILABLE_MODELS] = serialized } } catch (e: Exception) { - Timber.w(e, "Failed to cache available models") + Logger.w(e) { "Failed to cache available models" } } } @@ -83,7 +83,7 @@ class ConfigCacheDataStore( ) json.decodeFromString(serializer, serialized) } catch (e: Exception) { - Timber.w(e, "Failed to load cached available models") + Logger.w(e) { "Failed to load cached available models" } null } } diff --git a/core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/ServerDataStore.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/ServerDataStore.kt new file mode 100644 index 0000000..7dfd1ec --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/ServerDataStore.kt @@ -0,0 +1,88 @@ +package com.librechat.android.core.data.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.librechat.android.core.network.client.ServerUrlProvider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking + +/** + * Optional fallback for reading/writing the server URL in a location + * that survives app reinstall (e.g. iOS Keychain). On platforms without + * a fallback (Android), pass null. + */ +interface ServerUrlKeychainFallback { + fun readServerUrl(): String? + fun writeServerUrl(url: String) + fun removeServerUrl() +} + +class ServerDataStore( + private val dataStore: DataStore, + ioDispatcher: CoroutineDispatcher, + private val keychainFallback: ServerUrlKeychainFallback? = null, +) : ServerUrlProvider { + + private val _currentUrl = MutableStateFlow("") + val currentUrlFlow: Flow = _currentUrl + + init { + // Load the persisted URL synchronously so getBaseUrl() is ready + // immediately after construction. This is a singleton created once + // at app startup, so a brief block is acceptable. + var url = runBlocking(ioDispatcher) { + dataStore.data + .map { prefs -> prefs[KEY_SERVER_URL].orEmpty() } + .first() + } + // If DataStore is empty (e.g. after reinstall), try the Keychain fallback. + // Keychain items persist across iOS app reinstalls. + if (url.isBlank() && keychainFallback != null) { + val restored = keychainFallback.readServerUrl() + if (!restored.isNullOrBlank()) { + url = restored + // Re-persist to DataStore so subsequent reads don't need the fallback + runBlocking(ioDispatcher) { + dataStore.edit { prefs -> prefs[KEY_SERVER_URL] = restored } + } + } + } + _currentUrl.value = url + } + + override fun getBaseUrl(): String = _currentUrl.value + + fun hasServerUrl(): Flow = + dataStore.data.map { prefs -> + !prefs[KEY_SERVER_URL].isNullOrBlank() + } + + suspend fun setServerUrl(url: String) { + val trimmed = url.trimEnd('/') + _currentUrl.value = trimmed + dataStore.edit { prefs -> + prefs[KEY_SERVER_URL] = trimmed + } + // Mirror to Keychain so the URL survives app reinstall on iOS + keychainFallback?.writeServerUrl(trimmed) + } + + /** + * Clear the server URL from both DataStore and Keychain fallback. + */ + suspend fun clearServerUrl() { + _currentUrl.value = "" + dataStore.edit { prefs -> prefs.remove(KEY_SERVER_URL) } + keychainFallback?.removeServerUrl() + } + + companion object { + private val KEY_SERVER_URL = stringPreferencesKey("server_url") + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/SettingsDataStore.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/SettingsDataStore.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/datastore/SettingsDataStore.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/SettingsDataStore.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ThemeDataStore.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/ThemeDataStore.kt similarity index 63% rename from core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ThemeDataStore.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/ThemeDataStore.kt index 1339384..bb4fa90 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ThemeDataStore.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/datastore/ThemeDataStore.kt @@ -4,8 +4,12 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking enum class ThemeMode { SYSTEM, LIGHT, DARK @@ -15,12 +19,16 @@ class ThemeDataStore( private val dataStore: DataStore, ) { + /** + * Synchronously loaded initial value so the first compose frame uses the correct theme + * and avoids a flash of light mode when the user has dark mode saved. + */ + val initialThemeMode: ThemeMode = runBlocking(Dispatchers.IO) { + dataStore.data.map { prefs -> prefs[KEY_THEME].toThemeMode() }.first() + } + val themeMode: Flow = dataStore.data.map { prefs -> - when (prefs[KEY_THEME]) { - "light" -> ThemeMode.LIGHT - "dark" -> ThemeMode.DARK - else -> ThemeMode.SYSTEM - } + prefs[KEY_THEME].toThemeMode() } suspend fun setThemeMode(mode: ThemeMode) { @@ -35,5 +43,11 @@ class ThemeDataStore( companion object { private val KEY_THEME = stringPreferencesKey("theme_mode") + + private fun String?.toThemeMode(): ThemeMode = when (this) { + "light" -> ThemeMode.LIGHT + "dark" -> ThemeMode.DARK + else -> ThemeMode.SYSTEM + } } } diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/LibreChatDatabase.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/LibreChatDatabase.kt similarity index 85% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/LibreChatDatabase.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/LibreChatDatabase.kt index 3eed4f4..fb56665 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/db/LibreChatDatabase.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/LibreChatDatabase.kt @@ -1,8 +1,10 @@ package com.librechat.android.core.data.db import androidx.room.AutoMigration +import androidx.room.ConstructedBy import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.RoomDatabaseConstructor import androidx.room.TypeConverters import com.librechat.android.core.data.db.converter.Converters import com.librechat.android.core.data.db.dao.AgentDao @@ -38,6 +40,7 @@ import com.librechat.android.core.data.db.entity.PresetEntity ], ) @TypeConverters(Converters::class) +@ConstructedBy(LibreChatDatabaseConstructor::class) abstract class LibreChatDatabase : RoomDatabase() { abstract fun conversationDao(): ConversationDao abstract fun messageDao(): MessageDao @@ -47,3 +50,7 @@ abstract class LibreChatDatabase : RoomDatabase() { abstract fun conversationTagDao(): ConversationTagDao abstract fun draftDao(): DraftDao } + +// Room KSP auto-generates the actual implementations for each platform +@Suppress("NO_ACTUAL_FOR_EXPECT") +expect object LibreChatDatabaseConstructor : RoomDatabaseConstructor diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/converter/Converters.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/converter/Converters.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/converter/Converters.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/converter/Converters.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/AgentDao.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/AgentDao.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/AgentDao.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/AgentDao.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/ConversationDao.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/ConversationDao.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/ConversationDao.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/ConversationDao.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/ConversationTagDao.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/ConversationTagDao.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/ConversationTagDao.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/ConversationTagDao.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/DraftDao.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/DraftDao.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/DraftDao.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/DraftDao.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/FileDao.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/FileDao.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/FileDao.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/FileDao.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/MessageDao.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/MessageDao.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/MessageDao.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/MessageDao.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/PresetDao.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/PresetDao.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/dao/PresetDao.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/dao/PresetDao.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/AgentEntity.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/AgentEntity.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/AgentEntity.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/AgentEntity.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/ConversationEntity.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/ConversationEntity.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/ConversationEntity.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/ConversationEntity.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/ConversationTagEntity.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/ConversationTagEntity.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/ConversationTagEntity.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/ConversationTagEntity.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/DraftEntity.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/DraftEntity.kt similarity index 80% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/DraftEntity.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/DraftEntity.kt index 1550282..bc0bfa4 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/DraftEntity.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/DraftEntity.kt @@ -3,6 +3,7 @@ package com.librechat.android.core.data.db.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import kotlin.time.Clock @Entity(tableName = "drafts") data class DraftEntity( @@ -12,5 +13,5 @@ data class DraftEntity( @ColumnInfo(name = "text") val text: String, @ColumnInfo(name = "updated_at") - val updatedAt: Long = System.currentTimeMillis(), + val updatedAt: Long = Clock.System.now().toEpochMilliseconds(), ) diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/FileEntity.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/FileEntity.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/FileEntity.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/FileEntity.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/MessageEntity.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/MessageEntity.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/MessageEntity.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/MessageEntity.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/PresetEntity.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/PresetEntity.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/db/entity/PresetEntity.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/db/entity/PresetEntity.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/di/DataModule.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/di/DataModule.kt similarity index 82% rename from core/data/src/main/kotlin/com/librechat/android/core/data/di/DataModule.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/di/DataModule.kt index 2934101..0d0e822 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/di/DataModule.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/di/DataModule.kt @@ -1,16 +1,11 @@ package com.librechat.android.core.data.di -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore -import androidx.room.Room import com.librechat.android.core.common.di.KoinQualifiers import com.librechat.android.core.data.datastore.ConfigCacheDataStore import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.datastore.ServerUrlKeychainFallback import com.librechat.android.core.data.datastore.SettingsDataStore import com.librechat.android.core.data.datastore.ThemeDataStore -import com.librechat.android.core.data.datastore.TokenDataStore import com.librechat.android.core.data.db.LibreChatDatabase import com.librechat.android.core.data.repository.AgentRepository import com.librechat.android.core.data.repository.AgentRepositoryImpl @@ -46,6 +41,7 @@ import com.librechat.android.core.data.repository.PromptRepository import com.librechat.android.core.data.repository.PromptRepositoryImpl import com.librechat.android.core.data.repository.SearchRepository import com.librechat.android.core.data.repository.SearchRepositoryImpl +import com.librechat.android.core.data.repository.SessionCacheCleaner import com.librechat.android.core.data.repository.ShareRepository import com.librechat.android.core.data.repository.ShareRepositoryImpl import com.librechat.android.core.data.repository.SpeechRepository @@ -54,27 +50,20 @@ import com.librechat.android.core.data.repository.TagRepository import com.librechat.android.core.data.repository.TagRepositoryImpl import com.librechat.android.core.data.repository.UserRepository import com.librechat.android.core.data.repository.UserRepositoryImpl -import com.librechat.android.core.network.client.SecureTokenStorage import com.librechat.android.core.network.client.ServerUrlProvider -import com.librechat.android.core.network.client.TokenManager -import io.ktor.client.HttpClient -import org.koin.android.ext.koin.androidContext +import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.bind -import org.koin.dsl.binds import org.koin.dsl.module -private val Context.settingsDataStore: DataStore by preferencesDataStore( - name = "librechat_settings", -) +expect val dataPlatformModule: Module val dataModule = module { - // --- Database + DAOs --- + includes(dataPlatformModule) + + // --- DAOs --- - single { - Room.databaseBuilder(androidContext(), LibreChatDatabase::class.java, "librechat.db").build() - } single { get().conversationDao() } single { get().messageDao() } single { get().fileDao() } @@ -83,20 +72,15 @@ val dataModule = module { single { get().conversationTagDao() } single { get().draftDao() } - // --- DataStore --- - - single> { androidContext().settingsDataStore } - // --- Datastores --- single { - TokenDataStore( - context = androidContext(), - refreshClient = lazy(LazyThreadSafetyMode.NONE) { get(KoinQualifiers.Refresh) }, + ServerDataStore( + dataStore = get(), + ioDispatcher = get(KoinQualifiers.IO), + keychainFallback = getOrNull(), ) - } binds arrayOf(TokenManager::class, SecureTokenStorage::class) - - singleOf(::ServerDataStore) bind ServerUrlProvider::class + } bind ServerUrlProvider::class singleOf(::ThemeDataStore) singleOf(::ConfigCacheDataStore) singleOf(::SettingsDataStore) @@ -108,7 +92,7 @@ val dataModule = module { authApi = get(), userApi = get(), tokenManager = get(), - context = androidContext(), + sessionCacheCleaner = get(), ) } diff --git a/core/data/src/commonMain/kotlin/com/librechat/android/core/data/di/DataStorePath.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/di/DataStorePath.kt new file mode 100644 index 0000000..fa4e900 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/di/DataStorePath.kt @@ -0,0 +1,6 @@ +package com.librechat.android.core.data.di + +/** + * The base name used for the DataStore preferences file on all platforms. + */ +internal const val DATASTORE_FILE_NAME = "librechat_settings" diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/mapper/ConversationMapper.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/mapper/ConversationMapper.kt similarity index 86% rename from core/data/src/main/kotlin/com/librechat/android/core/data/mapper/ConversationMapper.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/mapper/ConversationMapper.kt index c2f1c2d..f61941c 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/mapper/ConversationMapper.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/mapper/ConversationMapper.kt @@ -3,6 +3,8 @@ package com.librechat.android.core.data.mapper import com.librechat.android.core.data.db.entity.ConversationEntity import com.librechat.android.core.model.Conversation import com.librechat.android.core.model.EModelEndpoint +import kotlin.time.Clock +import kotlin.time.Instant import kotlinx.serialization.json.Json private val json = Json { ignoreUnknownKeys = true } @@ -51,14 +53,14 @@ fun ConversationEntity.toModel(): Conversation = Conversation( fun List.toModels(): List = map { it.toModel() } private fun parseTimestamp(dateString: String?): Long { - if (dateString == null) return System.currentTimeMillis() + if (dateString == null) return Clock.System.now().toEpochMilliseconds() return try { - java.time.Instant.parse(dateString).toEpochMilli() + Instant.parse(dateString).toEpochMilliseconds() } catch (_: Exception) { - System.currentTimeMillis() + Clock.System.now().toEpochMilliseconds() } } private fun formatTimestamp(epochMillis: Long): String { - return java.time.Instant.ofEpochMilli(epochMillis).toString() + return Instant.fromEpochMilliseconds(epochMillis).toString() } diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/mapper/MessageMapper.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/mapper/MessageMapper.kt similarity index 90% rename from core/data/src/main/kotlin/com/librechat/android/core/data/mapper/MessageMapper.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/mapper/MessageMapper.kt index fb92075..6d249d2 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/mapper/MessageMapper.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/mapper/MessageMapper.kt @@ -6,6 +6,8 @@ import com.librechat.android.core.model.Feedback import com.librechat.android.core.model.FileReference import com.librechat.android.core.model.Message import com.librechat.android.core.model.MessageContentPart +import kotlin.time.Clock +import kotlin.time.Instant import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject @@ -71,14 +73,14 @@ fun MessageEntity.toModel(): Message = Message( fun List.toModels(): List = map { it.toModel() } private fun parseTimestamp(dateString: String?): Long { - if (dateString == null) return System.currentTimeMillis() + if (dateString == null) return Clock.System.now().toEpochMilliseconds() return try { - java.time.Instant.parse(dateString).toEpochMilli() + Instant.parse(dateString).toEpochMilliseconds() } catch (_: Exception) { - System.currentTimeMillis() + Clock.System.now().toEpochMilliseconds() } } private fun formatTimestamp(epochMillis: Long): String { - return java.time.Instant.ofEpochMilli(epochMillis).toString() + return Instant.fromEpochMilliseconds(epochMillis).toString() } diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AgentRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/AgentRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/AgentRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/AgentRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AgentRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/AgentRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/AgentRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/AgentRepositoryImpl.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ApiKeyRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ApiKeyRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/ApiKeyRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ApiKeyRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ApiKeyRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ApiKeyRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/ApiKeyRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ApiKeyRepositoryImpl.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/AuthRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/AuthRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt similarity index 82% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt index 67bae6f..c6989e8 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/AuthRepositoryImpl.kt @@ -1,6 +1,5 @@ package com.librechat.android.core.data.repository -import android.content.Context import com.librechat.android.core.common.result.Result import com.librechat.android.core.common.result.safeApiCall import com.librechat.android.core.model.LoginOutcome @@ -9,14 +8,12 @@ import com.librechat.android.core.model.response.TwoFactorSetupResponse import com.librechat.android.core.network.api.AuthApi import com.librechat.android.core.network.api.UserApi import com.librechat.android.core.network.client.TokenManager -import timber.log.Timber -import java.io.File class AuthRepositoryImpl( private val authApi: AuthApi, private val userApi: UserApi, private val tokenManager: TokenManager, - private val context: Context, + private val sessionCacheCleaner: SessionCacheCleaner, ) : AuthRepository { override suspend fun login(email: String, password: String): Result { @@ -56,10 +53,6 @@ class AuthRepositoryImpl( } } - /** - * Verify 2FA during login using temporary token. - * Uses /api/auth/2fa/verify-temp which expects { tempToken, token, backupCode? }. - */ override suspend fun verifyTwoFactor(tempToken: String, code: String): Result { return safeApiCall { val result = authApi.verifyTempToken(tempToken = tempToken, totpCode = code) @@ -88,28 +81,11 @@ class AuthRepositoryImpl( authApi.logout() } finally { tokenManager.clearTokens() - clearSessionCaches() + sessionCacheCleaner.clearSessionCaches() } } } - /** - * Clears cached images and temporary files from the previous session. - * This prevents data from one user's session persisting after logout. - */ - private fun clearSessionCaches() { - try { - // Coil image cache (memory cache clears naturally as composables leave composition) - File(context.cacheDir, "image_cache").deleteRecursively() - // Temporary artifact files - File(context.cacheDir, "artifacts").deleteRecursively() - // Shared image files - File(context.cacheDir, "shared_images").deleteRecursively() - } catch (e: Exception) { - Timber.w(e, "Failed to clear session caches on logout") - } - } - override suspend fun isLoggedIn(): Boolean { return tokenManager.getAccessToken() != null } diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BalanceRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/BalanceRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/BalanceRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/BalanceRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BalanceRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/BalanceRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/BalanceRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/BalanceRepositoryImpl.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BannerRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/BannerRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/BannerRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/BannerRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/BannerRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/BannerRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/BannerRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/BannerRepositoryImpl.kt diff --git a/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/CacheDirectory.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/CacheDirectory.kt new file mode 100644 index 0000000..e18f0a4 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/CacheDirectory.kt @@ -0,0 +1,31 @@ +package com.librechat.android.core.data.repository + +import co.touchlab.kermit.Logger + +/** + * Subdirectories under the platform cache root that are cleared on logout. + */ +internal val CACHE_SUBDIRECTORIES = listOf("image_cache", "artifacts", "shared_images") + +/** + * Deletes a directory and all its contents at the given [path]. + */ +internal expect fun deleteDirectoryRecursively(path: String) + +/** + * Common implementation of [SessionCacheCleaner] that iterates [CACHE_SUBDIRECTORIES] + * and deletes each from the provided [cacheRoot] directory. + */ +class CommonSessionCacheCleaner( + private val cacheRoot: String, +) : SessionCacheCleaner { + override fun clearSessionCaches() { + try { + for (subdir in CACHE_SUBDIRECTORIES) { + deleteDirectoryRecursively("$cacheRoot/$subdir") + } + } catch (e: Exception) { + Logger.w(e) { "Failed to clear session caches on logout" } + } + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatPayloadBuilder.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ChatPayloadBuilder.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatPayloadBuilder.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ChatPayloadBuilder.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ChatRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ChatRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ChatRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/ChatRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ChatRepositoryImpl.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ConfigRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ConfigRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt similarity index 92% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt index d1b2a17..fda2b82 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ConfigRepositoryImpl.kt @@ -13,8 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.SerializationException -import timber.log.Timber -import java.io.IOException +import co.touchlab.kermit.Logger class ConfigRepositoryImpl( private val configApi: ConfigApi, @@ -51,10 +50,8 @@ class ConfigRepositoryImpl( Result.Error(e, message) } catch (e: HttpRequestTimeoutException) { Result.Error(e, "Connection timed out. Check the URL and try again.") - } catch (e: IOException) { - Result.Error(e, "Could not reach the server. Check the URL and your connection.") } catch (e: Exception) { - Result.Error(e, "This doesn't appear to be a LibreChat server") + Result.Error(e, "Could not reach the server. Check the URL and your connection.") } } @@ -86,7 +83,7 @@ class ConfigRepositoryImpl( if (result is Result.Error) { val cached = _startupConfig.value if (cached != null) { - Timber.d("Using cached startup config (network unavailable)") + Logger.d { "Using cached startup config (network unavailable)" } return Result.Success(cached) } } @@ -110,7 +107,7 @@ class ConfigRepositoryImpl( if (result is Result.Error) { val cached = _endpointConfigs.value if (cached.isNotEmpty()) { - Timber.d("Using cached endpoint configs (network unavailable)") + Logger.d { "Using cached endpoint configs (network unavailable)" } return Result.Success(cached) } } @@ -134,7 +131,7 @@ class ConfigRepositoryImpl( if (result is Result.Error) { val cached = _availableModels.value if (cached.isNotEmpty()) { - Timber.d("Using cached models (network unavailable)") + Logger.d { "Using cached models (network unavailable)" } return Result.Success(cached) } } @@ -167,14 +164,14 @@ class ConfigRepositoryImpl( ?: BackendVersion.extractVersionFromFooter(config?.customFooter) if (detectedVersion != null) { - Timber.d("Backend version detected: %s (supported: %s)", detectedVersion, supported) + Logger.d { "Backend version detected: $detectedVersion (supported: $supported)" } VersionCheckResult( backendVersion = detectedVersion, supportedVersion = supported, isCompatible = BackendVersion.isCompatible(supported, detectedVersion), ) } else { - Timber.d("Backend version could not be determined, skipping check") + Logger.d { "Backend version could not be determined, skipping check" } VersionCheckResult( backendVersion = null, supportedVersion = supported, diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ConversationRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ConversationRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ConversationRepositoryImpl.kt similarity index 93% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ConversationRepositoryImpl.kt index 90832bb..fa9fcb1 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ConversationRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ConversationRepositoryImpl.kt @@ -11,7 +11,8 @@ import com.librechat.android.core.model.request.ForkConversationRequest import com.librechat.android.core.network.api.ConversationsApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import timber.log.Timber +import kotlin.time.Clock +import co.touchlab.kermit.Logger class ConversationRepositoryImpl( private val conversationsApi: ConversationsApi, @@ -66,7 +67,7 @@ class ConversationRepositoryImpl( override suspend fun updateTitle(id: String, title: String): Result { return safeApiCall { val updated = conversationsApi.updateTitle(id, title) - conversationDao.updateTitle(id, title, System.currentTimeMillis()) + conversationDao.updateTitle(id, title, Clock.System.now().toEpochMilliseconds()) updated } } @@ -75,7 +76,7 @@ class ConversationRepositoryImpl( return safeApiCall { val response = conversationsApi.generateTitle(conversationId) // Update the local cache with the generated title - conversationDao.updateTitle(conversationId, response.title, System.currentTimeMillis()) + conversationDao.updateTitle(conversationId, response.title, Clock.System.now().toEpochMilliseconds()) response.title } } @@ -83,7 +84,7 @@ class ConversationRepositoryImpl( override suspend fun archive(id: String, isArchived: Boolean): Result { return safeApiCall { val updated = conversationsApi.archive(id, isArchived) - conversationDao.updateArchived(id, isArchived, System.currentTimeMillis()) + conversationDao.updateArchived(id, isArchived, Clock.System.now().toEpochMilliseconds()) updated } } @@ -131,7 +132,7 @@ class ConversationRepositoryImpl( override suspend fun importConversation(jsonContent: String): Result { return safeApiCall { - val fileBytes = jsonContent.toByteArray(Charsets.UTF_8) + val fileBytes = jsonContent.encodeToByteArray() val imported = conversationsApi.importConversations( fileBytes = fileBytes, filename = "conversation.json", @@ -161,7 +162,7 @@ class ConversationRepositoryImpl( val entities = response.conversations.map { it.toEntity() } conversationDao.upsertAll(entities) } catch (e: Exception) { - Timber.w(e, "Failed to refresh conversations") + Logger.w(e) { "Failed to refresh conversations" } } } } diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/DraftRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/DraftRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/DraftRepositoryImpl.kt similarity index 62% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/DraftRepositoryImpl.kt index 2119953..a15f2ab 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/DraftRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/DraftRepositoryImpl.kt @@ -10,25 +10,27 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class DraftRepositoryImpl( private val draftDao: DraftDao, private val ioDispatcher: CoroutineDispatcher, ) : DraftRepository { - private val cache = ConcurrentHashMap() - private val debounceJobs = ConcurrentHashMap() + private val mutex = Mutex() + private val cache = mutableMapOf() + private val debounceJobs = mutableMapOf() private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) override suspend fun getDraft(conversationId: String): String? { // Check cache first for fast reads - cache[conversationId]?.let { return it } + mutex.withLock { cache[conversationId] }?.let { return it } // Fallback to Room val entity = draftDao.getDraft(conversationId) val text = entity?.text if (text != null) { - cache[conversationId] = text + mutex.withLock { cache[conversationId] = text } } return text } @@ -39,24 +41,28 @@ class DraftRepositoryImpl( return } // Update cache immediately for fast reads - cache[conversationId] = text - // Schedule debounced write to Room - debounceJobs[conversationId]?.cancel() - debounceJobs[conversationId] = scope.launch { - delay(DEBOUNCE_MS) - draftDao.upsertDraft( - DraftEntity( - conversationId = conversationId, - text = text, - ), - ) - debounceJobs.remove(conversationId) + mutex.withLock { + cache[conversationId] = text + // Schedule debounced write to Room + debounceJobs[conversationId]?.cancel() + debounceJobs[conversationId] = scope.launch { + delay(DEBOUNCE_MS) + draftDao.upsertDraft( + DraftEntity( + conversationId = conversationId, + text = text, + ), + ) + mutex.withLock { debounceJobs.remove(conversationId) } + } } } override suspend fun deleteDraft(conversationId: String) { - cache.remove(conversationId) - debounceJobs.remove(conversationId)?.cancel() + mutex.withLock { + cache.remove(conversationId) + debounceJobs.remove(conversationId)?.cancel() + } draftDao.deleteDraft(conversationId) } diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/FileRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/FileRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/FileRepositoryImpl.kt similarity index 92% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/FileRepositoryImpl.kt index 230d020..25e81d5 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/FileRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/FileRepositoryImpl.kt @@ -7,7 +7,10 @@ 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 kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +@OptIn(ExperimentalUuidApi::class) class FileRepositoryImpl( private val filesApi: FilesApi, private val filesExtApi: FilesExtApi, @@ -23,7 +26,7 @@ class FileRepositoryImpl( filename = filename, type = type, // file_id and endpoint are required by the backend; provide defaults - fileId = java.util.UUID.randomUUID().toString(), + fileId = Uuid.random().toString(), endpoint = "agents", ) } @@ -45,7 +48,7 @@ class FileRepositoryImpl( bytes = bytes, filename = filename, type = type, - fileId = fileId ?: java.util.UUID.randomUUID().toString(), + fileId = fileId ?: Uuid.random().toString(), endpoint = endpoint, model = model, agentId = agentId, diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/KeyRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/KeyRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/KeyRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/KeyRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/KeyRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/KeyRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/KeyRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/KeyRepositoryImpl.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/McpRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/McpRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/McpRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/McpRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/McpRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/McpRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/McpRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/McpRepositoryImpl.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MemoryRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/MemoryRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/MemoryRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/MemoryRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MemoryRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/MemoryRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/MemoryRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/MemoryRepositoryImpl.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/MessageRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/MessageRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/MessageRepositoryImpl.kt similarity index 96% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/MessageRepositoryImpl.kt index f6d3cfa..f4eea27 100644 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/MessageRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/MessageRepositoryImpl.kt @@ -12,7 +12,7 @@ import com.librechat.android.core.network.api.MessagesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import timber.log.Timber +import co.touchlab.kermit.Logger class MessageRepositoryImpl( private val messagesApi: MessagesApi, @@ -37,7 +37,7 @@ class MessageRepositoryImpl( if (result is Result.Error) { val cached = messageDao.getMessagesForConversation(conversationId).first() if (cached.isNotEmpty()) { - Timber.d("Using cached messages for $conversationId (network unavailable)") + Logger.d { "Using cached messages for $conversationId (network unavailable)" } return Result.Success(cached.toModels()) } } diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PresetRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/PresetRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/PresetRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/PresetRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PresetRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/PresetRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/PresetRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/PresetRepositoryImpl.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PromptRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/PromptRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/PromptRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/PromptRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/PromptRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/PromptRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/PromptRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/PromptRepositoryImpl.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SearchRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/SearchRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/SearchRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/SearchRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SearchRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/SearchRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/SearchRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/SearchRepositoryImpl.kt diff --git a/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/SessionCacheCleaner.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/SessionCacheCleaner.kt new file mode 100644 index 0000000..76b9676 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/SessionCacheCleaner.kt @@ -0,0 +1,5 @@ +package com.librechat.android.core.data.repository + +interface SessionCacheCleaner { + fun clearSessionCaches() +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ShareRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ShareRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/ShareRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ShareRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/ShareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ShareRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/ShareRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/ShareRepositoryImpl.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SpeechRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/SpeechRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/SpeechRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/SpeechRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/SpeechRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/SpeechRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/SpeechRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/SpeechRepositoryImpl.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/TagRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/TagRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/TagRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/TagRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/TagRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/TagRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/TagRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/TagRepositoryImpl.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepository.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/UserRepository.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepository.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/UserRepository.kt diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/UserRepositoryImpl.kt similarity index 100% rename from core/data/src/main/kotlin/com/librechat/android/core/data/repository/UserRepositoryImpl.kt rename to core/data/src/commonMain/kotlin/com/librechat/android/core/data/repository/UserRepositoryImpl.kt diff --git a/core/data/src/iosMain/kotlin/com/librechat/android/core/data/datastore/IosTokenDataStore.kt b/core/data/src/iosMain/kotlin/com/librechat/android/core/data/datastore/IosTokenDataStore.kt new file mode 100644 index 0000000..18b1351 --- /dev/null +++ b/core/data/src/iosMain/kotlin/com/librechat/android/core/data/datastore/IosTokenDataStore.kt @@ -0,0 +1,166 @@ +package com.librechat.android.core.data.datastore + +import io.ktor.client.HttpClient +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import platform.CoreFoundation.CFDictionaryAddValue +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.CFMutableDictionaryRef +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.CFTypeRef +import platform.CoreFoundation.CFTypeRefVar +import platform.CoreFoundation.kCFBooleanTrue +import platform.Foundation.CFBridgingRelease +import platform.Foundation.CFBridgingRetain +import platform.Foundation.NSData +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Foundation.dataUsingEncoding +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.errSecSuccess +import platform.Security.kSecAttrAccessible +import platform.Security.kSecAttrAccessibleAfterFirstUnlock +import platform.Security.kSecAttrAccount +import platform.Security.kSecAttrService +import platform.Security.kSecClass +import platform.Security.kSecClassGenericPassword +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecReturnData +import platform.Security.kSecValueData +import co.touchlab.kermit.Logger + +@OptIn(ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) +class IosTokenDataStore( + refreshClient: Lazy, +) : CommonTokenDataStore(refreshClient), ServerUrlKeychainFallback { + + init { + initializeTokenCache() + } + + override fun readAccessToken(): String? = keychainGet(KEY_ACCESS_TOKEN) + + override fun readRefreshToken(): String? = keychainGet(KEY_REFRESH_TOKEN) + + override fun writeTokens(accessToken: String, refreshToken: String) { + keychainSet(KEY_ACCESS_TOKEN, accessToken) + keychainSet(KEY_REFRESH_TOKEN, refreshToken) + } + + override fun removeTokens() { + keychainDelete(KEY_ACCESS_TOKEN) + keychainDelete(KEY_REFRESH_TOKEN) + } + + override fun readServerUrl(): String? = keychainGet(KEY_SERVER_URL) + + override fun writeServerUrl(url: String) { + if (url.isBlank()) { + keychainDelete(KEY_SERVER_URL) + } else { + keychainSet(KEY_SERVER_URL, url) + } + } + + override fun removeServerUrl() { + keychainDelete(KEY_SERVER_URL) + } + + override fun onKeystoreCorruption() { + removeTokens() + } + + // ---- Keychain helpers using CFDictionary directly ---- + // Prior implementation bridged a Kotlin Map → NSDictionary, but + // CFStringRef keys (kSecClass, etc.) are raw CPointers that don't survive + // the Kotlin→ObjC bridging. Building a CFMutableDictionary with + // CFDictionaryAddValue avoids that problem entirely. + + @OptIn(ExperimentalForeignApi::class) + private fun keychainGet(key: String): String? { + val cfService = CFBridgingRetain(SERVICE_NAME as NSString) + val cfKey = CFBridgingRetain(key as NSString) + val query = CFDictionaryCreateMutable(null, 5, null, null) ?: return null + try { + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(query, kSecAttrService, cfService) + CFDictionaryAddValue(query, kSecAttrAccount, cfKey) + CFDictionaryAddValue(query, kSecReturnData, kCFBooleanTrue) + CFDictionaryAddValue(query, kSecMatchLimit, kSecMatchLimitOne) + + memScoped { + val result = alloc() + val status = SecItemCopyMatching(query, result.ptr) + if (status == errSecSuccess) { + val data = CFBridgingRelease(result.value) as? NSData ?: return null + return NSString.create(data = data, encoding = NSUTF8StringEncoding) as? String + } + } + } finally { + CFRelease(query) + cfService?.let { CFRelease(it) } + cfKey?.let { CFRelease(it) } + } + return null + } + + @OptIn(ExperimentalForeignApi::class) + private fun keychainSet(key: String, value: String) { + keychainDelete(key) + val data = (value as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: run { + Logger.e { "keychainSet($key): dataUsingEncoding returned null" } + return + } + val cfService = CFBridgingRetain(SERVICE_NAME as NSString) + val cfKey = CFBridgingRetain(key as NSString) + val cfData = CFBridgingRetain(data) + val query = CFDictionaryCreateMutable(null, 5, null, null) ?: return + try { + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(query, kSecAttrService, cfService) + CFDictionaryAddValue(query, kSecAttrAccount, cfKey) + CFDictionaryAddValue(query, kSecAttrAccessible, kSecAttrAccessibleAfterFirstUnlock) + CFDictionaryAddValue(query, kSecValueData, cfData) + + val status = SecItemAdd(query, null) + if (status != errSecSuccess) { + Logger.w { "keychainSet($key): SecItemAdd failed with status=$status" } + } + } finally { + CFRelease(query) + cfService?.let { CFRelease(it) } + cfKey?.let { CFRelease(it) } + cfData?.let { CFRelease(it) } + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun keychainDelete(key: String) { + val cfService = CFBridgingRetain(SERVICE_NAME as NSString) + val cfKey = CFBridgingRetain(key as NSString) + val query = CFDictionaryCreateMutable(null, 3, null, null) ?: return + try { + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(query, kSecAttrService, cfService) + CFDictionaryAddValue(query, kSecAttrAccount, cfKey) + + SecItemDelete(query) + } finally { + CFRelease(query) + cfService?.let { CFRelease(it) } + cfKey?.let { CFRelease(it) } + } + } + + companion object { + private const val SERVICE_NAME = "com.librechat.android.tokens" + private const val KEY_SERVER_URL = "server_url" + } +} diff --git a/core/data/src/iosMain/kotlin/com/librechat/android/core/data/di/DataPlatformModule.ios.kt b/core/data/src/iosMain/kotlin/com/librechat/android/core/data/di/DataPlatformModule.ios.kt new file mode 100644 index 0000000..c672d45 --- /dev/null +++ b/core/data/src/iosMain/kotlin/com/librechat/android/core/data/di/DataPlatformModule.ios.kt @@ -0,0 +1,61 @@ +package com.librechat.android.core.data.di + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import com.librechat.android.core.common.di.KoinQualifiers +import com.librechat.android.core.data.datastore.IosTokenDataStore +import com.librechat.android.core.data.datastore.ServerUrlKeychainFallback +import com.librechat.android.core.data.db.LibreChatDatabase +import com.librechat.android.core.data.repository.CommonSessionCacheCleaner +import com.librechat.android.core.data.repository.SessionCacheCleaner +import com.librechat.android.core.network.client.SecureTokenStorage +import com.librechat.android.core.network.client.TokenManager +import io.ktor.client.HttpClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import okio.Path.Companion.toPath +import org.koin.core.module.Module +import org.koin.dsl.binds +import org.koin.dsl.module +import platform.Foundation.NSHomeDirectory + +actual val dataPlatformModule: Module = module { + + // --- Database --- + single { + val dbPath = NSHomeDirectory() + "/Library/Application Support/librechat.db" + Room.databaseBuilder(name = dbPath) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + } + + // --- DataStore --- + single> { + val path = NSHomeDirectory() + "/Library/Application Support/datastore/$DATASTORE_FILE_NAME.preferences_pb" + PreferenceDataStoreFactory.createWithPath( + produceFile = { path.toPath() }, + ) + } + + // --- Token Storage --- + single { + IosTokenDataStore( + refreshClient = lazy(LazyThreadSafetyMode.NONE) { get(KoinQualifiers.Refresh) }, + ) + } binds arrayOf(TokenManager::class, SecureTokenStorage::class, ServerUrlKeychainFallback::class) + + // --- Session Cache Cleaner --- + single { + @OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + val cachePath = platform.Foundation.NSSearchPathForDirectoriesInDomains( + platform.Foundation.NSCachesDirectory, + platform.Foundation.NSUserDomainMask, + true, + ).firstOrNull() as? String ?: error("Unable to resolve NSCachesDirectory") + CommonSessionCacheCleaner(cachePath) + } +} diff --git a/core/data/src/iosMain/kotlin/com/librechat/android/core/data/repository/CacheDirectory.ios.kt b/core/data/src/iosMain/kotlin/com/librechat/android/core/data/repository/CacheDirectory.ios.kt new file mode 100644 index 0000000..b1c9d5b --- /dev/null +++ b/core/data/src/iosMain/kotlin/com/librechat/android/core/data/repository/CacheDirectory.ios.kt @@ -0,0 +1,12 @@ +package com.librechat.android.core.data.repository + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSFileManager + +@OptIn(ExperimentalForeignApi::class) +internal actual fun deleteDirectoryRecursively(path: String) { + val fm = NSFileManager.defaultManager + if (fm.fileExistsAtPath(path)) { + fm.removeItemAtPath(path, null) + } +} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ServerDataStore.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ServerDataStore.kt deleted file mode 100644 index 698e886..0000000 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/ServerDataStore.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.librechat.android.core.data.datastore - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import com.librechat.android.core.network.client.ServerUrlProvider -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking - -class ServerDataStore( - private val dataStore: DataStore, -) : ServerUrlProvider { - - private val _currentUrl = MutableStateFlow("") - val currentUrlFlow: Flow = _currentUrl - - init { - // Load the persisted URL synchronously so getBaseUrl() is ready - // immediately after construction. This is a singleton created once - // at app startup, so a brief block on Dispatchers.IO is acceptable. - _currentUrl.value = runBlocking(Dispatchers.IO) { - dataStore.data - .map { prefs -> prefs[KEY_SERVER_URL].orEmpty() } - .first() - } - } - - override fun getBaseUrl(): String = _currentUrl.value - - fun hasServerUrl(): Flow = - dataStore.data.map { prefs -> - !prefs[KEY_SERVER_URL].isNullOrBlank() - } - - suspend fun setServerUrl(url: String) { - val trimmed = url.trimEnd('/') - _currentUrl.value = trimmed - dataStore.edit { prefs -> - prefs[KEY_SERVER_URL] = trimmed - } - } - - companion object { - private val KEY_SERVER_URL = stringPreferencesKey("server_url") - } -} diff --git a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/TokenDataStore.kt b/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/TokenDataStore.kt deleted file mode 100644 index 7912f20..0000000 --- a/core/data/src/main/kotlin/com/librechat/android/core/data/datastore/TokenDataStore.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.librechat.android.core.data.datastore - -import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import com.librechat.android.core.model.response.RefreshResponse -import com.librechat.android.core.network.client.CookieHelper -import com.librechat.android.core.network.client.SecureTokenStorage -import com.librechat.android.core.network.client.TokenManager -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.plugins.ClientRequestException -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.HttpResponse -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import timber.log.Timber -import java.io.IOException -import java.security.KeyStoreException - -class TokenDataStore( - context: Context, - private val refreshClient: Lazy, -) : TokenManager, SecureTokenStorage { - - private val masterKey: MasterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - private val prefs: SharedPreferences = EncryptedSharedPreferences.create( - context, - PREFS_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - - /** Volatile in-memory cache for fast access-token reads. */ - @Volatile - private var cachedAccessToken: String? = prefs.getString(KEY_ACCESS_TOKEN, null) - - private val refreshMutex = Mutex() - - private val _sessionExpired = MutableSharedFlow(extraBufferCapacity = 1) - override val sessionExpiredFlow: SharedFlow = _sessionExpired.asSharedFlow() - - // --- TokenManager --- - - override suspend fun getAccessToken(): String? = cachedAccessToken - - override suspend fun setTokens(accessToken: String, refreshToken: String) { - cachedAccessToken = accessToken - prefs.edit() - .putString(KEY_ACCESS_TOKEN, accessToken) - .putString(KEY_REFRESH_TOKEN, refreshToken) - .apply() - } - - override suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock { - // Re-check cached token: another coroutine may have already refreshed while we waited - // on the mutex. If the cached token differs from what triggered the 401, skip refresh. - val storedRefreshToken = prefs.getString(KEY_REFRESH_TOKEN, null) - if (storedRefreshToken.isNullOrBlank()) { - Timber.w("No refresh token available") - return false - } - - return try { - // Send refresh token via both Cookie header and request body. - // 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.value - .post("/api/auth/refresh") { - header("Cookie", "refreshToken=$storedRefreshToken") - setBody(mapOf("refreshToken" to storedRefreshToken)) - } - - val body: RefreshResponse = httpResponse.body() - - // The backend may rotate the refresh token via Set-Cookie. - // If a new refresh token is present, persist it; otherwise keep the current one. - val newRefreshToken = CookieHelper.extractRefreshToken(httpResponse.headers) - ?: storedRefreshToken - - setTokens(body.token, newRefreshToken) - Timber.d("Token refreshed successfully") - true - } catch (e: KeyStoreException) { - Timber.e(e, "Keystore corruption—clearing tokens") - clearTokens() - false - } catch (e: IOException) { - Timber.w(e, "Network error during token refresh") - false - } catch (e: ClientRequestException) { - // 401/403 means session is truly expired - Timber.w(e, "Auth error during token refresh (status=${e.response.status})") - clearTokens() - false - } catch (e: Exception) { - Timber.e(e, "Unexpected error during token refresh") - false - } - } - - override suspend fun clearTokens() { - cachedAccessToken = null - prefs.edit() - .remove(KEY_ACCESS_TOKEN) - .remove(KEY_REFRESH_TOKEN) - .apply() - } - - override fun emitSessionExpired() { - _sessionExpired.tryEmit(Unit) - } - - // --- SecureTokenStorage --- - - override suspend fun getRefreshToken(): String? = - prefs.getString(KEY_REFRESH_TOKEN, null) - - override suspend fun storeTokens(accessToken: String, refreshToken: String) { - setTokens(accessToken, refreshToken) - } - - override suspend fun clearAll() { - clearTokens() - } - - companion object { - private const val PREFS_NAME = "librechat_tokens" - private const val KEY_ACCESS_TOKEN = "access_token" - private const val KEY_REFRESH_TOKEN = "refresh_token" - } -} diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index ee7d4e6..97f314e 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("librechat.android.library") + id("librechat.kmp.library") id("librechat.kotlin.serialization") } @@ -7,10 +7,11 @@ android { namespace = "com.librechat.android.core.model" } -dependencies { - implementation(project(":core:common")) - implementation(libs.kotlinx.serialization.json) - // compose-runtime is needed for @Immutable annotation on model classes, - // enabling proper skip logic in the Compose compiler. - implementation("androidx.compose.runtime:runtime:1.7.6") +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":core:common")) + implementation(libs.kotlinx.serialization.json) + } + } } diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Agent.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Agent.kt similarity index 97% rename from core/model/src/main/kotlin/com/librechat/android/core/model/Agent.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/Agent.kt index d96dd64..6e72aea 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/Agent.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Agent.kt @@ -1,6 +1,5 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement @@ -8,7 +7,6 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -@Immutable @Serializable data class Agent( val id: String, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/AgentAction.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/AgentAction.kt similarity index 96% rename from core/model/src/main/kotlin/com/librechat/android/core/model/AgentAction.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/AgentAction.kt index a63d1e5..24b133b 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/AgentAction.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/AgentAction.kt @@ -1,10 +1,8 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Immutable @Serializable data class AgentAction( @SerialName("action_id") val actionId: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/AgentCategory.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/AgentCategory.kt similarity index 82% rename from core/model/src/main/kotlin/com/librechat/android/core/model/AgentCategory.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/AgentCategory.kt index fa48e7f..c6ec87c 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/AgentCategory.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/AgentCategory.kt @@ -1,9 +1,7 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable -@Immutable @Serializable data class AgentCategory( val value: String, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/AgentExpanded.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/AgentExpanded.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/AgentExpanded.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/AgentExpanded.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/AgentTool.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/AgentTool.kt similarity index 90% rename from core/model/src/main/kotlin/com/librechat/android/core/model/AgentTool.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/AgentTool.kt index 5201b38..60d0ee8 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/AgentTool.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/AgentTool.kt @@ -1,11 +1,9 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement -@Immutable @Serializable data class AgentTool( @SerialName("tool_id") val toolId: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ApiKey.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/ApiKey.kt similarity index 90% rename from core/model/src/main/kotlin/com/librechat/android/core/model/ApiKey.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/ApiKey.kt index 78024cc..2f49a94 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/ApiKey.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/ApiKey.kt @@ -1,10 +1,8 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Immutable @Serializable data class ApiKey( val id: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Balance.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Balance.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/Balance.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/Balance.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Banner.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Banner.kt similarity index 89% rename from core/model/src/main/kotlin/com/librechat/android/core/model/Banner.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/Banner.kt index cec9e82..bf565bd 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/Banner.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Banner.kt @@ -1,9 +1,7 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable -@Immutable @Serializable data class Banner( val bannerId: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Conversation.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Conversation.kt similarity index 96% rename from core/model/src/main/kotlin/com/librechat/android/core/model/Conversation.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/Conversation.kt index bd3c420..8b1f1ae 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/Conversation.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Conversation.kt @@ -1,10 +1,8 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Immutable @Serializable data class Conversation( val conversationId: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ConversationExport.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/ConversationExport.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/ConversationExport.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/ConversationExport.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ConversationTag.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/ConversationTag.kt similarity index 89% rename from core/model/src/main/kotlin/com/librechat/android/core/model/ConversationTag.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/ConversationTag.kt index fdc54e0..b6e85d4 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/ConversationTag.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/ConversationTag.kt @@ -1,10 +1,8 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Immutable @Serializable data class ConversationTag( @SerialName("_id") val id: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Endpoint.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Endpoint.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/Endpoint.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/Endpoint.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/EndpointConfig.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/EndpointConfig.kt similarity index 89% rename from core/model/src/main/kotlin/com/librechat/android/core/model/EndpointConfig.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/EndpointConfig.kt index fa276f7..bff23d1 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/EndpointConfig.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/EndpointConfig.kt @@ -1,9 +1,7 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable -@Immutable @Serializable data class EndpointConfig( val type: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Feedback.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Feedback.kt similarity index 83% rename from core/model/src/main/kotlin/com/librechat/android/core/model/Feedback.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/Feedback.kt index 04c9918..3a46f5b 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/Feedback.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Feedback.kt @@ -1,10 +1,8 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement -@Immutable @Serializable data class Feedback( val rating: FeedbackRating, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/FileModel.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/FileModel.kt similarity index 94% rename from core/model/src/main/kotlin/com/librechat/android/core/model/FileModel.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/FileModel.kt index 83aed40..ae3f3e3 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/FileModel.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/FileModel.kt @@ -1,10 +1,8 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Immutable @Serializable data class FileObject( @SerialName("file_id") val fileId: String, @@ -23,7 +21,6 @@ data class FileObject( val updatedAt: String? = null, ) -@Immutable @Serializable data class FileReference( @SerialName("file_id") val fileId: String? = null, @@ -36,7 +33,6 @@ data class FileReference( val source: String? = null, ) -@Immutable @Serializable data class Attachment( @SerialName("file_id") val fileId: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/LoginOutcome.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/LoginOutcome.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/LoginOutcome.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/LoginOutcome.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Memory.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Memory.kt similarity index 88% rename from core/model/src/main/kotlin/com/librechat/android/core/model/Memory.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/Memory.kt index 4042fae..185625c 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/Memory.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Memory.kt @@ -1,10 +1,8 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Immutable @Serializable data class Memory( val key: String, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Message.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Message.kt similarity index 95% rename from core/model/src/main/kotlin/com/librechat/android/core/model/Message.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/Message.kt index 35cc3e4..25d3dbf 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/Message.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Message.kt @@ -1,11 +1,9 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject -@Immutable @Serializable data class Message( val messageId: String, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/MessageContentPart.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/MessageContentPart.kt similarity index 97% rename from core/model/src/main/kotlin/com/librechat/android/core/model/MessageContentPart.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/MessageContentPart.kt index 1e1c81f..9302ac9 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/MessageContentPart.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/MessageContentPart.kt @@ -1,11 +1,9 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement -@Immutable @Serializable data class MessageContentPart( val type: ContentType, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/PaginatedAgents.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/PaginatedAgents.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/PaginatedAgents.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/PaginatedAgents.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ParameterDefinition.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/ParameterDefinition.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/ParameterDefinition.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/ParameterDefinition.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Preset.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Preset.kt similarity index 95% rename from core/model/src/main/kotlin/com/librechat/android/core/model/Preset.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/Preset.kt index 14c9f0f..0b63f69 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/Preset.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Preset.kt @@ -1,10 +1,8 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Immutable @Serializable data class Preset( val presetId: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/Prompt.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Prompt.kt similarity index 93% rename from core/model/src/main/kotlin/com/librechat/android/core/model/Prompt.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/Prompt.kt index d8d1ec2..710f822 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/Prompt.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/Prompt.kt @@ -1,10 +1,8 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Immutable @Serializable data class Prompt( @SerialName("_id") val id: String? = null, @@ -21,7 +19,6 @@ data class ProductionPromptEmbed( val prompt: String? = null, ) -@Immutable @Serializable data class PromptGroup( @SerialName("_id") val id: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ServerConnection.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/ServerConnection.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/ServerConnection.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/ServerConnection.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/SharedLink.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/SharedLink.kt similarity index 89% rename from core/model/src/main/kotlin/com/librechat/android/core/model/SharedLink.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/SharedLink.kt index ac2879e..5bceff0 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/SharedLink.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/SharedLink.kt @@ -1,10 +1,8 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Immutable @Serializable data class SharedLink( @SerialName("_id") val id: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/SseContentEvent.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/SseContentEvent.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/SseContentEvent.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/SseContentEvent.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/StartupConfig.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/StartupConfig.kt similarity index 98% rename from core/model/src/main/kotlin/com/librechat/android/core/model/StartupConfig.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/StartupConfig.kt index fee368d..3aa021d 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/StartupConfig.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/StartupConfig.kt @@ -1,12 +1,10 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject -@Immutable @Serializable data class StartupConfig( val appTitle: String = "LibreChat", @@ -55,7 +53,6 @@ data class StartupConfig( val version: String? = null, ) -@Immutable @Serializable data class ModelSpecs( val list: List = emptyList(), @@ -70,7 +67,6 @@ data class ModelSpec( val description: String? = null, ) -@Immutable @Serializable data class InterfaceConfig( val privacyPolicy: PrivacyPolicyConfig? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/StreamEvent.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/StreamEvent.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/StreamEvent.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/StreamEvent.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/SupportContact.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/SupportContact.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/SupportContact.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/SupportContact.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ToolAuthStatus.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/ToolAuthStatus.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/ToolAuthStatus.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/ToolAuthStatus.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ToolCallRecord.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/ToolCallRecord.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/ToolCallRecord.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/ToolCallRecord.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/ToolCallResult.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/ToolCallResult.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/ToolCallResult.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/ToolCallResult.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/User.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/User.kt similarity index 93% rename from core/model/src/main/kotlin/com/librechat/android/core/model/User.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/User.kt index 2654552..4914918 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/User.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/User.kt @@ -1,6 +1,5 @@ package com.librechat.android.core.model -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -22,7 +21,6 @@ data class User( val updatedAt: String? = null, ) -@Immutable @Serializable data class UserFavorite( val agentId: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/UserKey.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/UserKey.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/UserKey.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/UserKey.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpConnectionStatus.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpConnectionStatus.kt similarity index 91% rename from core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpConnectionStatus.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpConnectionStatus.kt index 8e55a88..e3b77cc 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpConnectionStatus.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpConnectionStatus.kt @@ -1,20 +1,17 @@ package com.librechat.android.core.model.mcp -import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable /** * Response from GET /api/mcp/connection/status. * Backend returns: { success: true, connectionStatus: { "serverName": { connectionState, requiresOAuth, error? } } } */ -@Immutable @Serializable data class McpConnectionStatusResponse( val success: Boolean = false, val connectionStatus: Map = emptyMap(), ) -@Immutable @Serializable data class McpServerStatus( val connectionState: String = "disconnected", diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpOAuth.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpOAuth.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpOAuth.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpOAuth.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpRequests.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpRequests.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpRequests.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpRequests.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpServer.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpServer.kt similarity index 96% rename from core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpServer.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpServer.kt index a593b9a..9584f29 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpServer.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpServer.kt @@ -1,6 +1,5 @@ package com.librechat.android.core.model.mcp -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -9,7 +8,6 @@ import kotlinx.serialization.Serializable * The backend returns a map of server name -> config from GET /api/mcp/servers. * This model is constructed from that map in the API layer. */ -@Immutable data class McpServer( val name: String, val url: String = "", @@ -45,7 +43,6 @@ enum class McpServerType { enum class McpAuthMode { NONE, API_KEY, OAUTH } /** Maps to backend apiKey object: { source, authorization_type, key?, custom_header? }. */ -@Immutable @Serializable data class McpApiKeyConfig( val source: McpApiKeySource = McpApiKeySource.USER, @@ -76,7 +73,6 @@ enum class McpAuthorizationType { } /** Maps to backend oauth object. Only the most common fields are exposed in the UI. */ -@Immutable @Serializable data class McpOAuthConfig( @SerialName("authorization_url") val authorizationUrl: String? = null, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpTool.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpTool.kt similarity index 88% rename from core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpTool.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpTool.kt index 809a860..bce7479 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/mcp/McpTool.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/mcp/McpTool.kt @@ -1,11 +1,9 @@ package com.librechat.android.core.model.mcp -import androidx.compose.runtime.Immutable import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject -@Immutable @Serializable data class McpTool( val name: String, diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/AddPromptToGroupRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/AddPromptToGroupRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/AddPromptToGroupRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/AddPromptToGroupRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/AddedConversation.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/AddedConversation.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/AddedConversation.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/AddedConversation.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/AgentActionRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/AgentActionRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/AgentActionRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/AgentActionRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ArchiveConversationRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ArchiveConversationRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/ArchiveConversationRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ArchiveConversationRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/BranchMessageRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/BranchMessageRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/BranchMessageRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/BranchMessageRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatAbortRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ChatAbortRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatAbortRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ChatAbortRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ChatRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/ChatRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ChatRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ConvoDeleteBody.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ConvoDeleteBody.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/ConvoDeleteBody.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ConvoDeleteBody.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ConvoUpdateBody.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ConvoUpdateBody.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/ConvoUpdateBody.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ConvoUpdateBody.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateAgentRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/CreateAgentRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateAgentRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/CreateAgentRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateApiKeyRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/CreateApiKeyRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateApiKeyRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/CreateApiKeyRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateMemoryRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/CreateMemoryRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateMemoryRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/CreateMemoryRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreatePromptRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/CreatePromptRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/CreatePromptRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/CreatePromptRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateShareRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/CreateShareRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateShareRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/CreateShareRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateTagRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/CreateTagRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/CreateTagRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/CreateTagRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/DeleteFilesRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/DeleteFilesRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/DeleteFilesRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/DeleteFilesRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/DuplicateConversationRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/DuplicateConversationRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/DuplicateConversationRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/DuplicateConversationRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/EphemeralAgent.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/EphemeralAgent.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/EphemeralAgent.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/EphemeralAgent.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/FeedbackRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/FeedbackRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/FeedbackRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/FeedbackRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ForkConversationRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ForkConversationRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/ForkConversationRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ForkConversationRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/LoginRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/LoginRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/LoginRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/LoginRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/OtpVerificationRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/OtpVerificationRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/OtpVerificationRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/OtpVerificationRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/PasswordResetRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/PasswordResetRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/PasswordResetRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/PasswordResetRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/PresetDeleteRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/PresetDeleteRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/PresetDeleteRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/PresetDeleteRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/RegisterRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/RegisterRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/RegisterRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/RegisterRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/RevertAgentRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/RevertAgentRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/RevertAgentRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/RevertAgentRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/SaveMessageRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/SaveMessageRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/SaveMessageRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/SaveMessageRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/TagUpdateRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/TagUpdateRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/TagUpdateRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/TagUpdateRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/ToolCallRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ToolCallRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/ToolCallRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/ToolCallRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/TwoFactorDisableRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/TwoFactorDisableRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/TwoFactorDisableRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/TwoFactorDisableRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/TwoFactorVerifyRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/TwoFactorVerifyRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/TwoFactorVerifyRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/TwoFactorVerifyRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateAgentRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdateAgentRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateAgentRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdateAgentRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateArtifactRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdateArtifactRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateArtifactRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdateArtifactRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateKeyRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdateKeyRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateKeyRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdateKeyRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMemoryPreferencesRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdateMemoryPreferencesRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMemoryPreferencesRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdateMemoryPreferencesRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMemoryRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdateMemoryRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMemoryRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdateMemoryRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMessageRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdateMessageRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdateMessageRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdateMessageRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdatePromptGroupRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdatePromptGroupRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdatePromptGroupRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdatePromptGroupRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdatePromptTagRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdatePromptTagRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/UpdatePromptTagRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/UpdatePromptTagRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/request/VerifyEmailRequest.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/VerifyEmailRequest.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/request/VerifyEmailRequest.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/request/VerifyEmailRequest.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ActiveJobsResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ActiveJobsResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/ActiveJobsResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ActiveJobsResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/AgentListResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/AgentListResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/AgentListResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/AgentListResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/Category.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/Category.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/Category.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/Category.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatAbortResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ChatAbortResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatAbortResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ChatAbortResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatStartResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ChatStartResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatStartResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ChatStartResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatStatusResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ChatStatusResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/ChatStatusResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ChatStatusResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ConversationListResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ConversationListResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/ConversationListResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ConversationListResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/FileUploadConfig.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/FileUploadConfig.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/FileUploadConfig.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/FileUploadConfig.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ForkConversationResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ForkConversationResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/ForkConversationResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ForkConversationResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/GenerateTitleResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/GenerateTitleResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/GenerateTitleResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/GenerateTitleResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/LoginResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/LoginResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/LoginResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/LoginResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/MessageListResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/MessageListResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/MessageListResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/MessageListResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/PromptGroupListResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/PromptGroupListResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/PromptGroupListResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/PromptGroupListResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/PromptListResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/PromptListResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/PromptListResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/PromptListResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/RefreshResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/RefreshResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/RefreshResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/RefreshResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/RegisterResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/RegisterResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/RegisterResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/RegisterResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/SearchResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/SearchResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/SearchResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/SearchResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/ShareLinkCheckResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ShareLinkCheckResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/ShareLinkCheckResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/ShareLinkCheckResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/SharedLinksResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/SharedLinksResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/SharedLinksResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/SharedLinksResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/TermsResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/TermsResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/TermsResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/TermsResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/response/TwoFactorSetupResponse.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/TwoFactorSetupResponse.kt similarity index 100% rename from core/model/src/main/kotlin/com/librechat/android/core/model/response/TwoFactorSetupResponse.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/response/TwoFactorSetupResponse.kt diff --git a/core/model/src/main/kotlin/com/librechat/android/core/model/speech/SpeechModels.kt b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/speech/SpeechModels.kt similarity index 98% rename from core/model/src/main/kotlin/com/librechat/android/core/model/speech/SpeechModels.kt rename to core/model/src/commonMain/kotlin/com/librechat/android/core/model/speech/SpeechModels.kt index a3a14fc..29f0142 100644 --- a/core/model/src/main/kotlin/com/librechat/android/core/model/speech/SpeechModels.kt +++ b/core/model/src/commonMain/kotlin/com/librechat/android/core/model/speech/SpeechModels.kt @@ -1,6 +1,5 @@ package com.librechat.android.core.model.speech -import androidx.compose.runtime.Immutable import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -26,7 +25,6 @@ data class TextToSpeechRequest( val model: String? = null, ) -@Immutable @Serializable data class TtsVoice( val id: String, diff --git a/core/model/src/commonTest/kotlin/com/librechat/android/core/model/AgentSerializationTest.kt b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/AgentSerializationTest.kt new file mode 100644 index 0000000..b92fb21 --- /dev/null +++ b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/AgentSerializationTest.kt @@ -0,0 +1,135 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AgentSerializationTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun minimalAgentRoundTrip() { + val original = Agent(id = "agent-001") + val encoded = json.encodeToString(Agent.serializer(), original) + val decoded = json.decodeFromString(Agent.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun agentWithStringAvatarRoundTrip() { + val original = Agent( + id = "agent-002", + name = "Test Agent", + avatar = JsonPrimitive("https://example.com/avatar.png"), + ) + val encoded = json.encodeToString(Agent.serializer(), original) + val decoded = json.decodeFromString(Agent.serializer(), encoded) + assertEquals(original, decoded) + assertEquals("https://example.com/avatar.png", decoded.avatarUrl) + } + + @Test + fun agentWithObjectAvatarRoundTrip() { + val avatarObj = buildJsonObject { + put("filepath", "/images/agent.png") + put("source", "upload") + } + val original = Agent( + id = "agent-003", + name = "Object Avatar Agent", + avatar = avatarObj, + ) + val encoded = json.encodeToString(Agent.serializer(), original) + val decoded = json.decodeFromString(Agent.serializer(), encoded) + assertEquals(original, decoded) + assertEquals("/images/agent.png", decoded.avatarUrl) + } + + @Test + fun agentAvatarUrlReturnsNullForNonHttpString() { + val original = Agent( + id = "agent-004", + avatar = JsonPrimitive("not-a-url"), + ) + assertNull(original.avatarUrl) + } + + @Test + fun fullyPopulatedAgentRoundTrip() { + val original = Agent( + id = "agent-005", + mongoId = "65abc123", + name = "Full Agent", + description = "A test agent", + instructions = "Be helpful", + avatar = JsonPrimitive("https://example.com/agent.png"), + provider = "openAI", + model = "gpt-4o", + modelParameters = buildJsonObject { + put("temperature", 0.7) + put("max_tokens", 4096) + }, + artifacts = "true", + accessLevel = 2, + recursionLimit = 25, + hideSequentialOutputs = true, + endAfterTools = false, + category = "productivity", + author = "user-123", + authorName = "Test User", + isPromoted = true, + isPublic = true, + conversationStarters = listOf("Hello!", "What can you do?"), + tools = listOf("web_search", "code_interpreter"), + actions = listOf("action-1"), + agentIds = listOf("sub-agent-1"), + isCollaborative = false, + projectIds = listOf("proj-1"), + updatedAt = "2026-03-28T12:00:00Z", + createdAt = "2026-03-28T10:00:00Z", + supportContact = buildJsonObject { + put("name", "Support") + put("email", "support@example.com") + }, + toolOptions = JsonObject(mapOf("web_search" to JsonPrimitive(true))), + mcpServerNames = listOf("mcp-1"), + ) + val encoded = json.encodeToString(Agent.serializer(), original) + val decoded = json.decodeFromString(Agent.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun agentDeserializesSerialNames() { + val serverJson = """ + { + "id": "agent-srv", + "_id": "mongo-id", + "model_parameters": {"temperature": 0.5}, + "access_level": 1, + "recursion_limit": 10, + "hide_sequential_outputs": false, + "end_after_tools": true, + "is_promoted": false, + "conversation_starters": ["Hi"], + "agent_ids": [], + "support_contact": null, + "tool_options": null + } + """.trimIndent() + val decoded = json.decodeFromString(Agent.serializer(), serverJson) + assertEquals("agent-srv", decoded.id) + assertEquals("mongo-id", decoded.mongoId) + assertEquals(1, decoded.accessLevel) + assertEquals(10, decoded.recursionLimit) + assertEquals(false, decoded.hideSequentialOutputs) + assertEquals(true, decoded.endAfterTools) + assertEquals(listOf("Hi"), decoded.conversationStarters) + } +} diff --git a/core/model/src/commonTest/kotlin/com/librechat/android/core/model/ConversationSerializationTest.kt b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/ConversationSerializationTest.kt new file mode 100644 index 0000000..43269f0 --- /dev/null +++ b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/ConversationSerializationTest.kt @@ -0,0 +1,103 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConversationSerializationTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun minimalConversationRoundTrip() { + val original = Conversation( + conversationId = "conv-001", + title = "Test Chat", + ) + val encoded = json.encodeToString(Conversation.serializer(), original) + val decoded = json.decodeFromString(Conversation.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun fullyPopulatedConversationRoundTrip() { + val original = Conversation( + conversationId = "conv-002", + title = "Full Chat", + user = "user-123", + endpoint = EModelEndpoint.OPENAI, + endpointType = EModelEndpoint.AGENTS, + model = "gpt-4o", + agentId = "agent-abc", + assistantId = "asst-def", + tags = listOf("work", "important"), + isArchived = true, + temperature = 0.7, + topP = 0.9, + topK = 40, + frequencyPenalty = 0.5, + presencePenalty = 0.3, + maxOutputTokens = 4096, + maxContextTokens = 128000, + maxTokens = 4096, + system = "You are a helpful assistant.", + reasoningEffort = "high", + effort = "high", + thinkingLevel = "extended", + stop = listOf("\n\n", "END"), + iconURL = "https://example.com/icon.png", + greeting = "Hello!", + spec = "openAI", + tools = listOf("web_search", "code_interpreter"), + webSearch = true, + createdAt = "2026-03-28T10:00:00Z", + updatedAt = "2026-03-28T11:00:00Z", + ) + val encoded = json.encodeToString(Conversation.serializer(), original) + val decoded = json.decodeFromString(Conversation.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun conversationWithDefaultsRoundTrip() { + val original = Conversation() + val encoded = json.encodeToString(Conversation.serializer(), original) + val decoded = json.decodeFromString(Conversation.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun conversationDeserializesSerialNames() { + val serverJson = """ + { + "conversationId": "conv-srv", + "agent_id": "agent-1", + "assistant_id": "asst-1", + "top_p": 0.95, + "frequency_penalty": 0.2, + "presence_penalty": 0.1, + "reasoning_effort": "medium", + "web_search": false, + "thinkingLevel": "basic" + } + """.trimIndent() + val decoded = json.decodeFromString(Conversation.serializer(), serverJson) + assertEquals("agent-1", decoded.agentId) + assertEquals("asst-1", decoded.assistantId) + assertEquals(0.95, decoded.topP) + assertEquals(0.2, decoded.frequencyPenalty) + assertEquals(0.1, decoded.presencePenalty) + assertEquals("medium", decoded.reasoningEffort) + assertEquals(false, decoded.webSearch) + assertEquals("basic", decoded.thinkingLevel) + } + + @Test + fun endpointEnumSerializesToJsonString() { + val original = Conversation(endpoint = EModelEndpoint.ANTHROPIC) + val encoded = json.encodeToString(Conversation.serializer(), original) + assertEquals(true, encoded.contains("\"anthropic\""), "Expected serialized name 'anthropic' in $encoded") + val decoded = json.decodeFromString(Conversation.serializer(), encoded) + assertEquals(EModelEndpoint.ANTHROPIC, decoded.endpoint) + } +} diff --git a/core/model/src/commonTest/kotlin/com/librechat/android/core/model/EndpointConfigSerializationTest.kt b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/EndpointConfigSerializationTest.kt new file mode 100644 index 0000000..b9dc294 --- /dev/null +++ b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/EndpointConfigSerializationTest.kt @@ -0,0 +1,56 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class EndpointConfigSerializationTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun minimalEndpointConfigRoundTrip() { + val original = EndpointConfig() + val encoded = json.encodeToString(EndpointConfig.serializer(), original) + val decoded = json.decodeFromString(EndpointConfig.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun fullyPopulatedEndpointConfigRoundTrip() { + val original = EndpointConfig( + type = "openAI", + order = 1, + iconURL = "https://example.com/icon.svg", + modelDisplayLabel = "GPT-4o", + name = "OpenAI", + userProvide = true, + userProvideURL = false, + capabilities = listOf("image_input", "tools", "reasoning"), + disableBuilder = false, + ) + val encoded = json.encodeToString(EndpointConfig.serializer(), original) + val decoded = json.decodeFromString(EndpointConfig.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun endpointConfigDeserializesFromServerJson() { + val serverJson = """ + { + "type": "anthropic", + "order": 2, + "modelDisplayLabel": "Claude", + "capabilities": ["image_input", "tools"], + "disableBuilder": true, + "someNewField": 42 + } + """.trimIndent() + val decoded = json.decodeFromString(EndpointConfig.serializer(), serverJson) + assertEquals("anthropic", decoded.type) + assertEquals(2, decoded.order) + assertEquals("Claude", decoded.modelDisplayLabel) + assertEquals(listOf("image_input", "tools"), decoded.capabilities) + assertEquals(true, decoded.disableBuilder) + } +} diff --git a/core/model/src/commonTest/kotlin/com/librechat/android/core/model/EnumSerializationTest.kt b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/EnumSerializationTest.kt new file mode 100644 index 0000000..2fdcb21 --- /dev/null +++ b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/EnumSerializationTest.kt @@ -0,0 +1,95 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class EnumSerializationTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun eModelEndpointRoundTrip() { + for (value in EModelEndpoint.entries) { + val encoded = json.encodeToString(EModelEndpoint.serializer(), value) + val decoded = json.decodeFromString(EModelEndpoint.serializer(), encoded) + assertEquals(value, decoded) + } + } + + @Test + fun eModelEndpointSerialNames() { + val pairs = mapOf( + EModelEndpoint.OPENAI to "\"openAI\"", + EModelEndpoint.AZURE_OPENAI to "\"azureOpenAI\"", + EModelEndpoint.GOOGLE to "\"google\"", + EModelEndpoint.ANTHROPIC to "\"anthropic\"", + EModelEndpoint.ASSISTANTS to "\"assistants\"", + EModelEndpoint.AZURE_ASSISTANTS to "\"azureAssistants\"", + EModelEndpoint.AGENTS to "\"agents\"", + EModelEndpoint.CUSTOM to "\"custom\"", + EModelEndpoint.BEDROCK to "\"bedrock\"", + ) + for ((value, expected) in pairs) { + val encoded = json.encodeToString(EModelEndpoint.serializer(), value) + assertEquals(expected, encoded, "Serialized name mismatch for $value") + } + } + + @Test + fun contentTypeRoundTrip() { + for (value in ContentType.entries) { + val encoded = json.encodeToString(ContentType.serializer(), value) + val decoded = json.decodeFromString(ContentType.serializer(), encoded) + assertEquals(value, decoded) + } + } + + @Test + fun feedbackRatingRoundTrip() { + for (value in FeedbackRating.entries) { + val encoded = json.encodeToString(FeedbackRating.serializer(), value) + val decoded = json.decodeFromString(FeedbackRating.serializer(), encoded) + assertEquals(value, decoded) + } + } + + @Test + fun feedbackRatingSerialNames() { + assertEquals( + "\"thumbsUp\"", + json.encodeToString(FeedbackRating.serializer(), FeedbackRating.THUMBS_UP), + ) + assertEquals( + "\"thumbsDown\"", + json.encodeToString(FeedbackRating.serializer(), FeedbackRating.THUMBS_DOWN), + ) + } + + @Test + fun toolCallTypeRoundTrip() { + for (value in ToolCallType.entries) { + val encoded = json.encodeToString(ToolCallType.serializer(), value) + val decoded = json.decodeFromString(ToolCallType.serializer(), encoded) + assertEquals(value, decoded) + } + } + + @Test + fun stepTypeRoundTrip() { + for (value in StepType.entries) { + val encoded = json.encodeToString(StepType.serializer(), value) + val decoded = json.decodeFromString(StepType.serializer(), encoded) + assertEquals(value, decoded) + } + } + + @Test + fun providerRoundTrip() { + for (value in Provider.entries) { + val encoded = json.encodeToString(Provider.serializer(), value) + val decoded = json.decodeFromString(Provider.serializer(), encoded) + assertEquals(value, decoded) + } + } +} diff --git a/core/model/src/commonTest/kotlin/com/librechat/android/core/model/MessageSerializationTest.kt b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/MessageSerializationTest.kt new file mode 100644 index 0000000..f45490b --- /dev/null +++ b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/MessageSerializationTest.kt @@ -0,0 +1,104 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals + +class MessageSerializationTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun minimalMessageRoundTrip() { + val original = Message( + messageId = "msg-001", + conversationId = "conv-001", + text = "Hello, world!", + ) + val encoded = json.encodeToString(Message.serializer(), original) + val decoded = json.decodeFromString(Message.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun fullyPopulatedMessageRoundTrip() { + val original = Message( + messageId = "msg-002", + conversationId = "conv-002", + parentMessageId = "msg-001", + responseMessageId = "msg-003", + overrideParentMessageId = "msg-000", + user = "user-123", + model = "gpt-4o", + endpoint = "openAI", + sender = "GPT-4o", + text = "Here is your answer.", + isCreatedByUser = false, + error = false, + unfinished = false, + finishReason = "stop", + tokenCount = 42, + iconURL = "https://example.com/icon.png", + content = listOf( + MessageContentPart(type = ContentType.TEXT, text = "Here is your answer."), + ), + files = listOf( + FileReference(fileId = "file-1", filename = "data.csv", type = "text/csv"), + ), + attachments = listOf( + Attachment(fileId = "att-1", filename = "image.png", type = "image/png"), + ), + feedback = Feedback( + rating = FeedbackRating.THUMBS_UP, + tag = JsonPrimitive("helpful"), + text = "Great answer!", + ), + threadId = "thread-abc", + metadata = JsonObject(mapOf("key" to JsonPrimitive("value"))), + createdAt = "2026-03-28T12:00:00Z", + updatedAt = "2026-03-28T12:01:00Z", + title = "Test message", + ) + val encoded = json.encodeToString(Message.serializer(), original) + val decoded = json.decodeFromString(Message.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun messageWithNullOptionalFieldsRoundTrip() { + val original = Message( + messageId = "msg-003", + conversationId = "conv-003", + parentMessageId = null, + model = null, + content = null, + files = null, + metadata = null, + ) + val encoded = json.encodeToString(Message.serializer(), original) + val decoded = json.decodeFromString(Message.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun messageDeserializesFromServerJson() { + val serverJson = """ + { + "messageId": "msg-srv", + "conversationId": "conv-srv", + "text": "Server response", + "isCreatedByUser": false, + "sender": "Assistant", + "finish_reason": "stop", + "thread_id": "t-1", + "unknownField": "should be ignored" + } + """.trimIndent() + val decoded = json.decodeFromString(Message.serializer(), serverJson) + assertEquals("msg-srv", decoded.messageId) + assertEquals("stop", decoded.finishReason) + assertEquals("t-1", decoded.threadId) + } +} diff --git a/core/model/src/commonTest/kotlin/com/librechat/android/core/model/SseContentEventSerializationTest.kt b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/SseContentEventSerializationTest.kt new file mode 100644 index 0000000..490a847 --- /dev/null +++ b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/SseContentEventSerializationTest.kt @@ -0,0 +1,169 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals + +class SseContentEventSerializationTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun contentDeltaEventRoundTrip() { + val original = SseContentEvent( + type = "content_delta", + messageId = "msg-001", + conversationId = "conv-001", + text = "Hello ", + ) + val encoded = json.encodeToString(SseContentEvent.serializer(), original) + val decoded = json.decodeFromString(SseContentEvent.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun finalEventWithNestedModelsRoundTrip() { + val msg = Message(messageId = "msg-f", conversationId = "conv-f", text = "Done") + val convo = Conversation(conversationId = "conv-f", title = "Final Chat") + val original = SseContentEvent( + type = "final", + final = true, + message = msg, + conversation = convo, + ) + val encoded = json.encodeToString(SseContentEvent.serializer(), original) + val decoded = json.decodeFromString(SseContentEvent.serializer(), encoded) + assertEquals(original, decoded) + assertEquals("msg-f", decoded.message?.messageId) + assertEquals("Final Chat", decoded.conversation?.title) + } + + @Test + fun toolCallEventRoundTrip() { + val original = SseContentEvent( + type = "tool_call_start", + toolCallId = "tc-001", + toolName = "web_search", + input = """{"query": "kotlin multiplatform"}""", + ) + val encoded = json.encodeToString(SseContentEvent.serializer(), original) + val decoded = json.decodeFromString(SseContentEvent.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun stepEventWithJsonElementRoundTrip() { + val stepData = buildJsonObject { + put("tool_calls", "active") + put("index", 0) + } + val original = SseContentEvent( + type = "step", + stepType = "tool_calls", + stepData = stepData, + ) + val encoded = json.encodeToString(SseContentEvent.serializer(), original) + val decoded = json.decodeFromString(SseContentEvent.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun errorEventRoundTrip() { + val original = SseContentEvent( + type = "error", + error = "Rate limit exceeded", + ) + val encoded = json.encodeToString(SseContentEvent.serializer(), original) + val decoded = json.decodeFromString(SseContentEvent.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun eventWithAttachmentsRoundTrip() { + val original = SseContentEvent( + type = "attachment_created", + fileId = "file-001", + filename = "output.png", + fileType = "image/png", + attachments = listOf( + Attachment( + fileId = "file-001", + filename = "output.png", + type = "image/png", + width = 800, + height = 600, + ), + ), + ) + val encoded = json.encodeToString(SseContentEvent.serializer(), original) + val decoded = json.decodeFromString(SseContentEvent.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun eventWithContentPartsRoundTrip() { + val original = SseContentEvent( + type = "content", + content = listOf( + MessageContentPart(type = ContentType.TEXT, text = "Some text"), + MessageContentPart( + type = ContentType.TOOL_CALL, + toolCall = AgentToolCall( + type = ToolCallType.FUNCTION, + name = "search", + id = "tc-1", + ), + ), + MessageContentPart(type = ContentType.THINK, think = "Let me think..."), + ), + ) + val encoded = json.encodeToString(SseContentEvent.serializer(), original) + val decoded = json.decodeFromString(SseContentEvent.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun eventWithExtraJsonFieldsRoundTrip() { + val original = SseContentEvent( + type = "sync", + sync = true, + extra = JsonObject(mapOf("customKey" to JsonPrimitive("customValue"))), + ) + val encoded = json.encodeToString(SseContentEvent.serializer(), original) + val decoded = json.decodeFromString(SseContentEvent.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun emptyEventRoundTrip() { + val original = SseContentEvent() + val encoded = json.encodeToString(SseContentEvent.serializer(), original) + val decoded = json.decodeFromString(SseContentEvent.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun deserializesServerSsePayload() { + val serverJson = """ + { + "type": "content_delta", + "conversationId": "c-1", + "messageId": "m-1", + "parentMessageId": "m-0", + "responseMessageId": "m-1", + "text": "Hi there!", + "created": true, + "futureField": "should-be-ignored" + } + """.trimIndent() + val decoded = json.decodeFromString(SseContentEvent.serializer(), serverJson) + assertEquals("content_delta", decoded.type) + assertEquals("c-1", decoded.conversationId) + assertEquals("Hi there!", decoded.text) + assertEquals(true, decoded.created) + } +} diff --git a/core/model/src/commonTest/kotlin/com/librechat/android/core/model/UserSerializationTest.kt b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/UserSerializationTest.kt new file mode 100644 index 0000000..49f83d8 --- /dev/null +++ b/core/model/src/commonTest/kotlin/com/librechat/android/core/model/UserSerializationTest.kt @@ -0,0 +1,94 @@ +package com.librechat.android.core.model + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class UserSerializationTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun minimalUserRoundTrip() { + val original = User(email = "test@example.com") + val encoded = json.encodeToString(User.serializer(), original) + val decoded = json.decodeFromString(User.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun fullyPopulatedUserRoundTrip() { + val original = User( + id = "user-001", + mongoId = "65abc456", + name = "Test User", + username = "testuser", + email = "test@example.com", + emailVerified = true, + avatar = "https://example.com/avatar.jpg", + provider = "google", + role = "ADMIN", + twoFactorEnabled = true, + termsAccepted = true, + favorites = listOf( + UserFavorite(agentId = "agent-1", model = "gpt-4o", endpoint = "openAI"), + UserFavorite(model = "claude-3-opus"), + ), + createdAt = "2026-01-01T00:00:00Z", + updatedAt = "2026-03-28T12:00:00Z", + ) + val encoded = json.encodeToString(User.serializer(), original) + val decoded = json.decodeFromString(User.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun userDeserializesFromServerJson() { + val serverJson = """ + { + "id": "user-srv", + "_id": "mongo-srv", + "name": "Server User", + "email": "srv@example.com", + "emailVerified": false, + "provider": "local", + "role": "USER", + "twoFactorEnabled": false, + "termsAccepted": true, + "favorites": [ + {"agentId": "a1", "endpoint": "agents"} + ], + "extraField": "ignored" + } + """.trimIndent() + val decoded = json.decodeFromString(User.serializer(), serverJson) + assertEquals("user-srv", decoded.id) + assertEquals("mongo-srv", decoded.mongoId) + assertEquals("Server User", decoded.name) + assertEquals(1, decoded.favorites.size) + assertEquals("a1", decoded.favorites[0].agentId) + } + + @Test + fun userFavoriteRoundTrip() { + val original = UserFavorite( + agentId = "agent-x", + model = "gpt-4o-mini", + endpoint = "openAI", + ) + val encoded = json.encodeToString(UserFavorite.serializer(), original) + val decoded = json.decodeFromString(UserFavorite.serializer(), encoded) + assertEquals(original, decoded) + } + + @Test + fun userDefaultValuesPreserved() { + val user = User(email = "defaults@example.com") + assertEquals(false, user.emailVerified) + assertEquals("local", user.provider) + assertEquals("USER", user.role) + assertEquals(false, user.twoFactorEnabled) + assertEquals(false, user.termsAccepted) + assertEquals(emptyList(), user.favorites) + } +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 55f5a0c..d70a99a 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - id("librechat.android.library") - id("librechat.android.koin") + id("librechat.kmp.library") + id("librechat.kmp.koin") id("librechat.kotlin.serialization") } @@ -8,10 +8,27 @@ android { namespace = "com.librechat.android.core.network" } -dependencies { - implementation(project(":core:model")) - implementation(project(":core:common")) - implementation(libs.bundles.ktor) - implementation(libs.kotlinx.serialization.json) - implementation(libs.timber) +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.logging) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kermit) + } + androidMain.dependencies { + implementation(libs.ktor.client.okhttp) + } + iosMain.dependencies { + implementation(libs.ktor.client.darwin) + } + named("androidUnitTest").dependencies { + implementation(libs.koin.test) + implementation(libs.ktor.client.mock) + } + } } diff --git a/core/network/src/androidMain/kotlin/com/librechat/android/core/network/di/NetworkPlatformModule.android.kt b/core/network/src/androidMain/kotlin/com/librechat/android/core/network/di/NetworkPlatformModule.android.kt new file mode 100644 index 0000000..9e64361 --- /dev/null +++ b/core/network/src/androidMain/kotlin/com/librechat/android/core/network/di/NetworkPlatformModule.android.kt @@ -0,0 +1,10 @@ +package com.librechat.android.core.network.di + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.okhttp.OkHttp +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val networkPlatformModule: Module = module { + single> { OkHttp } +} diff --git a/core/network/src/androidUnitTest/kotlin/com/librechat/android/core/network/client/AuthInterceptorTest.kt b/core/network/src/androidUnitTest/kotlin/com/librechat/android/core/network/client/AuthInterceptorTest.kt new file mode 100644 index 0000000..3ced842 --- /dev/null +++ b/core/network/src/androidUnitTest/kotlin/com/librechat/android/core/network/client/AuthInterceptorTest.kt @@ -0,0 +1,206 @@ +package com.librechat.android.core.network.client + +import com.google.common.truth.Truth.assertThat +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AuthInterceptorTest { + + private class FakeTokenManager( + private var accessToken: String? = "initial-token", + private var refreshSucceeds: Boolean = true, + private var refreshedToken: String = "refreshed-token", + ) : TokenManager { + var refreshCallCount = 0 + var sessionExpiredCount = 0 + private val _sessionExpiredFlow = MutableSharedFlow() + override val sessionExpiredFlow: SharedFlow = _sessionExpiredFlow + override val isAuthenticated: Boolean get() = accessToken != null + + override suspend fun getAccessToken(): String? = accessToken + override suspend fun setTokens(accessToken: String, refreshToken: String) { + this.accessToken = accessToken + } + + override suspend fun refreshAccessToken(): Boolean { + refreshCallCount++ + return if (refreshSucceeds) { + accessToken = refreshedToken + true + } else { + false + } + } + + override suspend fun clearTokens() { + accessToken = null + } + + override fun emitSessionExpired() { + sessionExpiredCount++ + } + } + + private fun createClient( + tokenManager: FakeTokenManager, + engine: MockEngine, + ): HttpClient = HttpClient(engine) { + install(ContentNegotiation) { json() } + install(AuthInterceptorPlugin) { + this.tokenManager = tokenManager + } + } + + @Test + fun `attaches Bearer token to requests`() = runTest { + val tokenManager = FakeTokenManager(accessToken = "my-token") + var capturedAuth: String? = null + + val engine = MockEngine { request -> + capturedAuth = request.headers[HttpHeaders.Authorization] + respond("OK", HttpStatusCode.OK) + } + val client = createClient(tokenManager, engine) + + client.get("https://example.com/api/conversations") + assertThat(capturedAuth).isEqualTo("Bearer my-token") + } + + @Test + fun `skips auth for login endpoint`() = runTest { + val tokenManager = FakeTokenManager(accessToken = "my-token") + var capturedAuth: String? = null + + val engine = MockEngine { request -> + capturedAuth = request.headers[HttpHeaders.Authorization] + respond("OK", HttpStatusCode.OK) + } + val client = createClient(tokenManager, engine) + + client.get("https://example.com/auth/login") + assertThat(capturedAuth).isNull() + } + + @Test + fun `skips auth for register endpoint`() = runTest { + val tokenManager = FakeTokenManager(accessToken = "my-token") + var capturedAuth: String? = null + + val engine = MockEngine { request -> + capturedAuth = request.headers[HttpHeaders.Authorization] + respond("OK", HttpStatusCode.OK) + } + val client = createClient(tokenManager, engine) + + client.get("https://example.com/auth/register") + assertThat(capturedAuth).isNull() + } + + @Test + fun `refreshes token on 401 and retries`() = runTest { + val tokenManager = FakeTokenManager( + accessToken = "expired-token", + refreshSucceeds = true, + refreshedToken = "new-token", + ) + var requestCount = 0 + val capturedTokens = mutableListOf() + + val engine = MockEngine { request -> + requestCount++ + capturedTokens.add(request.headers[HttpHeaders.Authorization]) + if (requestCount == 1) { + respond("Unauthorized", HttpStatusCode.Unauthorized) + } else { + respond("OK", HttpStatusCode.OK) + } + } + val client = createClient(tokenManager, engine) + + val response = client.get("https://example.com/api/data") + assertThat(response.status).isEqualTo(HttpStatusCode.OK) + assertThat(requestCount).isEqualTo(2) + assertThat(tokenManager.refreshCallCount).isEqualTo(1) + assertThat(capturedTokens[0]).isEqualTo("Bearer expired-token") + assertThat(capturedTokens[1]).isEqualTo("Bearer new-token") + } + + @Test + fun `emits session expired when refresh fails`() = runTest { + val tokenManager = FakeTokenManager( + accessToken = "expired-token", + refreshSucceeds = false, + ) + + val engine = MockEngine { + respond("Unauthorized", HttpStatusCode.Unauthorized) + } + val client = createClient(tokenManager, engine) + + val response = client.get("https://example.com/api/data") + assertThat(response.status).isEqualTo(HttpStatusCode.Unauthorized) + assertThat(tokenManager.refreshCallCount).isEqualTo(1) + assertThat(tokenManager.sessionExpiredCount).isEqualTo(1) + } + + @Test + fun `returns 401 when refreshed token is also expired`() = runTest { + val tokenManager = FakeTokenManager( + accessToken = "expired-token", + refreshSucceeds = true, + refreshedToken = "still-expired", + ) + + val engine = MockEngine { + respond("Unauthorized", HttpStatusCode.Unauthorized) + } + val client = createClient(tokenManager, engine) + + val response = client.get("https://example.com/api/data") + assertThat(response.status).isEqualTo(HttpStatusCode.Unauthorized) + // Refresh was attempted once, then the retry 401 is returned as-is + // (execute() inside interceptor doesn't re-enter the same interceptor) + assertThat(tokenManager.refreshCallCount).isEqualTo(1) + } + + @Test + fun `does not refresh on non-401 errors`() = runTest { + val tokenManager = FakeTokenManager() + + val engine = MockEngine { + respond("Server Error", HttpStatusCode.InternalServerError) + } + val client = createClient(tokenManager, engine) + + val response = client.get("https://example.com/api/data") + assertThat(response.status).isEqualTo(HttpStatusCode.InternalServerError) + assertThat(tokenManager.refreshCallCount).isEqualTo(0) + } + + @Test + fun `sends request without token when no token available`() = runTest { + val tokenManager = FakeTokenManager(accessToken = null) + var capturedAuth: String? = "not-null" + + val engine = MockEngine { request -> + capturedAuth = request.headers[HttpHeaders.Authorization] + respond("OK", HttpStatusCode.OK) + } + val client = createClient(tokenManager, engine) + + client.get("https://example.com/api/data") + assertThat(capturedAuth).isNull() + } +} diff --git a/core/network/src/test/kotlin/com/librechat/android/core/network/di/NetworkModuleVerificationTest.kt b/core/network/src/androidUnitTest/kotlin/com/librechat/android/core/network/di/NetworkModuleVerificationTest.kt similarity index 89% rename from core/network/src/test/kotlin/com/librechat/android/core/network/di/NetworkModuleVerificationTest.kt rename to core/network/src/androidUnitTest/kotlin/com/librechat/android/core/network/di/NetworkModuleVerificationTest.kt index 8b6d19b..c8e36c0 100644 --- a/core/network/src/test/kotlin/com/librechat/android/core/network/di/NetworkModuleVerificationTest.kt +++ b/core/network/src/androidUnitTest/kotlin/com/librechat/android/core/network/di/NetworkModuleVerificationTest.kt @@ -11,6 +11,7 @@ class NetworkModuleVerificationTest { android.content.Context::class, android.app.Application::class, io.ktor.client.engine.HttpClientEngine::class, + io.ktor.client.engine.HttpClientEngineFactory::class, com.librechat.android.core.network.client.TokenManager::class, com.librechat.android.core.network.client.ServerUrlProvider::class, ), diff --git a/core/network/src/androidUnitTest/kotlin/com/librechat/android/core/network/sse/SseEventMapperTest.kt b/core/network/src/androidUnitTest/kotlin/com/librechat/android/core/network/sse/SseEventMapperTest.kt new file mode 100644 index 0000000..cb0e3f6 --- /dev/null +++ b/core/network/src/androidUnitTest/kotlin/com/librechat/android/core/network/sse/SseEventMapperTest.kt @@ -0,0 +1,336 @@ +package com.librechat.android.core.network.sse + +import com.librechat.android.core.model.StreamEvent +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.Json +import org.junit.Before +import org.junit.Test + +class SseEventMapperTest { + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + } + private lateinit var mapper: SseEventMapper + + @Before + fun setUp() { + mapper = SseEventMapper(json) + } + + // --- Content Delta (LangGraph) --- + + @Test + fun `maps on_message_delta to ContentDelta`() { + val event = SseEvent( + event = "", + data = """{"event":"on_message_delta","data":{"id":"step_1","delta":{"content":[{"type":"text","text":"Hello "}]}}}""", + ) + val result = mapper.map(event) + assertThat(result).isInstanceOf(StreamEvent.ContentDelta::class.java) + val delta = result as StreamEvent.ContentDelta + assertThat(delta.chunk).isEqualTo("Hello ") + assertThat(delta.messageId).isEqualTo("step_1") + } + + @Test + fun `maps on_message_delta with multiple content parts`() { + val event = SseEvent( + event = "", + data = """{"event":"on_message_delta","data":{"id":"step_1","delta":{"content":[{"type":"text","text":"Hello "},{"type":"text","text":"world"}]}}}""", + ) + val result = mapper.map(event) as StreamEvent.ContentDelta + assertThat(result.chunk).isEqualTo("Hello world") + } + + @Test + fun `returns null for empty content delta`() { + val event = SseEvent( + event = "", + data = """{"event":"on_message_delta","data":{"id":"step_1","delta":{"content":[{"type":"image_url","url":"http://x.com"}]}}}""", + ) + val result = mapper.map(event) + assertThat(result).isNull() + } + + // --- Thinking Delta --- + + @Test + fun `maps on_reasoning_delta to ThinkingDelta`() { + val event = SseEvent( + event = "", + data = """{"event":"on_reasoning_delta","data":{"id":"step_1","delta":{"content":[{"type":"think","think":"Let me consider..."}]}}}""", + ) + val result = mapper.map(event) + assertThat(result).isInstanceOf(StreamEvent.ThinkingDelta::class.java) + assertThat((result as StreamEvent.ThinkingDelta).chunk).isEqualTo("Let me consider...") + } + + // --- Tool Calls --- + + @Test + fun `maps on_run_step to ToolCallStart`() { + val event = SseEvent( + event = "", + data = """{"event":"on_run_step","data":{"id":"step_1","stepDetails":{"type":"tool_calls","tool_calls":[{"id":"call_1","name":"web_search","args":"{\"query\":\"kotlin\"}"}]},"agentId":"agent_1","groupId":1}}""", + ) + val result = mapper.map(event) + assertThat(result).isInstanceOf(StreamEvent.ToolCallStart::class.java) + val tc = result as StreamEvent.ToolCallStart + assertThat(tc.toolCallId).isEqualTo("call_1") + assertThat(tc.toolName).isEqualTo("web_search") + assertThat(tc.agentId).isEqualTo("agent_1") + assertThat(tc.groupId).isEqualTo(1) + } + + @Test + fun `maps on_run_step_completed to ToolCallComplete`() { + val event = SseEvent( + event = "", + data = """{"event":"on_run_step_completed","data":{"result":{"id":"step_1","tool_call":{"id":"call_1","name":"search","output":"Results here"}},"agentId":"agent_1","groupId":1}}""", + ) + val result = mapper.map(event) + assertThat(result).isInstanceOf(StreamEvent.ToolCallComplete::class.java) + val tc = result as StreamEvent.ToolCallComplete + assertThat(tc.toolCallId).isEqualTo("call_1") + assertThat(tc.output).isEqualTo("Results here") + } + + @Test + fun `tool call output handles JSON object values`() { + val event = SseEvent( + event = "", + data = """{"event":"on_run_step_completed","data":{"result":{"id":"s","tool_call":{"id":"c1","output":{"url":"http://img.com/x.png"}}}}}""", + ) + val result = mapper.map(event) as StreamEvent.ToolCallComplete + assertThat(result.output).contains("http://img.com/x.png") + } + + // --- Agent Context Tracking --- + + @Test + fun `tracks agentId from on_run_step and applies to subsequent content events`() { + // Step 1: on_run_step sets agent context + mapper.map( + SseEvent( + event = "", + data = """{"event":"on_run_step","data":{"id":"s1","stepDetails":{"type":"tool_calls","tool_calls":[{"id":"c1","name":"search","args":""}]},"agentId":"agent_A","groupId":2}}""", + ), + ) + + // Step 2: on_message_delta doesn't carry agentId — should inherit from step + val delta = mapper.map( + SseEvent( + event = "", + data = """{"event":"on_message_delta","data":{"id":"s2","delta":{"content":[{"type":"text","text":"Hi"}]}}}""", + ), + ) + + assertThat(delta).isInstanceOf(StreamEvent.ContentDelta::class.java) + assertThat((delta as StreamEvent.ContentDelta).agentId).isEqualTo("agent_A") + assertThat(delta.groupId).isEqualTo(2) + } + + @Test + fun `resetState clears tracked agent context`() { + mapper.map( + SseEvent( + event = "", + data = """{"event":"on_run_step","data":{"id":"s1","stepDetails":{"type":"tool_calls","tool_calls":[{"id":"c1","name":"t","args":""}]},"agentId":"agent_A","groupId":1}}""", + ), + ) + + mapper.resetState() + + val delta = mapper.map( + SseEvent( + event = "", + data = """{"event":"on_message_delta","data":{"id":"s2","delta":{"content":[{"type":"text","text":"Hi"}]}}}""", + ), + ) as StreamEvent.ContentDelta + + assertThat(delta.agentId).isNull() + assertThat(delta.groupId).isNull() + } + + // --- Control Events --- + + @Test + fun `maps final event with full payload`() { + val event = SseEvent( + event = "", + data = """{"final":true,"conversation":{"conversationId":"c1","title":"Chat"},"requestMessage":{"messageId":"m1","conversationId":"c1","text":"Hello","isCreatedByUser":true},"responseMessage":{"messageId":"m2","conversationId":"c1","text":"Hi there","isCreatedByUser":false}}""", + ) + val result = mapper.map(event) + assertThat(result).isInstanceOf(StreamEvent.Final::class.java) + val final = result as StreamEvent.Final + assertThat(final.conversation?.conversationId).isEqualTo("c1") + assertThat(final.requestMessage?.text).isEqualTo("Hello") + assertThat(final.responseMessage?.text).isEqualTo("Hi there") + assertThat(final.hasParseErrors).isFalse() + } + + @Test + fun `maps final event with legacy message field`() { + val event = SseEvent( + event = "", + data = """{"final":true,"message":{"messageId":"m1","conversationId":"c1","text":"Done"}}""", + ) + val final = mapper.map(event) as StreamEvent.Final + assertThat(final.message?.text).isEqualTo("Done") + } + + @Test + fun `maps created event with nested message object`() { + val event = SseEvent( + event = "", + data = """{"created":{"message":{"conversationId":"c1","messageId":"m1","parentMessageId":"m0"}}}""", + ) + val result = mapper.map(event) as StreamEvent.Created + assertThat(result.conversationId).isEqualTo("c1") + assertThat(result.messageId).isEqualTo("m1") + assertThat(result.parentMessageId).isEqualTo("m0") + } + + @Test + fun `maps created event with boolean flag format`() { + val event = SseEvent( + event = "", + data = """{"created":true,"message":{"conversationId":"c1","messageId":"m1","parentMessageId":"m0"}}""", + ) + val result = mapper.map(event) as StreamEvent.Created + assertThat(result.conversationId).isEqualTo("c1") + } + + @Test + fun `maps error event`() { + val event = SseEvent( + event = "", + data = """{"error":"Rate limit exceeded"}""", + ) + val result = mapper.map(event) as StreamEvent.Error + assertThat(result.message).isEqualTo("Rate limit exceeded") + } + + @Test + fun `maps sync event with aggregatedContent`() { + val event = SseEvent( + event = "", + data = """{"sync":true,"resumeState":{"aggregatedContent":[{"type":"text","text":"Previously streamed text"}]}}""", + ) + val result = mapper.map(event) as StreamEvent.Sync + assertThat(result.aggregatedContent).hasSize(1) + assertThat(result.aggregatedContent[0].text).isEqualTo("Previously streamed text") + } + + // --- Attachment Events --- + + @Test + fun `maps SSE-level attachment event`() { + val event = SseEvent( + event = "attachment", + data = """{"file_id":"f1","filename":"image.png","type":"image/png","filepath":"/files/image.png","width":800,"height":600}""", + ) + val result = mapper.map(event) as StreamEvent.AttachmentCreated + assertThat(result.fileId).isEqualTo("f1") + assertThat(result.filename).isEqualTo("image.png") + assertThat(result.width).isEqualTo(800) + assertThat(result.height).isEqualTo(600) + } + + @Test + fun `maps librechat attachment event variant`() { + val event = SseEvent( + event = "librechat:attachment", + data = """{"file_id":"f2","filename":"doc.pdf","type":"application/pdf"}""", + ) + val result = mapper.map(event) as StreamEvent.AttachmentCreated + assertThat(result.fileId).isEqualTo("f2") + } + + // --- Legacy Flat Format --- + + @Test + fun `maps legacy content event`() { + val event = SseEvent( + event = "", + data = """{"type":"content","text":"Legacy text","messageId":"m1"}""", + ) + val result = mapper.map(event) as StreamEvent.ContentDelta + assertThat(result.chunk).isEqualTo("Legacy text") + assertThat(result.messageId).isEqualTo("m1") + } + + @Test + fun `maps legacy thinking event`() { + val event = SseEvent( + event = "", + data = """{"type":"thinking","text":"Hmm...","metadata":{"agentId":"a1","groupId":1}}""", + ) + val result = mapper.map(event) as StreamEvent.ThinkingDelta + assertThat(result.chunk).isEqualTo("Hmm...") + assertThat(result.agentId).isEqualTo("a1") + } + + @Test + fun `maps legacy tool_call_start event`() { + val event = SseEvent( + event = "", + data = """{"type":"tool_call_start","toolCallId":"tc1","toolName":"calculator","input":"2+2"}""", + ) + val result = mapper.map(event) as StreamEvent.ToolCallStart + assertThat(result.toolCallId).isEqualTo("tc1") + assertThat(result.toolName).isEqualTo("calculator") + assertThat(result.input).isEqualTo("2+2") + } + + @Test + fun `maps legacy text-only fallback event`() { + val event = SseEvent( + event = "", + data = """{"text":"Fallback content"}""", + ) + val result = mapper.map(event) as StreamEvent.ContentDelta + assertThat(result.chunk).isEqualTo("Fallback content") + } + + // --- Edge Cases --- + + @Test + fun `returns null for blank data`() { + assertThat(mapper.map(SseEvent(event = "", data = ""))).isNull() + assertThat(mapper.map(SseEvent(event = "", data = " "))).isNull() + } + + @Test + fun `returns null for DONE marker`() { + assertThat(mapper.map(SseEvent(event = "", data = "[DONE]"))).isNull() + } + + @Test + fun `returns Error for unparseable JSON`() { + val result = mapper.map(SseEvent(event = "", data = "not json at all")) + assertThat(result).isInstanceOf(StreamEvent.Error::class.java) + assertThat((result as StreamEvent.Error).message).contains("Parse error") + } + + @Test + fun `ignores on_chat_model_end event`() { + val event = SseEvent( + event = "", + data = """{"event":"on_chat_model_end","data":{"id":"step_1"}}""", + ) + assertThat(mapper.map(event)).isNull() + } + + @Test + fun `ignores on_run_step_delta event`() { + val event = SseEvent( + event = "", + data = """{"event":"on_run_step_delta","data":{"id":"step_1","delta":{"args":"partial"}}}""", + ) + assertThat(mapper.map(event)).isNull() + } +} diff --git a/core/network/src/androidUnitTest/kotlin/com/librechat/android/core/network/sse/SseLineParserTest.kt b/core/network/src/androidUnitTest/kotlin/com/librechat/android/core/network/sse/SseLineParserTest.kt new file mode 100644 index 0000000..80719cb --- /dev/null +++ b/core/network/src/androidUnitTest/kotlin/com/librechat/android/core/network/sse/SseLineParserTest.kt @@ -0,0 +1,136 @@ +package com.librechat.android.core.network.sse + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.ktor.utils.io.ByteReadChannel +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SseLineParserTest { + + private val parser = SseLineParser(lineReadTimeoutMs = 5_000L) + + private fun channel(text: String): ByteReadChannel = + ByteReadChannel(text.encodeToByteArray()) + + @Test + fun `parses single event`() = runTest { + val input = "data: {\"text\":\"hello\"}\n\n" + parser.parse(channel(input)).test { + val event = awaitItem() + assertThat(event.event).isEmpty() + assertThat(event.data).isEqualTo("{\"text\":\"hello\"}") + awaitComplete() + } + } + + @Test + fun `parses event with type`() = runTest { + val input = "event: message\ndata: {\"text\":\"hi\"}\n\n" + parser.parse(channel(input)).test { + val event = awaitItem() + assertThat(event.event).isEqualTo("message") + assertThat(event.data).isEqualTo("{\"text\":\"hi\"}") + awaitComplete() + } + } + + @Test + fun `parses multiple events`() = runTest { + val input = "data: {\"n\":1}\n\ndata: {\"n\":2}\n\n" + parser.parse(channel(input)).test { + assertThat(awaitItem().data).isEqualTo("{\"n\":1}") + assertThat(awaitItem().data).isEqualTo("{\"n\":2}") + awaitComplete() + } + } + + @Test + fun `handles multiline data`() = runTest { + val input = "data: line1\ndata: line2\ndata: line3\n\n" + parser.parse(channel(input)).test { + val event = awaitItem() + assertThat(event.data).isEqualTo("line1\nline2\nline3") + awaitComplete() + } + } + + @Test + fun `ignores comment lines`() = runTest { + val input = ": keepalive\ndata: {\"ok\":true}\n\n" + parser.parse(channel(input)).test { + val event = awaitItem() + assertThat(event.data).isEqualTo("{\"ok\":true}") + awaitComplete() + } + } + + @Test + fun `stops on DONE marker`() = runTest { + val input = "data: {\"n\":1}\n\ndata: [DONE]\n\n" + parser.parse(channel(input)).test { + assertThat(awaitItem().data).isEqualTo("{\"n\":1}") + awaitComplete() + } + } + + @Test + fun `emits remaining buffered data on channel close`() = runTest { + // No trailing \n\n - channel closes with buffered data + val input = "data: {\"partial\":true}" + parser.parse(channel(input)).test { + val event = awaitItem() + assertThat(event.data).isEqualTo("{\"partial\":true}") + awaitComplete() + } + } + + @Test + fun `ignores empty events between non-empty events`() = runTest { + val input = "data: first\n\n\n\ndata: second\n\n" + parser.parse(channel(input)).test { + assertThat(awaitItem().data).isEqualTo("first") + assertThat(awaitItem().data).isEqualTo("second") + awaitComplete() + } + } + + @Test + fun `resets event type between events`() = runTest { + val input = "event: type1\ndata: first\n\ndata: second\n\n" + parser.parse(channel(input)).test { + val first = awaitItem() + assertThat(first.event).isEqualTo("type1") + val second = awaitItem() + assertThat(second.event).isEmpty() + awaitComplete() + } + } + + @Test + fun `handles attachment event type`() = runTest { + val input = "event: attachment\ndata: {\"file_id\":\"f1\"}\n\n" + parser.parse(channel(input)).test { + val event = awaitItem() + assertThat(event.event).isEqualTo("attachment") + assertThat(event.data).isEqualTo("{\"file_id\":\"f1\"}") + awaitComplete() + } + } + + @Test + fun `handles empty channel`() = runTest { + parser.parse(channel("")).test { + awaitComplete() + } + } + + @Test + fun `does not emit DONE as data`() = runTest { + // DONE as sole remaining buffer should not be emitted + val input = "data: [DONE]" + parser.parse(channel(input)).test { + awaitComplete() + } + } +} diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/AgentsApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/AgentsApi.kt similarity index 96% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/AgentsApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/AgentsApi.kt index 29f0e18..bccb187 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/api/AgentsApi.kt +++ b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/AgentsApi.kt @@ -28,7 +28,7 @@ import io.ktor.http.Headers import io.ktor.http.HttpHeaders import io.ktor.http.path import kotlinx.serialization.json.JsonArray -import timber.log.Timber +import co.touchlab.kermit.Logger class AgentsApi constructor( private val client: HttpClient, @@ -57,7 +57,7 @@ class AgentsApi constructor( }.body() suspend fun getAgent(agentId: String): Agent { - Timber.d("AgentsApi.getAgent(%s) - using VIEW endpoint", agentId) + Logger.d("AgentsApi") { "getAgent($agentId) - using VIEW endpoint" } return client.get { url { path("api/agents/$agentId") } }.body() @@ -76,7 +76,7 @@ class AgentsApi constructor( * and returns the complete agent document with all fields. */ suspend fun getAgentForEditing(agentId: String): Agent { - Timber.d("AgentsApi.getAgentForEditing(%s) - using EXPANDED endpoint", agentId) + Logger.d("AgentsApi") { "getAgentForEditing($agentId) - using EXPANDED endpoint" } return client.get { url { path("api/agents/$agentId/expanded") } }.body() diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/ApiKeysApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/ApiKeysApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/ApiKeysApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/ApiKeysApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/AuthApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/AuthApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/AuthApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/AuthApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/BalanceApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/BalanceApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/BalanceApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/BalanceApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/BannerApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/BannerApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/BannerApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/BannerApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/ChatApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/ChatApi.kt similarity index 98% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/ChatApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/ChatApi.kt index 060bf62..558ca22 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/api/ChatApi.kt +++ b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/ChatApi.kt @@ -16,6 +16,7 @@ import io.ktor.http.path class ChatApi constructor( private val client: HttpClient, ) { + @Throws(Exception::class) suspend fun startChat(endpoint: String, request: ChatRequest): ChatStartResponse = client.post { url { path("api/agents/chat/$endpoint") } diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/ConfigApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/ConfigApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/ConfigApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/ConfigApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/ConversationsApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/ConversationsApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/ConversationsApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/ConversationsApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/FilesApi.kt similarity index 66% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/FilesApi.kt index f6c8a33..f431f61 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesApi.kt +++ b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/FilesApi.kt @@ -1,5 +1,6 @@ package com.librechat.android.core.network.api +import co.touchlab.kermit.Logger import com.librechat.android.core.model.FileObject import com.librechat.android.core.model.request.DeleteFilesRequest import com.librechat.android.core.model.response.FileUploadConfig @@ -13,8 +14,8 @@ import io.ktor.client.request.setBody import io.ktor.http.Headers import io.ktor.http.HttpHeaders import io.ktor.http.path -import timber.log.Timber -import java.util.UUID +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid class FilesApi constructor( private val client: HttpClient, @@ -29,22 +30,12 @@ class FilesApi constructor( url { path("api/files/config") } }.body() - /** - * Uploads a file to the server via multipart form data. - * - * The backend requires: - * - `file` part: the binary file data with filename and content type - * - `file_id`: a UUID identifying this upload (server replaces with its own, keeps ours as temp_file_id) - * - `endpoint`: the endpoint context (e.g. "agents", "openAI") -- required by filterFile() - * - * Optional fields: - * - `model`, `agent_id`, `tool_resource`, `message_file`, `width`, `height` - */ + @OptIn(ExperimentalUuidApi::class) suspend fun uploadFile( bytes: ByteArray, filename: String, type: String, - fileId: String = UUID.randomUUID().toString(), + fileId: String = Uuid.random().toString(), endpoint: String? = null, model: String? = null, agentId: String? = null, @@ -53,11 +44,10 @@ class FilesApi constructor( width: Int? = null, height: Int? = null, ): FileObject { - Timber.d( - "uploadFile: filename=%s, type=%s, size=%d bytes, fileId=%s, " + - "endpoint=%s, model=%s, agentId=%s, messageFile=%s, width=%s, height=%s", - filename, type, bytes.size, fileId, endpoint, model, agentId, messageFile, width, height, - ) + Logger.d("FilesApi") { + "uploadFile: filename=$filename, type=$type, size=${bytes.size} bytes, fileId=$fileId, " + + "endpoint=$endpoint, model=$model, agentId=$agentId, messageFile=$messageFile, width=$width, height=$height" + } val response: FileObject = client.submitFormWithBinaryData( formData = formData { @@ -65,7 +55,6 @@ class FilesApi constructor( append(HttpHeaders.ContentDisposition, "filename=\"${encodeFilename(filename)}\"") append(HttpHeaders.ContentType, type) }) - // file_id is required by the backend's filterFile() -- must be a valid UUID append("file_id", fileId) if (endpoint != null) append("endpoint", endpoint) if (model != null) append("model", model) @@ -79,10 +68,9 @@ class FilesApi constructor( url { path("api/files") } }.body() - Timber.d( - "uploadFile success: fileId=%s, filepath=%s, type=%s, width=%s, height=%s", - response.fileId, response.filepath, response.type, response.width, response.height, - ) + Logger.d("FilesApi") { + "uploadFile success: fileId=${response.fileId}, filepath=${response.filepath}, type=${response.type}, width=${response.width}, height=${response.height}" + } return response } @@ -99,10 +87,6 @@ class FilesApi constructor( }.body() companion object { - /** - * Encodes a filename for use in Content-Disposition header. - * Ensures special characters don't break the multipart form boundary. - */ private fun encodeFilename(filename: String): String = filename.replace("\"", "\\\"") } diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesExtApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/FilesExtApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/FilesExtApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/FilesExtApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/KeysApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/KeysApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/KeysApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/KeysApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/McpApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/McpApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/McpApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/McpApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/MemoriesApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/MemoriesApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/MemoriesApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/MemoriesApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/MessagesApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/MessagesApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/MessagesApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/MessagesApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/PresetsApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/PresetsApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/PresetsApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/PresetsApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/PromptsApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/PromptsApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/PromptsApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/PromptsApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/SearchApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/SearchApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/SearchApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/SearchApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/ShareApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/ShareApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/ShareApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/ShareApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/SpeechApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/SpeechApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/SpeechApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/SpeechApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/TagsApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/TagsApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/TagsApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/TagsApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/api/UserApi.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/UserApi.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/api/UserApi.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/api/UserApi.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/ApiException.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/ApiException.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/client/ApiException.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/ApiException.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/AuthInterceptorPlugin.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/AuthInterceptorPlugin.kt similarity index 85% rename from core/network/src/main/kotlin/com/librechat/android/core/network/client/AuthInterceptorPlugin.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/AuthInterceptorPlugin.kt index 76a02db..61e43ec 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/client/AuthInterceptorPlugin.kt +++ b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/AuthInterceptorPlugin.kt @@ -1,5 +1,6 @@ package com.librechat.android.core.network.client +import co.touchlab.kermit.Logger import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpClientPlugin import io.ktor.client.plugins.HttpSend @@ -9,7 +10,6 @@ import io.ktor.client.request.headers import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.util.AttributeKey -import timber.log.Timber class AuthInterceptorPlugin private constructor( private val tokenManager: TokenManager, @@ -45,8 +45,6 @@ class AuthInterceptorPlugin private constructor( } // Intercept 401 responses at the HttpSend level for proper retry. - // HttpSend.intercept operates before response deserialization, so retrying - // here re-runs the full pipeline including content negotiation. scope.plugin(HttpSend).intercept { request -> val originalCall = execute(request) @@ -54,23 +52,21 @@ class AuthInterceptorPlugin private constructor( return@intercept originalCall } - // Check if we already retried this request val alreadyRetried = request.attributes.getOrNull(RetryFlag) == true if (alreadyRetried) { - Timber.w("401 after retry - session expired") + Logger.w("Auth") { "401 after retry - session expired" } plugin.tokenManager.emitSessionExpired() return@intercept originalCall } - Timber.d("401 received, attempting token refresh") + Logger.d("Auth") { "401 received, attempting token refresh" } val refreshed = plugin.tokenManager.refreshAccessToken() if (!refreshed) { - Timber.w("Token refresh failed - session expired") + Logger.w("Auth") { "Token refresh failed - session expired" } plugin.tokenManager.emitSessionExpired() return@intercept originalCall } - // Retry the original request with the new token val newToken = plugin.tokenManager.getAccessToken() request.headers { remove(HttpHeaders.Authorization) diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/CookieHelper.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/CookieHelper.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/client/CookieHelper.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/CookieHelper.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/LibreChatHttpClient.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/LibreChatHttpClient.kt similarity index 69% rename from core/network/src/main/kotlin/com/librechat/android/core/network/client/LibreChatHttpClient.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/LibreChatHttpClient.kt index 2260103..01a1b4a 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/client/LibreChatHttpClient.kt +++ b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/LibreChatHttpClient.kt @@ -1,14 +1,14 @@ package com.librechat.android.core.network.client +import co.touchlab.kermit.Logger import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.HttpResponseValidator import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType @@ -20,40 +20,28 @@ import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject -import okhttp3.Cache -import timber.log.Timber -import java.io.File object LibreChatHttpClient { - private const val HTTP_CACHE_SIZE = 50L * 1024 * 1024 // 50 MB - fun create( + engineFactory: HttpClientEngineFactory<*>, json: Json, tokenManager: TokenManager, serverUrlProvider: ServerUrlProvider, - cacheDir: File? = null, debug: Boolean = false, - ): HttpClient = HttpClient(OkHttp) { - engine { - if (cacheDir != null) { - config { - cache(Cache(File(cacheDir, "http_cache"), HTTP_CACHE_SIZE)) - } - } - } + ): HttpClient = HttpClient(engineFactory) { install(ContentNegotiation) { json(json) } install(Logging) { - logger = object : Logger { + logger = object : io.ktor.client.plugins.logging.Logger { override fun log(message: String) { val sanitized = message .replace(Regex("Authorization: Bearer [^\\s]+"), "Authorization: Bearer [REDACTED]") .replace(Regex("refreshToken=[^;&\\s]+"), "refreshToken=[REDACTED]") - Timber.tag("HTTP").d(sanitized) + Logger.d("HTTP") { sanitized } } } level = if (debug) LogLevel.HEADERS else LogLevel.NONE @@ -75,10 +63,6 @@ object LibreChatHttpClient { this.tokenManager = tokenManager } - // Validate responses before deserialization — catches non-2xx and parses error body. - // Note: 401 responses are handled by AuthInterceptorPlugin at the HttpSend level - // (token refresh + retry). By the time the validator sees the response, 401 means - // the retry also failed, so we throw ApiException to surface the auth failure. HttpResponseValidator { validateResponse { response -> if (!response.status.isSuccess()) { @@ -87,7 +71,7 @@ object LibreChatHttpClient { val errorMessage = extractErrorMessage(json, bodyText, statusCode) val isBanned = statusCode == 403 && bodyText.contains("ban", ignoreCase = true) - Timber.w("HTTP $statusCode: $errorMessage") + Logger.w("HTTP") { "HTTP $statusCode: $errorMessage" } if (isBanned) { tokenManager.emitSessionExpired() @@ -108,35 +92,23 @@ object LibreChatHttpClient { url.takeFrom(baseUrl) } contentType(ContentType.Application.Json) - // WORKAROUND: The backend's uaParser middleware (ua-parser-js) rejects requests - // that don't have a recognized browser User-Agent with 403. We set a standard - // Chrome mobile UA so the parser detects a browser name. This is a temporary - // workaround until the backend exempts native app clients from the UA check. headers.append(HttpHeaders.UserAgent, BROWSER_USER_AGENT) } } // Standard Chrome on Android UA string — ua-parser-js will detect "Chrome" as browser name - private const val BROWSER_USER_AGENT = + const val BROWSER_USER_AGENT = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36" - /** - * Try to extract a user-friendly error message from the response body. - * Handles JSON responses like {"error": "message"} or {"message": "..."}. - * Falls back to a generic message based on status code. - */ private fun extractErrorMessage(json: Json, body: String, statusCode: Int): String { if (body.isNotBlank()) { try { val jsonObj = json.parseToJsonElement(body).jsonObject - // Try common error fields: "message", "error", "error_message". - // Fields may be objects rather than strings, so safe-cast to JsonPrimitive. val msg = (jsonObj["message"] as? JsonPrimitive)?.content ?: (jsonObj["error"] as? JsonPrimitive)?.content ?: (jsonObj["error_message"] as? JsonPrimitive)?.content if (!msg.isNullOrBlank()) return msg } catch (_: Exception) { - // Body is not JSON (might be HTML) — check for common patterns if (body.contains("banned", ignoreCase = true) || body.contains("forbidden", ignoreCase = true)) { return "Access denied. Your account may have been restricted." } diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/SecureTokenStorage.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/SecureTokenStorage.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/client/SecureTokenStorage.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/SecureTokenStorage.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/ServerUrlProvider.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/ServerUrlProvider.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/client/ServerUrlProvider.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/ServerUrlProvider.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/client/TokenManager.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/TokenManager.kt similarity index 75% rename from core/network/src/main/kotlin/com/librechat/android/core/network/client/TokenManager.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/TokenManager.kt index a330448..4f2aeab 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/client/TokenManager.kt +++ b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/client/TokenManager.kt @@ -3,6 +3,8 @@ package com.librechat.android.core.network.client import kotlinx.coroutines.flow.SharedFlow interface TokenManager { + /** Non-suspend check — returns true when an access token is cached in memory. */ + val isAuthenticated: Boolean suspend fun getAccessToken(): String? suspend fun setTokens(accessToken: String, refreshToken: String) suspend fun refreshAccessToken(): Boolean diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/di/NetworkModule.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/di/NetworkModule.kt similarity index 89% rename from core/network/src/main/kotlin/com/librechat/android/core/network/di/NetworkModule.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/di/NetworkModule.kt index 5c8b8db..c2bf495 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/di/NetworkModule.kt +++ b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/di/NetworkModule.kt @@ -28,7 +28,7 @@ import com.librechat.android.core.network.client.ServerUrlProvider import com.librechat.android.core.network.client.TokenManager import com.librechat.android.core.network.sse.SseClient import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest @@ -38,11 +38,14 @@ import io.ktor.http.contentType import io.ktor.http.takeFrom import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json -import org.koin.android.ext.koin.androidContext +import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.module +expect val networkPlatformModule: Module + val networkModule = module { + includes(networkPlatformModule) single { Json { @@ -56,17 +59,18 @@ val networkModule = module { single { LibreChatHttpClient.create( + engineFactory = get>(), json = get(), tokenManager = get(), serverUrlProvider = get(), - cacheDir = androidContext().cacheDir, ) } single(KoinQualifiers.Streaming) { val tokenManager = get() val serverUrlProvider = get() - HttpClient(OkHttp) { + val engineFactory = get>() + HttpClient(engineFactory) { install(AuthInterceptorPlugin) { this.tokenManager = tokenManager } @@ -80,10 +84,7 @@ val networkModule = module { 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, LibreChatHttpClient.BROWSER_USER_AGENT) } } } @@ -91,7 +92,8 @@ val networkModule = module { single(KoinQualifiers.Refresh) { val json = get() val serverUrlProvider = get() - HttpClient(OkHttp) { + val engineFactory = get>() + HttpClient(engineFactory) { install(ContentNegotiation) { json(json) } install(HttpTimeout) { requestTimeoutMillis = 15_000 diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseClient.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/sse/SseClient.kt similarity index 74% rename from core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseClient.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/sse/SseClient.kt index 1a069c7..578b364 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseClient.kt +++ b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/sse/SseClient.kt @@ -1,5 +1,6 @@ package com.librechat.android.core.network.sse +import co.touchlab.kermit.Logger import com.librechat.android.core.model.StreamEvent import io.ktor.client.HttpClient import io.ktor.client.request.accept @@ -15,7 +16,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.serialization.json.Json -import timber.log.Timber import kotlin.math.min class SseClient( @@ -35,6 +35,10 @@ class SseClient( resume: Boolean = false, connectivityFlow: Flow? = null, ): Flow = flow { + // Outer try/catch ensures no exception escapes the flow. + // SKIE's Flow→AsyncSequence iterator calls fatalError on unexpected errors, + // so any Kotlin exception that escapes this flow will crash the iOS app. + try { mapper.resetState() var attempt = 0 var shouldResume = resume @@ -45,10 +49,6 @@ class SseClient( while (attempt <= maxRetries && !done) { try { - // Use prepareGet + execute to stream the response incrementally. - // client.get() buffers the entire body in memory before returning, - // which defeats SSE streaming. prepareGet().execute {} keeps the - // HTTP connection open so we can read line-by-line as data arrives. val statement = client.prepareGet { url { path(streamPath) } accept(ContentType.Text.EventStream) @@ -60,7 +60,7 @@ class SseClient( statement.execute { response -> when { response.status.isSuccess() -> { - attempt = 0 // Reset on successful connection + attempt = 0 val channel = response.bodyAsChannel() lineParser.parse(channel).collect { sseEvent -> val streamEvent = mapper.map(sseEvent) @@ -79,26 +79,26 @@ class SseClient( } response.status == HttpStatusCode.Unauthorized -> { - Timber.w("SSE: 401 Unauthorized for $streamPath") + Logger.w("SSE") { "SSE: 401 Unauthorized for $streamPath" } emit(StreamEvent.Error(message = "Unauthorized", code = "401")) done = true } else -> { - Timber.w("SSE: unexpected status ${response.status} for $streamPath") + Logger.w("SSE") { "SSE: unexpected status ${response.status} for $streamPath" } attempt++ } } } - } catch (e: java.io.IOException) { - Timber.w(e, "SSE I/O error (attempt $attempt)") + } catch (e: SseStreamException) { + Logger.w("SSE", e) { "SSE I/O error (attempt $attempt)" } attempt++ if (attempt > maxRetries) { emit(StreamEvent.Error(message = "Connection lost. Please check your network and try again.", isNetworkError = true)) done = true } } catch (e: Exception) { - Timber.w(e, "SSE connection error (attempt $attempt)") + Logger.w("SSE", e) { "SSE connection error (attempt $attempt)" } attempt++ if (attempt > maxRetries) { emit(StreamEvent.Error(message = "Connection failed. Please try again.")) @@ -109,20 +109,16 @@ class SseClient( if (!done) { emit(StreamEvent.Retrying(attempt = attempt, maxAttempts = maxRetries)) - // If we have connectivity info and network is down, wait for it - // to come back instead of burning through retry delays blindly. - // Note: Each .first() call creates a new ConnectivityManager callback registration. - // Consider using a shared StateFlow if retry frequency becomes a concern. if (connectivityFlow != null) { try { val isConnected = connectivityFlow.first() if (!isConnected) { - Timber.d("SSE: network is down, waiting for connectivity before retry $attempt") + Logger.d("SSE") { "SSE: network is down, waiting for connectivity before retry $attempt" } connectivityFlow.first { it } - Timber.d("SSE: network restored, proceeding with retry $attempt") + Logger.d("SSE") { "SSE: network restored, proceeding with retry $attempt" } } } catch (e: Exception) { - Timber.w(e, "SSE: error checking connectivity, falling back to delay") + Logger.w("SSE", e) { "SSE: error checking connectivity, falling back to delay" } } } @@ -131,5 +127,11 @@ class SseClient( shouldResume = true } } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e // Re-throw cancellation — SKIE handles this gracefully + } catch (e: Exception) { + Logger.e("SSE", e) { "SSE: unhandled exception escaped flow" } + emit(StreamEvent.Error(message = "Unexpected error: ${e.message}")) + } } } diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEvent.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/sse/SseEvent.kt similarity index 100% rename from core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEvent.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/sse/SseEvent.kt diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt similarity index 97% rename from core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt index 321adaf..f90d95d 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt +++ b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/sse/SseEventMapper.kt @@ -13,7 +13,7 @@ import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import timber.log.Timber +import co.touchlab.kermit.Logger /** * Maps raw SSE events into domain [StreamEvent]s. @@ -77,7 +77,7 @@ class SseEventMapper(private val json: Json) { } mapJsonObject(root) } catch (e: Exception) { - Timber.w(e, "SSE parse error: event=${event.event}") + Logger.w("SSE", e) { "SSE parse error: event=${event.event}" } StreamEvent.Error(message = "Parse error: ${e.message}") } } @@ -122,7 +122,7 @@ class SseEventMapper(private val json: Json) { val conversation = root["conversation"]?.let { try { json.decodeFromJsonElement(Conversation.serializer(), it) } catch (e: Exception) { val msg = "Failed to parse final conversation: ${e.message}" - Timber.w(e, msg) + Logger.w("SSE", e) { msg } parseErrors.add(msg) null } @@ -130,7 +130,7 @@ class SseEventMapper(private val json: Json) { val requestMessage = root["requestMessage"]?.let { try { json.decodeFromJsonElement(Message.serializer(), it) } catch (e: Exception) { val msg = "Failed to parse final requestMessage: ${e.message}" - Timber.w(e, msg) + Logger.w("SSE", e) { msg } parseErrors.add(msg) null } @@ -138,7 +138,7 @@ class SseEventMapper(private val json: Json) { val responseMessage = root["responseMessage"]?.let { try { json.decodeFromJsonElement(Message.serializer(), it) } catch (e: Exception) { val msg = "Failed to parse final responseMessage: ${e.message}" - Timber.w(e, msg) + Logger.w("SSE", e) { msg } parseErrors.add(msg) null } @@ -147,7 +147,7 @@ class SseEventMapper(private val json: Json) { val legacyMessage = root["message"]?.let { try { json.decodeFromJsonElement(Message.serializer(), it) } catch (e: Exception) { val msg = "Failed to parse final legacy message: ${e.message}" - Timber.w(e, msg) + Logger.w("SSE", e) { msg } parseErrors.add(msg) null } @@ -157,7 +157,7 @@ class SseEventMapper(private val json: Json) { val allFieldsNull = conversation == null && requestMessage == null && responseMessage == null && legacyMessage == null if (allFieldsNull && parseErrors.isNotEmpty()) { - Timber.e("Final event: all fields failed to parse -- %s", parseErrors) + Logger.e("SSE") { "Final event: all fields failed to parse -- $parseErrors" } return StreamEvent.Error( message = "Failed to parse final event: ${parseErrors.joinToString("; ")}", ) @@ -214,7 +214,7 @@ class SseEventMapper(private val json: Json) { element, ) } catch (e: Exception) { - Timber.w(e, "Failed to parse sync aggregatedContent part") + Logger.w("SSE", e) { "Failed to parse sync aggregatedContent part" } null } } diff --git a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseLineParser.kt b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/sse/SseLineParser.kt similarity index 80% rename from core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseLineParser.kt rename to core/network/src/commonMain/kotlin/com/librechat/android/core/network/sse/SseLineParser.kt index 33c0a0c..4a22410 100644 --- a/core/network/src/main/kotlin/com/librechat/android/core/network/sse/SseLineParser.kt +++ b/core/network/src/commonMain/kotlin/com/librechat/android/core/network/sse/SseLineParser.kt @@ -1,12 +1,11 @@ package com.librechat.android.core.network.sse +import co.touchlab.kermit.Logger import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.readUTF8Line import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withTimeout -import timber.log.Timber -import java.io.IOException class SseLineParser( private val lineReadTimeoutMs: Long = DEFAULT_LINE_READ_TIMEOUT_MS, @@ -23,8 +22,8 @@ class SseLineParser( channel.readUTF8Line() } } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - Timber.w(e, "SSE stream stalled: no data for ${lineReadTimeoutMs / 1000}s") - throw IOException("Stream stalled: no data received for ${lineReadTimeoutMs / 1000}s", e) + Logger.w("SSE") { "SSE stream stalled: no data for ${lineReadTimeoutMs / 1000}s" } + throw SseStreamException("Stream stalled: no data received for ${lineReadTimeoutMs / 1000}s", e) } ?: break when { @@ -61,8 +60,11 @@ class SseLineParser( } } } - } catch (e: IOException) { - Timber.e(e, "SSE parse IOException") + } catch (e: SseStreamException) { + Logger.e("SSE", e) { "SSE parse error" } + throw e + } catch (e: Exception) { + Logger.e("SSE", e) { "SSE parse error" } throw e } @@ -80,3 +82,6 @@ class SseLineParser( const val DEFAULT_LINE_READ_TIMEOUT_MS = 120_000L } } + +/** Cross-platform exception for SSE stream errors (replaces java.io.IOException). */ +class SseStreamException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/core/network/src/iosMain/kotlin/com/librechat/android/core/network/di/NetworkPlatformModule.ios.kt b/core/network/src/iosMain/kotlin/com/librechat/android/core/network/di/NetworkPlatformModule.ios.kt new file mode 100644 index 0000000..a4d05fa --- /dev/null +++ b/core/network/src/iosMain/kotlin/com/librechat/android/core/network/di/NetworkPlatformModule.ios.kt @@ -0,0 +1,10 @@ +package com.librechat.android.core.network.di + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.darwin.Darwin +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val networkPlatformModule: Module = module { + single> { Darwin } +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 50a9d99..403f686 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -1,14 +1,19 @@ plugins { - id("librechat.android.library") - id("librechat.android.compose") + id("librechat.kmp.library") + id("librechat.kmp.compose") } android { namespace = "com.librechat.android.core.ui" } -dependencies { - implementation(project(":core:model")) - implementation(project(":core:common")) - implementation(libs.coil.compose) +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(libs.coil3.compose) + implementation(libs.coil3.network.ktor) + } + } } diff --git a/core/ui/src/androidMain/kotlin/com/librechat/android/core/ui/components/AvatarImage.android.kt b/core/ui/src/androidMain/kotlin/com/librechat/android/core/ui/components/AvatarImage.android.kt new file mode 100644 index 0000000..0228907 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/com/librechat/android/core/ui/components/AvatarImage.android.kt @@ -0,0 +1,38 @@ +package com.librechat.android.core.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Android backward-compatible overload that accepts a drawable resource ID. + * Feature modules not yet migrated to KMP can continue using `@DrawableRes Int?`. + */ +@Composable +fun AvatarImage( + imageUrl: String?, + modifier: Modifier = Modifier, + size: Dp = 32.dp, + fallbackText: String = "?", + @DrawableRes fallbackIconRes: Int?, + fallbackBackgroundColor: Color? = null, + showPersonIcon: Boolean = false, + tintIcon: Boolean = false, + contentDescription: String? = "$fallbackText avatar", +) { + AvatarImage( + imageUrl = imageUrl, + modifier = modifier, + size = size, + fallbackText = fallbackText, + fallbackIconPainter = fallbackIconRes?.let { painterResource(it) }, + fallbackBackgroundColor = fallbackBackgroundColor, + showPersonIcon = showPersonIcon, + tintIcon = tintIcon, + contentDescription = contentDescription, + ) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointIcons.kt b/core/ui/src/androidMain/kotlin/com/librechat/android/core/ui/components/EndpointIcons.android.kt similarity index 53% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointIcons.kt rename to core/ui/src/androidMain/kotlin/com/librechat/android/core/ui/components/EndpointIcons.android.kt index 1331a3b..92fba9d 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointIcons.kt +++ b/core/ui/src/androidMain/kotlin/com/librechat/android/core/ui/components/EndpointIcons.android.kt @@ -5,11 +5,13 @@ import com.librechat.android.core.model.EModelEndpoint import com.librechat.android.core.ui.R /** - * Returns the bundled drawable resource for the given endpoint, or null if none is available. - * Used by conversation list items and chat message avatars for provider-specific icons. + * Returns the bundled Android drawable resource for the given endpoint, or null. */ @DrawableRes -fun EModelEndpoint.toIconRes(): Int? = when (this) { +fun EModelEndpoint.toIconRes(): Int? = endpointIconRes(this) + +@DrawableRes +private fun endpointIconRes(endpoint: EModelEndpoint): Int? = when (endpoint) { EModelEndpoint.OPENAI -> R.drawable.ic_openai EModelEndpoint.AZURE_OPENAI -> R.drawable.ic_azure EModelEndpoint.GOOGLE -> R.drawable.ic_google @@ -22,7 +24,8 @@ fun EModelEndpoint.toIconRes(): Int? = when (this) { } /** - * Returns the drawable resource for an endpoint string (serial name), or null. + * Returns the bundled Android drawable resource for the given endpoint string, or null. + * Used by Android-only code paths that still pass `@DrawableRes Int?` through the component chain. */ @DrawableRes fun endpointIconRes(endpoint: String?): Int? = when (endpoint) { @@ -36,24 +39,3 @@ fun endpointIconRes(endpoint: String?): Int? = when (endpoint) { "azureAssistants" -> R.drawable.ic_azure else -> null } - -/** - * Returns true if the endpoint icon is monochrome and should be tinted with - * the theme's onSurfaceVariant color. Brand-colored icons (Anthropic, Google, - * Azure, Bedrock) should be rendered with [Color.Unspecified] to preserve their colors. - */ -fun EModelEndpoint.isMonochromeIcon(): Boolean = when (this) { - EModelEndpoint.OPENAI, - EModelEndpoint.ASSISTANTS, - EModelEndpoint.AGENTS, - -> true - else -> false -} - -/** - * String-based variant of [isMonochromeIcon]. - */ -fun isMonochromeEndpointIcon(endpoint: String?): Boolean = when (endpoint) { - "openAI", "assistants", "agents" -> true - else -> false -} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt b/core/ui/src/androidMain/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt similarity index 97% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt rename to core/ui/src/androidMain/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt index 18cf50f..49697e8 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt +++ b/core/ui/src/androidMain/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt @@ -26,9 +26,9 @@ private const val FALLBACK_CORNER_RADIUS_DP = 24f * Pass the [transition] from AnimatedVisibilityScope inside a composable() block. */ @Composable -fun ScreenTransitionWrapper( +actual fun ScreenTransitionWrapper( transition: Transition, - modifier: Modifier = Modifier, + modifier: Modifier, content: @Composable () -> Unit, ) { val screenCornerRadiusDp = rememberScreenCornerRadiusDp() diff --git a/core/ui/src/androidMain/kotlin/com/librechat/android/core/ui/theme/Theme.android.kt b/core/ui/src/androidMain/kotlin/com/librechat/android/core/ui/theme/Theme.android.kt new file mode 100644 index 0000000..2a5950d --- /dev/null +++ b/core/ui/src/androidMain/kotlin/com/librechat/android/core/ui/theme/Theme.android.kt @@ -0,0 +1,17 @@ +package com.librechat.android.core.ui.theme + +import android.os.Build +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun platformColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? { + if (dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val context = LocalContext.current + return if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + return null +} diff --git a/core/ui/src/main/res/drawable/ic_agents.xml b/core/ui/src/androidMain/res/drawable/ic_agents.xml similarity index 100% rename from core/ui/src/main/res/drawable/ic_agents.xml rename to core/ui/src/androidMain/res/drawable/ic_agents.xml diff --git a/core/ui/src/main/res/drawable/ic_anthropic.xml b/core/ui/src/androidMain/res/drawable/ic_anthropic.xml similarity index 100% rename from core/ui/src/main/res/drawable/ic_anthropic.xml rename to core/ui/src/androidMain/res/drawable/ic_anthropic.xml diff --git a/core/ui/src/main/res/drawable/ic_azure.xml b/core/ui/src/androidMain/res/drawable/ic_azure.xml similarity index 100% rename from core/ui/src/main/res/drawable/ic_azure.xml rename to core/ui/src/androidMain/res/drawable/ic_azure.xml diff --git a/core/ui/src/main/res/drawable/ic_bedrock.xml b/core/ui/src/androidMain/res/drawable/ic_bedrock.xml similarity index 100% rename from core/ui/src/main/res/drawable/ic_bedrock.xml rename to core/ui/src/androidMain/res/drawable/ic_bedrock.xml diff --git a/core/ui/src/main/res/drawable/ic_google.xml b/core/ui/src/androidMain/res/drawable/ic_google.xml similarity index 100% rename from core/ui/src/main/res/drawable/ic_google.xml rename to core/ui/src/androidMain/res/drawable/ic_google.xml diff --git a/core/ui/src/main/res/drawable/ic_openai.xml b/core/ui/src/androidMain/res/drawable/ic_openai.xml similarity index 100% rename from core/ui/src/main/res/drawable/ic_openai.xml rename to core/ui/src/androidMain/res/drawable/ic_openai.xml diff --git a/core/ui/src/commonMain/composeResources/drawable/ic_agents.xml b/core/ui/src/commonMain/composeResources/drawable/ic_agents.xml new file mode 100644 index 0000000..1144948 --- /dev/null +++ b/core/ui/src/commonMain/composeResources/drawable/ic_agents.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/core/ui/src/commonMain/composeResources/drawable/ic_anthropic.xml b/core/ui/src/commonMain/composeResources/drawable/ic_anthropic.xml new file mode 100644 index 0000000..05d612a --- /dev/null +++ b/core/ui/src/commonMain/composeResources/drawable/ic_anthropic.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/core/ui/src/commonMain/composeResources/drawable/ic_azure.xml b/core/ui/src/commonMain/composeResources/drawable/ic_azure.xml new file mode 100644 index 0000000..6f530bd --- /dev/null +++ b/core/ui/src/commonMain/composeResources/drawable/ic_azure.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/ui/src/commonMain/composeResources/drawable/ic_bedrock.xml b/core/ui/src/commonMain/composeResources/drawable/ic_bedrock.xml new file mode 100644 index 0000000..f880935 --- /dev/null +++ b/core/ui/src/commonMain/composeResources/drawable/ic_bedrock.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/commonMain/composeResources/drawable/ic_google.xml b/core/ui/src/commonMain/composeResources/drawable/ic_google.xml new file mode 100644 index 0000000..8c3dc0f --- /dev/null +++ b/core/ui/src/commonMain/composeResources/drawable/ic_google.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/commonMain/composeResources/drawable/ic_openai.xml b/core/ui/src/commonMain/composeResources/drawable/ic_openai.xml new file mode 100644 index 0000000..366b49d --- /dev/null +++ b/core/ui/src/commonMain/composeResources/drawable/ic_openai.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/AvatarImage.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/AvatarImage.kt similarity index 89% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/AvatarImage.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/AvatarImage.kt index 63bbb9b..2ab04e1 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/AvatarImage.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/AvatarImage.kt @@ -1,6 +1,5 @@ package com.librechat.android.core.ui.components -import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size @@ -15,11 +14,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage +import coil3.compose.AsyncImage /** Default user avatar background matching the official web app. */ private val UserAvatarBlue = Color(0xFF7989FF) @@ -30,7 +29,7 @@ fun AvatarImage( modifier: Modifier = Modifier, size: Dp = 32.dp, fallbackText: String = "?", - @DrawableRes fallbackIconRes: Int? = null, + fallbackIconPainter: Painter? = null, fallbackBackgroundColor: Color? = null, showPersonIcon: Boolean = false, tintIcon: Boolean = false, @@ -59,8 +58,8 @@ fun AvatarImage( tint = Color.White, ) } - } else if (fallbackIconRes != null) { - // Provider icon fallback: drawable resource with themed background + } else if (fallbackIconPainter != null) { + // Provider icon fallback: painter with themed background Box( modifier = modifier .size(size) @@ -69,7 +68,7 @@ fun AvatarImage( contentAlignment = Alignment.Center, ) { Icon( - painter = painterResource(id = fallbackIconRes), + painter = fallbackIconPainter, contentDescription = contentDescription, modifier = Modifier.size(size * 0.7f), tint = if (tintIcon) { diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/BannerDisplay.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/BannerDisplay.kt similarity index 94% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/BannerDisplay.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/BannerDisplay.kt index f996b98..4b98e4a 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/BannerDisplay.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/BannerDisplay.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card @@ -26,13 +25,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.librechat.android.core.model.Banner -import com.librechat.android.core.ui.R /** * Renders a vertical stack of dismissible server banners. @@ -88,7 +85,7 @@ private fun BannerCard( } val icon = when (bannerType) { "warning" -> Icons.Default.Warning - "error" -> Icons.Default.Error + "error" -> Icons.Default.Warning else -> Icons.Default.Info } @@ -118,7 +115,7 @@ private fun BannerCard( IconButton(onClick = onDismiss) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_dismiss_banner), + contentDescription = "Dismiss banner", tint = contentColor, ) } diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicCheckbox.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicCheckbox.kt similarity index 96% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicCheckbox.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicCheckbox.kt index d5c862d..190e63a 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicCheckbox.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicCheckbox.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.tooling.preview.Preview +import org.jetbrains.compose.ui.tooling.preview.Preview /** Labeled boolean toggle rendered as a Material 3 Switch (not a Checkbox widget). */ @Composable @@ -56,7 +56,7 @@ fun DynamicCheckbox( } } -@Preview(showBackground = true) +@Preview @Composable private fun DynamicCheckboxPreview() { var checked by remember { mutableStateOf(true) } diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicDropdown.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicDropdown.kt similarity index 97% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicDropdown.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicDropdown.kt index 360723c..fd48a9a 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicDropdown.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicDropdown.kt @@ -18,7 +18,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import org.jetbrains.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp /** Labeled dropdown selector using ExposedDropdownMenuBox with read-only text field anchor. */ @@ -80,7 +80,7 @@ fun DynamicDropdown( } } -@Preview(showBackground = true) +@Preview @Composable private fun DynamicDropdownPreview() { var selected by remember { mutableStateOf("gpt-4") } diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicInput.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicInput.kt similarity index 96% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicInput.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicInput.kt index 67ae601..43391b8 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicInput.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicInput.kt @@ -13,7 +13,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import org.jetbrains.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp /** Single-line text input with label, optional placeholder, and description. */ @@ -53,7 +53,7 @@ fun DynamicInput( } } -@Preview(showBackground = true) +@Preview @Composable private fun DynamicInputPreview() { var text by remember { mutableStateOf("") } diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicParameterPanel.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicParameterPanel.kt similarity index 96% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicParameterPanel.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicParameterPanel.kt index 23571f7..8a3c7c0 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicParameterPanel.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicParameterPanel.kt @@ -11,14 +11,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.librechat.android.core.model.ParameterDefinition import com.librechat.android.core.model.ParameterType -import com.librechat.android.core.ui.R +import org.jetbrains.compose.ui.tooling.preview.Preview /** Dispatches a list of ParameterDefinitions to typed controls (slider, dropdown, checkbox, input, textarea) by ParameterType. */ @Composable @@ -27,7 +25,7 @@ fun DynamicParameterPanel( values: Map, onValueChanged: (key: String, value: String) -> Unit, modifier: Modifier = Modifier, - title: String = stringResource(R.string.endpoint_parameters), + title: String = "Endpoint Parameters", ) { Column( modifier = modifier.fillMaxWidth(), @@ -130,7 +128,7 @@ fun DynamicParameterPanel( } } -@Preview(showBackground = true) +@Preview @Composable private fun DynamicParameterPanelPreview() { val definitions = listOf( diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicSlider.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicSlider.kt similarity index 80% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicSlider.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicSlider.kt index 7e08840..35e5cdb 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicSlider.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicSlider.kt @@ -14,12 +14,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.tooling.preview.Preview -import com.librechat.android.core.ui.R +import org.jetbrains.compose.ui.tooling.preview.Preview +import kotlin.math.pow import kotlin.math.roundToInt +import kotlin.math.roundToLong /** Labeled slider that snaps to step increments, with stepCount derived from (max - min) / step. */ @Composable @@ -35,9 +35,9 @@ fun DynamicSlider( displayDecimals: Int = 2, ) { val stepCount = ((max - min) / step).toInt().coerceAtLeast(1) - 1 - val displayValue = "%.${displayDecimals}f".format(value) + val displayValue = roundToDecimals(value, displayDecimals) - val sliderCd = stringResource(R.string.cd_slider_value, label, displayValue) + val sliderCd = "$label slider, value $displayValue" Column( modifier = modifier .fillMaxWidth() @@ -77,7 +77,7 @@ fun DynamicSlider( } } -@Preview(showBackground = true) +@Preview @Composable private fun DynamicSliderPreview() { var value by remember { mutableFloatStateOf(0.7f) } @@ -91,3 +91,14 @@ private fun DynamicSliderPreview() { description = "Controls randomness of the output.", ) } + +private fun roundToDecimals(value: Float, decimals: Int): String { + val factor = 10f.pow(decimals) + val rounded = (value * factor).roundToLong() / factor + val str = rounded.toString() + val dot = str.indexOf('.') + if (dot < 0) return str + "." + "0".repeat(decimals) + val existing = str.length - dot - 1 + return if (existing >= decimals) str.substring(0, dot + decimals + 1) + else str + "0".repeat(decimals - existing) +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTagsInput.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicTagsInput.kt similarity index 92% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTagsInput.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicTagsInput.kt index 7b4f56f..6152d0c 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTagsInput.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicTagsInput.kt @@ -24,11 +24,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.librechat.android.core.ui.R +import org.jetbrains.compose.ui.tooling.preview.Preview /** Tag input that renders chips and allows adding/removing text tags up to a max count. */ @OptIn(ExperimentalLayoutApi::class) @@ -74,7 +72,7 @@ fun DynamicTagsInput( ) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_remove_tag, tag), + contentDescription = "Remove $tag", modifier = Modifier.size(14.dp), ) } @@ -90,7 +88,7 @@ fun DynamicTagsInput( value = inputText, onValueChange = { inputText = it }, modifier = Modifier.fillMaxWidth(), - placeholder = { Text(stringResource(R.string.add_stop_sequence_hint)) }, + placeholder = { Text("Add stop sequence\u2026") }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions( @@ -107,7 +105,7 @@ fun DynamicTagsInput( } } -@Preview(showBackground = true) +@Preview @Composable private fun DynamicTagsInputPreview() { var tags by remember { mutableStateOf(listOf("END", "STOP")) } diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTextarea.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicTextarea.kt similarity index 96% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTextarea.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicTextarea.kt index a52f7ed..03ddc0e 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/DynamicTextarea.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/DynamicTextarea.kt @@ -13,7 +13,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import org.jetbrains.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp /** Multi-line text input with configurable minLines/maxLines, label, and optional description. */ @@ -56,7 +56,7 @@ fun DynamicTextarea( } } -@Preview(showBackground = true) +@Preview @Composable private fun DynamicTextareaPreview() { var text by remember { mutableStateOf("") } diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EmptyState.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/EmptyState.kt similarity index 100% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EmptyState.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/EmptyState.kt diff --git a/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/EndpointIcons.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/EndpointIcons.kt new file mode 100644 index 0000000..7b13c51 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/EndpointIcons.kt @@ -0,0 +1,66 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import com.librechat.android.core.model.EModelEndpoint +import librechat_android.core.ui.generated.resources.Res +import librechat_android.core.ui.generated.resources.ic_agents +import librechat_android.core.ui.generated.resources.ic_anthropic +import librechat_android.core.ui.generated.resources.ic_azure +import librechat_android.core.ui.generated.resources.ic_bedrock +import librechat_android.core.ui.generated.resources.ic_google +import librechat_android.core.ui.generated.resources.ic_openai +import org.jetbrains.compose.resources.painterResource + +/** + * Returns a Painter for the given endpoint's brand icon, or null if none is available. + */ +@Composable +fun endpointIconPainter(endpoint: EModelEndpoint): Painter? = when (endpoint) { + EModelEndpoint.OPENAI -> painterResource(Res.drawable.ic_openai) + EModelEndpoint.AZURE_OPENAI -> painterResource(Res.drawable.ic_azure) + EModelEndpoint.GOOGLE -> painterResource(Res.drawable.ic_google) + EModelEndpoint.ANTHROPIC -> painterResource(Res.drawable.ic_anthropic) + EModelEndpoint.BEDROCK -> painterResource(Res.drawable.ic_bedrock) + EModelEndpoint.AGENTS -> painterResource(Res.drawable.ic_agents) + EModelEndpoint.ASSISTANTS -> painterResource(Res.drawable.ic_openai) + EModelEndpoint.AZURE_ASSISTANTS -> painterResource(Res.drawable.ic_azure) + EModelEndpoint.CUSTOM -> null +} + +/** + * String-based variant of [endpointIconPainter]. + */ +@Composable +fun endpointIconPainter(endpoint: String?): Painter? = when (endpoint) { + "openAI" -> painterResource(Res.drawable.ic_openai) + "azureOpenAI" -> painterResource(Res.drawable.ic_azure) + "google" -> painterResource(Res.drawable.ic_google) + "anthropic" -> painterResource(Res.drawable.ic_anthropic) + "bedrock" -> painterResource(Res.drawable.ic_bedrock) + "agents" -> painterResource(Res.drawable.ic_agents) + "assistants" -> painterResource(Res.drawable.ic_openai) + "azureAssistants" -> painterResource(Res.drawable.ic_azure) + else -> null +} + +/** + * Returns true if the endpoint icon is monochrome and should be tinted with + * the theme's onSurfaceVariant color. Brand-colored icons (Anthropic, Google, + * Azure, Bedrock) should be rendered with [Color.Unspecified] to preserve their colors. + */ +fun EModelEndpoint.isMonochromeIcon(): Boolean = when (this) { + EModelEndpoint.OPENAI, + EModelEndpoint.ASSISTANTS, + EModelEndpoint.AGENTS, + -> true + else -> false +} + +/** + * String-based variant of [isMonochromeIcon]. + */ +fun isMonochromeEndpointIcon(endpoint: String?): Boolean = when (endpoint) { + "openAI", "assistants", "agents" -> true + else -> false +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointParameterRegistry.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/EndpointParameterRegistry.kt similarity index 100% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/EndpointParameterRegistry.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/EndpointParameterRegistry.kt diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ErrorBanner.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/ErrorBanner.kt similarity index 90% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ErrorBanner.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/ErrorBanner.kt index 809696e..e37beeb 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ErrorBanner.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/ErrorBanner.kt @@ -16,12 +16,10 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import com.librechat.android.core.ui.R @Composable fun ErrorBanner( @@ -44,7 +42,7 @@ fun ErrorBanner( ) { Icon( imageVector = Icons.Default.Warning, - contentDescription = stringResource(R.string.cd_error), + contentDescription = "Error", tint = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.width(12.dp)) @@ -56,7 +54,7 @@ fun ErrorBanner( ) if (onRetry != null) { TextButton(onClick = onRetry) { - Text(stringResource(R.string.retry)) + Text("Retry") } } } diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/components/FileTypeFilterBar.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/FilterChipBar.kt similarity index 54% rename from feature/files/src/main/kotlin/com/librechat/android/feature/files/components/FileTypeFilterBar.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/FilterChipBar.kt index 8f24040..529987a 100644 --- a/feature/files/src/main/kotlin/com/librechat/android/feature/files/components/FileTypeFilterBar.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/FilterChipBar.kt @@ -1,4 +1,4 @@ -package com.librechat.android.feature.files.components +package com.librechat.android.core.ui.components import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -10,13 +10,21 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.librechat.android.feature.files.viewmodel.FileTypeFilter -/** Horizontal FilterChip row that filters files by MIME type prefix (All, Images, Documents, Audio, Video). */ +/** + * Generic horizontal scrolling row of FilterChips. + * + * @param items All items to display as chips. + * @param isSelected Determines whether an item is currently selected. + * @param onSelect Called when a chip is clicked. + * @param label Extracts a display label from each item. + */ @Composable -fun FileTypeFilterBar( - selectedFilter: FileTypeFilter, - onFilterChange: (FileTypeFilter) -> Unit, +fun FilterChipBar( + items: List, + isSelected: (T) -> Boolean, + onSelect: (T) -> Unit, + label: (T) -> String, modifier: Modifier = Modifier, ) { Row( @@ -25,11 +33,11 @@ fun FileTypeFilterBar( .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - FileTypeFilter.entries.forEach { filter -> + items.forEach { item -> FilterChip( - selected = filter == selectedFilter, - onClick = { onFilterChange(filter) }, - label = { Text(filter.label) }, + selected = isSelected(item), + onClick = { onSelect(item) }, + label = { Text(label(item)) }, ) } } diff --git a/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/FilterChipBottomSheet.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/FilterChipBottomSheet.kt new file mode 100644 index 0000000..eacd007 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/FilterChipBottomSheet.kt @@ -0,0 +1,153 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Generic bottom sheet with a FlowRow of FilterChips and an optional text input for adding new items. + * + * @param items All available items to display as chips. + * @param selectedItems Currently selected items. + * @param onSelectionChanged Called with the final selection when the sheet is dismissed. + * @param label Extracts a display label from each item. + * @param onDismiss Called when the sheet is dismissed. + * @param title Title displayed at the top of the sheet. + * @param emptyMessage Message shown when there are no items and nothing is selected. + * @param onAdd Optional callback that converts user-entered text into an item to add to the selection. + * When non-null, a text input row is shown. Return null to reject the input. + * @param addPlaceholder Placeholder text for the add-new input field. + * @param addContentDescription Content description for the add button. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun FilterChipBottomSheet( + items: List, + selectedItems: Set, + onSelectionChanged: (Set) -> Unit, + label: (T) -> String, + onDismiss: () -> Unit, + title: String, + emptyMessage: String = "", + onAdd: ((String) -> T?)? = null, + addPlaceholder: String = "", + addContentDescription: String = "", + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + var currentSelection by remember { mutableStateOf(selectedItems) } + var newItemText by remember { mutableStateOf("") } + + ModalBottomSheet( + onDismissRequest = { + onSelectionChanged(currentSelection) + onDismiss() + }, + modifier = modifier, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (items.isEmpty() && currentSelection.isEmpty()) { + if (emptyMessage.isNotEmpty()) { + Text( + text = emptyMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items.forEach { item -> + FilterChip( + selected = item in currentSelection, + onClick = { + currentSelection = if (item in currentSelection) { + currentSelection - item + } else { + currentSelection + item + } + }, + label = { Text(label(item)) }, + ) + } + } + } + + if (onAdd != null) { + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = newItemText, + onValueChange = { newItemText = it }, + placeholder = { Text(addPlaceholder) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = { + val trimmed = newItemText.trim() + if (trimmed.isNotEmpty()) { + val newItem = onAdd(trimmed) + if (newItem != null) { + currentSelection = currentSelection + newItem + } + newItemText = "" + } + }, + enabled = newItemText.isNotBlank(), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = addContentDescription, + ) + } + } + } + } + } +} diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LibreChatTopBar.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/LibreChatTopBar.kt similarity index 87% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LibreChatTopBar.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/LibreChatTopBar.kt index 44929b8..d2f6a80 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LibreChatTopBar.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/LibreChatTopBar.kt @@ -12,8 +12,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.librechat.android.core.ui.R @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -33,7 +31,7 @@ fun LibreChatTopBar( IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = "Back", ) } } @@ -41,7 +39,7 @@ fun LibreChatTopBar( IconButton(onClick = onMenuClick) { Icon( imageVector = Icons.Default.Menu, - contentDescription = stringResource(R.string.cd_menu), + contentDescription = "Menu", ) } } diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LoadingIndicator.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/LoadingIndicator.kt similarity index 84% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LoadingIndicator.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/LoadingIndicator.kt index 3499d87..cdeb9a2 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/LoadingIndicator.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/LoadingIndicator.kt @@ -7,23 +7,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import com.librechat.android.core.ui.R @Composable fun LoadingIndicator( modifier: Modifier = Modifier, ) { - val loadingCd = stringResource(R.string.cd_loading) Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { CircularProgressIndicator( color = MaterialTheme.colorScheme.primary, - modifier = Modifier.semantics { contentDescription = loadingCd }, + modifier = Modifier.semantics { contentDescription = "Loading" }, ) } } diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ModelParameterSheet.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/ModelParameterSheet.kt similarity index 96% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ModelParameterSheet.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/ModelParameterSheet.kt index 817f0d8..eb4076b 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/ModelParameterSheet.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/ModelParameterSheet.kt @@ -21,13 +21,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.librechat.android.core.model.ParameterDefinition import com.librechat.android.core.model.ParameterType -import com.librechat.android.core.ui.R @Stable data class ModelParameters( @@ -156,7 +154,7 @@ fun ModelParameterContent( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - text = stringResource(R.string.model_parameters), + text = "Model Parameters", style = MaterialTheme.typography.titleLarge, modifier = Modifier.semantics { heading() }, ) @@ -174,9 +172,7 @@ fun ModelParameterContent( onValueChange = { newValue -> onParametersChanged(parameters.withUpdatedKey(definition.key, newValue)) }, - placeholder = definition.default?.ifEmpty { - stringResource(R.string.default_placeholder) - } ?: stringResource(R.string.default_placeholder), + placeholder = definition.default?.ifEmpty { "Default" } ?: "Default", description = definition.description, ) } @@ -273,7 +269,7 @@ fun ModelParameterContent( }, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(R.string.reset_to_defaults)) + Text("Reset to Defaults") } // Save As Preset @@ -281,7 +277,7 @@ fun ModelParameterContent( onClick = onSaveAsPreset, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(R.string.save_as_preset)) + Text("Save As Preset") } Spacer(modifier = Modifier.height(16.dp)) diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/components/OtpVerificationDialog.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/OtpVerificationDialog.kt similarity index 100% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/components/OtpVerificationDialog.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/OtpVerificationDialog.kt diff --git a/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt new file mode 100644 index 0000000..619ccf5 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.kt @@ -0,0 +1,18 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.Transition +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Wraps screen content to apply rounded corners during navigation transitions. + * On Android, reads the device screen corner radius for a polished effect. + * On iOS, renders content directly (no transition animation needed). + */ +@Composable +expect fun ScreenTransitionWrapper( + transition: Transition, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/AccessibilityUtils.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/theme/AccessibilityUtils.kt similarity index 100% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/AccessibilityUtils.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/theme/AccessibilityUtils.kt diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Color.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/theme/Color.kt similarity index 100% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Color.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/theme/Color.kt diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Shape.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/theme/Shape.kt similarity index 100% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Shape.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/theme/Shape.kt diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Theme.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/theme/Theme.kt similarity index 79% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Theme.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/theme/Theme.kt index 7a7cb69..0f18516 100644 --- a/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Theme.kt +++ b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/theme/Theme.kt @@ -1,14 +1,11 @@ package com.librechat.android.core.ui.theme -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext private val libreChatLightColorScheme = lightColorScheme( primary = LightPrimary, @@ -54,23 +51,21 @@ private val libreChatDarkColorScheme = darkColorScheme( onError = DarkOnError, ) +/** + * Returns a platform-specific dynamic color scheme if available, or null to fall back + * to the static LibreChat color scheme. On Android 12+, returns Material You colors. + */ +@Composable +expect fun platformColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? + @Composable fun LibreChatTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = false, content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - if (darkTheme) { - dynamicDarkColorScheme(LocalContext.current) - } else { - dynamicLightColorScheme(LocalContext.current) - } - } - darkTheme -> libreChatDarkColorScheme - else -> libreChatLightColorScheme - } + val colorScheme = platformColorScheme(darkTheme, dynamicColor) + ?: if (darkTheme) libreChatDarkColorScheme else libreChatLightColorScheme MaterialTheme( colorScheme = colorScheme, diff --git a/core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Type.kt b/core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/theme/Type.kt similarity index 100% rename from core/ui/src/main/kotlin/com/librechat/android/core/ui/theme/Type.kt rename to core/ui/src/commonMain/kotlin/com/librechat/android/core/ui/theme/Type.kt diff --git a/core/ui/src/iosMain/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.ios.kt b/core/ui/src/iosMain/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.ios.kt new file mode 100644 index 0000000..b6601b5 --- /dev/null +++ b/core/ui/src/iosMain/kotlin/com/librechat/android/core/ui/components/ScreenTransitionWrapper.ios.kt @@ -0,0 +1,38 @@ +package com.librechat.android.core.ui.components + +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer + +@Composable +actual fun ScreenTransitionWrapper( + transition: Transition, + modifier: Modifier, + content: @Composable () -> Unit, +) { + val alpha by transition.animateFloat( + transitionSpec = { tween(300, easing = LinearEasing) }, + label = "screenAlpha", + ) { state -> + when (state) { + EnterExitState.PreEnter -> 0f + EnterExitState.Visible -> 1f + EnterExitState.PostExit -> 0f + } + } + Box( + modifier = modifier + .fillMaxSize() + .graphicsLayer { this.alpha = alpha }, + ) { + content() + } +} diff --git a/core/ui/src/iosMain/kotlin/com/librechat/android/core/ui/theme/Theme.ios.kt b/core/ui/src/iosMain/kotlin/com/librechat/android/core/ui/theme/Theme.ios.kt new file mode 100644 index 0000000..bcd8a51 --- /dev/null +++ b/core/ui/src/iosMain/kotlin/com/librechat/android/core/ui/theme/Theme.ios.kt @@ -0,0 +1,7 @@ +package com.librechat.android.core.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +@Composable +actual fun platformColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? = null diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml deleted file mode 100644 index 419f7f6..0000000 --- a/core/ui/src/main/res/values/strings.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - Loading\u2026 - Error - Retry - Cancel - Confirm - OK - Delete - Back - - - Loading - Back - Menu - Error - Dismiss banner - Remove %1$s - %1$s slider, value %2$s - AI provider: %1$s - Model: %1$s - %1$s avatar - - - Model Parameters - Reset to Defaults - Save As Preset - Default - Endpoint Parameters - - - Add stop sequence\u2026 - diff --git a/feature/agents/build.gradle.kts b/feature/agents/build.gradle.kts index 0f43935..4a05b66 100644 --- a/feature/agents/build.gradle.kts +++ b/feature/agents/build.gradle.kts @@ -1,15 +1,25 @@ plugins { - id("librechat.android.feature") + id("librechat.kmp.feature") } android { namespace = "com.librechat.android.feature.agents" } -dependencies { - implementation(project(":core:network")) - implementation(libs.coil.compose) - implementation(libs.kotlinx.serialization.json) - implementation(libs.snakeyaml.engine) - implementation(libs.timber) +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":core:network")) + implementation(libs.coil3.compose) + implementation(libs.coil3.network.ktor) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kermit) + } + androidMain.dependencies { + implementation(libs.koin.android) + } + getByName("androidUnitTest").dependencies { + implementation(libs.koin.test) + } + } } diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt b/feature/agents/src/androidMain/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt similarity index 86% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt rename to feature/agents/src/androidMain/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt index a397286..31f9f68 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt +++ b/feature/agents/src/androidMain/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt @@ -18,19 +18,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import com.librechat.android.feature.agents.R +import coil3.compose.AsyncImage +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* @Composable -fun AgentAvatarPicker( +actual fun AgentAvatarPicker( avatarUrl: String?, agentName: String, - onImageSelected: (Uri) -> Unit, - modifier: Modifier = Modifier, + onImageSelected: (Any) -> Unit, + modifier: Modifier, ) { val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), @@ -40,7 +41,7 @@ fun AgentAvatarPicker( } } - val avatarCd = stringResource(R.string.cd_agent_avatar_tap) + val avatarCd = stringResource(Res.string.cd_agent_avatar_tap) Box( modifier = modifier .size(80.dp) @@ -52,7 +53,7 @@ fun AgentAvatarPicker( if (avatarUrl != null) { AsyncImage( model = avatarUrl, - contentDescription = stringResource(R.string.cd_agent_avatar_name, agentName), + contentDescription = stringResource(Res.string.cd_agent_avatar_name, agentName), modifier = Modifier .size(80.dp) .clip(CircleShape), diff --git a/feature/agents/src/androidMain/kotlin/com/librechat/android/feature/agents/di/AgentsPlatformModule.android.kt b/feature/agents/src/androidMain/kotlin/com/librechat/android/feature/agents/di/AgentsPlatformModule.android.kt new file mode 100644 index 0000000..956a25c --- /dev/null +++ b/feature/agents/src/androidMain/kotlin/com/librechat/android/feature/agents/di/AgentsPlatformModule.android.kt @@ -0,0 +1,12 @@ +package com.librechat.android.feature.agents.di + +import com.librechat.android.feature.agents.util.AndroidContentReader +import com.librechat.android.feature.agents.util.ContentReader +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.Module +import org.koin.dsl.bind +import org.koin.dsl.module + +actual val agentsPlatformModule: Module = module { + single { AndroidContentReader(androidContext()) } bind ContentReader::class +} diff --git a/feature/agents/src/androidMain/kotlin/com/librechat/android/feature/agents/util/AndroidContentReader.kt b/feature/agents/src/androidMain/kotlin/com/librechat/android/feature/agents/util/AndroidContentReader.kt new file mode 100644 index 0000000..c39a21e --- /dev/null +++ b/feature/agents/src/androidMain/kotlin/com/librechat/android/feature/agents/util/AndroidContentReader.kt @@ -0,0 +1,17 @@ +package com.librechat.android.feature.agents.util + +import android.content.Context +import android.net.Uri + +class AndroidContentReader(private val context: Context) : ContentReader { + + override fun readBytes(uri: Any): ByteArray? { + val androidUri = uri as? Uri ?: return null + return context.contentResolver.openInputStream(androidUri)?.use { it.readBytes() } + } + + override fun getMimeType(uri: Any): String? { + val androidUri = uri as? Uri ?: return null + return context.contentResolver.getType(androidUri) + } +} diff --git a/feature/agents/src/test/kotlin/com/librechat/android/feature/agents/di/AgentsModuleVerificationTest.kt b/feature/agents/src/androidUnitTest/kotlin/com/librechat/android/feature/agents/di/AgentsModuleVerificationTest.kt similarity index 100% rename from feature/agents/src/test/kotlin/com/librechat/android/feature/agents/di/AgentsModuleVerificationTest.kt rename to feature/agents/src/androidUnitTest/kotlin/com/librechat/android/feature/agents/di/AgentsModuleVerificationTest.kt diff --git a/feature/agents/src/test/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModelTest.kt b/feature/agents/src/androidUnitTest/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModelTest.kt similarity index 100% rename from feature/agents/src/test/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModelTest.kt rename to feature/agents/src/androidUnitTest/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModelTest.kt diff --git a/feature/agents/src/main/res/values/strings.xml b/feature/agents/src/commonMain/composeResources/values/strings.xml similarity index 100% rename from feature/agents/src/main/res/values/strings.xml rename to feature/agents/src/commonMain/composeResources/values/strings.xml diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/AgentDisplayData.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/AgentDisplayData.kt similarity index 100% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/AgentDisplayData.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/AgentDisplayData.kt diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentActionsPanel.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentActionsPanel.kt similarity index 88% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentActionsPanel.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentActionsPanel.kt index 39c7fea..b2fd1a5 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentActionsPanel.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentActionsPanel.kt @@ -45,7 +45,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -57,7 +57,8 @@ import com.librechat.android.core.model.ActionAuth import com.librechat.android.core.model.ActionMetadata import com.librechat.android.core.model.request.FunctionTool import com.librechat.android.feature.agents.AgentActionDisplayData -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* import com.librechat.android.feature.agents.util.OpenApiSpecParser import com.librechat.android.feature.agents.util.ParsedFunctionInfo import kotlinx.coroutines.delay @@ -87,12 +88,12 @@ fun AgentActionsPanel( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.openapi_actions_count, actions.size), + text = stringResource(Res.string.openapi_actions_count, actions.size), style = MaterialTheme.typography.titleSmall, ) Icon( imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + contentDescription = if (expanded) stringResource(Res.string.cd_collapse) else stringResource(Res.string.cd_expand), ) } @@ -103,7 +104,7 @@ fun AgentActionsPanel( ) { if (actions.isEmpty()) { Text( - text = stringResource(R.string.no_actions_configured), + text = stringResource(Res.string.no_actions_configured), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp), @@ -134,7 +135,7 @@ fun AgentActionsPanel( contentDescription = null, ) Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.add_action)) + Text(stringResource(Res.string.add_action)) } } } @@ -178,16 +179,16 @@ private fun ActionCard( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = action.domain ?: stringResource(R.string.unknown_author), + text = action.domain ?: stringResource(Res.string.unknown_author), style = MaterialTheme.typography.bodyLarge, ) val authLabel = when (action.authType) { - "service_http" -> stringResource(R.string.auth_api_key) - "oauth" -> stringResource(R.string.auth_oauth) - else -> stringResource(R.string.auth_none) + "service_http" -> stringResource(Res.string.auth_api_key) + "oauth" -> stringResource(Res.string.auth_oauth) + else -> stringResource(Res.string.auth_none) } Text( - text = stringResource(R.string.action_auth_info, authLabel, action.functionCount), + text = stringResource(Res.string.action_auth_info, authLabel, action.functionCount), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -196,13 +197,13 @@ private fun ActionCard( IconButton(onClick = onEdit) { Icon( imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.cd_edit_action), + contentDescription = stringResource(Res.string.cd_edit_action), ) } IconButton(onClick = onDelete) { Icon( imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.cd_delete_action), + contentDescription = stringResource(Res.string.cd_delete_action), tint = MaterialTheme.colorScheme.error, ) } @@ -286,12 +287,12 @@ private fun ActionEditorDialog( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = if (isEditing) stringResource(R.string.edit_action) else stringResource(R.string.add_action), + text = if (isEditing) stringResource(Res.string.edit_action) else stringResource(Res.string.add_action), style = MaterialTheme.typography.titleMedium, ) Row { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) + Text(stringResource(Res.string.cancel)) } Spacer(modifier = Modifier.width(8.dp)) TextButton( @@ -346,7 +347,7 @@ private fun ActionEditorDialog( }, enabled = parsedFunctions.isNotEmpty() && validationErrors.isEmpty() && !isValidating, ) { - Text(if (isEditing) stringResource(R.string.save) else stringResource(R.string.create)) + Text(if (isEditing) stringResource(Res.string.save) else stringResource(Res.string.create)) } } } @@ -371,18 +372,18 @@ private fun ActionEditorDialog( // 2. Schema input Text( - text = stringResource(R.string.label_openapi_schema), + text = stringResource(Res.string.label_openapi_schema), style = MaterialTheme.typography.titleSmall, ) Text( - text = stringResource(R.string.openapi_schema_hint), + text = stringResource(Res.string.openapi_schema_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) OutlinedTextField( value = rawSpec, onValueChange = { rawSpec = it }, - label = { Text(stringResource(R.string.label_openapi_spec)) }, + label = { Text(stringResource(Res.string.label_openapi_spec)) }, placeholder = { Text( text = "{\n \"openapi\": \"3.0.0\",\n" + @@ -401,7 +402,7 @@ private fun ActionEditorDialog( // Validation feedback if (isValidating) { Text( - text = stringResource(R.string.validating), + text = stringResource(Res.string.validating), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, ) @@ -422,7 +423,7 @@ private fun ActionEditorDialog( // Domain display if (parsedDomain.isNotBlank()) { Text( - text = stringResource(R.string.domain_prefix, parsedDomain), + text = stringResource(Res.string.domain_prefix, parsedDomain), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, ) @@ -432,7 +433,7 @@ private fun ActionEditorDialog( if (parsedFunctionInfos.isNotEmpty()) { HorizontalDivider() Text( - text = stringResource(R.string.available_actions_count, parsedFunctionInfos.size), + text = stringResource(Res.string.available_actions_count, parsedFunctionInfos.size), style = MaterialTheme.typography.titleSmall, ) FunctionTable(functions = parsedFunctionInfos) @@ -442,7 +443,7 @@ private fun ActionEditorDialog( OutlinedTextField( value = privacyPolicyUrl, onValueChange = { privacyPolicyUrl = it }, - label = { Text(stringResource(R.string.label_privacy_policy_url)) }, + label = { Text(stringResource(Res.string.label_privacy_policy_url)) }, placeholder = { Text("https://example.com/privacy") }, singleLine = true, modifier = Modifier.fillMaxWidth(), @@ -497,7 +498,7 @@ private fun AuthenticationSection( ) { Column(modifier = modifier.fillMaxWidth()) { Text( - text = stringResource(R.string.label_authentication), + text = stringResource(Res.string.label_authentication), style = MaterialTheme.typography.titleSmall, ) Spacer(modifier = Modifier.height(8.dp)) @@ -516,9 +517,9 @@ private fun AuthenticationSection( Spacer(modifier = Modifier.width(8.dp)) Text( text = when (authType) { - "service_http" -> stringResource(R.string.auth_api_key_full) - "oauth" -> stringResource(R.string.auth_oauth_full) - else -> stringResource(R.string.auth_no_auth) + "service_http" -> stringResource(Res.string.auth_api_key_full) + "oauth" -> stringResource(Res.string.auth_oauth_full) + else -> stringResource(Res.string.auth_no_auth) }, ) } @@ -569,7 +570,7 @@ private fun AuthConfigDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.label_authentication)) }, + title = { Text(stringResource(Res.string.label_authentication)) }, text = { Column( modifier = Modifier @@ -579,22 +580,22 @@ private fun AuthConfigDialog( ) { // Auth type radio group Text( - text = stringResource(R.string.label_authentication_type), + text = stringResource(Res.string.label_authentication_type), style = MaterialTheme.typography.labelLarge, ) AuthTypeRadioOption( - label = stringResource(R.string.auth_none), + label = stringResource(Res.string.auth_none), selected = selectedAuthType == "none", onClick = { selectedAuthType = "none" }, ) AuthTypeRadioOption( - label = stringResource(R.string.auth_api_key), + label = stringResource(Res.string.auth_api_key), selected = selectedAuthType == "service_http", onClick = { selectedAuthType = "service_http" }, ) AuthTypeRadioOption( - label = stringResource(R.string.auth_oauth), + label = stringResource(Res.string.auth_oauth), selected = selectedAuthType == "oauth", onClick = { selectedAuthType = "oauth" }, ) @@ -606,14 +607,14 @@ private fun AuthConfigDialog( ) { HorizontalDivider() Text( - text = stringResource(R.string.label_api_key_settings), + text = stringResource(Res.string.label_api_key_settings), style = MaterialTheme.typography.labelLarge, ) OutlinedTextField( value = localApiKey, onValueChange = { localApiKey = it }, - label = { Text(stringResource(R.string.label_api_key)) }, + label = { Text(stringResource(Res.string.label_api_key)) }, placeholder = { Text(if (isEditing) HIDDEN_PLACEHOLDER else "sk-...") }, @@ -627,22 +628,22 @@ private fun AuthConfigDialog( ) Text( - text = stringResource(R.string.label_authorization_type), + text = stringResource(Res.string.label_authorization_type), style = MaterialTheme.typography.labelMedium, ) AuthTypeRadioOption( - label = stringResource(R.string.auth_basic), + label = stringResource(Res.string.auth_basic), selected = localAuthorizationType == "basic", onClick = { localAuthorizationType = "basic" }, ) AuthTypeRadioOption( - label = stringResource(R.string.auth_bearer), + label = stringResource(Res.string.auth_bearer), selected = localAuthorizationType == "bearer", onClick = { localAuthorizationType = "bearer" }, ) AuthTypeRadioOption( - label = stringResource(R.string.auth_custom), + label = stringResource(Res.string.auth_custom), selected = localAuthorizationType == "custom", onClick = { localAuthorizationType = "custom" }, ) @@ -651,7 +652,7 @@ private fun AuthConfigDialog( OutlinedTextField( value = localCustomHeader, onValueChange = { localCustomHeader = it }, - label = { Text(stringResource(R.string.label_custom_auth_header)) }, + label = { Text(stringResource(Res.string.label_custom_auth_header)) }, placeholder = { Text("X-Api-Key") }, singleLine = true, modifier = Modifier.fillMaxWidth(), @@ -667,14 +668,14 @@ private fun AuthConfigDialog( ) { HorizontalDivider() Text( - text = stringResource(R.string.label_oauth_settings), + text = stringResource(Res.string.label_oauth_settings), style = MaterialTheme.typography.labelLarge, ) OutlinedTextField( value = localOauthClientId, onValueChange = { localOauthClientId = it }, - label = { Text(stringResource(R.string.label_client_id)) }, + label = { Text(stringResource(Res.string.label_client_id)) }, placeholder = { Text(if (isEditing) HIDDEN_PLACEHOLDER else "client-id") }, @@ -685,7 +686,7 @@ private fun AuthConfigDialog( OutlinedTextField( value = localOauthClientSecret, onValueChange = { localOauthClientSecret = it }, - label = { Text(stringResource(R.string.label_client_secret)) }, + label = { Text(stringResource(Res.string.label_client_secret)) }, placeholder = { Text(if (isEditing) HIDDEN_PLACEHOLDER else "client-secret") }, @@ -701,7 +702,7 @@ private fun AuthConfigDialog( OutlinedTextField( value = localAuthUrl, onValueChange = { localAuthUrl = it }, - label = { Text(stringResource(R.string.label_authorization_url)) }, + label = { Text(stringResource(Res.string.label_authorization_url)) }, placeholder = { Text("https://auth.example.com/authorize") }, singleLine = true, modifier = Modifier.fillMaxWidth(), @@ -710,7 +711,7 @@ private fun AuthConfigDialog( OutlinedTextField( value = localClientUrl, onValueChange = { localClientUrl = it }, - label = { Text(stringResource(R.string.label_token_url)) }, + label = { Text(stringResource(Res.string.label_token_url)) }, placeholder = { Text("https://auth.example.com/token") }, singleLine = true, modifier = Modifier.fillMaxWidth(), @@ -719,24 +720,24 @@ private fun AuthConfigDialog( OutlinedTextField( value = localScope, onValueChange = { localScope = it }, - label = { Text(stringResource(R.string.label_scope)) }, + label = { Text(stringResource(Res.string.label_scope)) }, placeholder = { Text("openid profile") }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) Text( - text = stringResource(R.string.label_token_exchange_method), + text = stringResource(Res.string.label_token_exchange_method), style = MaterialTheme.typography.labelMedium, ) AuthTypeRadioOption( - label = stringResource(R.string.token_default_post), + label = stringResource(Res.string.token_default_post), selected = localTokenExchangeMethod == "default_post", onClick = { localTokenExchangeMethod = "default_post" }, ) AuthTypeRadioOption( - label = stringResource(R.string.token_basic_auth_header), + label = stringResource(Res.string.token_basic_auth_header), selected = localTokenExchangeMethod == "basic_auth_header", onClick = { localTokenExchangeMethod = "basic_auth_header" }, ) @@ -761,12 +762,12 @@ private fun AuthConfigDialog( ) }, ) { - Text(stringResource(R.string.apply)) + Text(stringResource(Res.string.apply)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) + Text(stringResource(Res.string.cancel)) } }, ) @@ -826,19 +827,19 @@ private fun FunctionTable( horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - text = stringResource(R.string.table_name), + text = stringResource(Res.string.table_name), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, modifier = Modifier.width(180.dp), ) Text( - text = stringResource(R.string.table_method), + text = stringResource(Res.string.table_method), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), ) Text( - text = stringResource(R.string.table_path), + text = stringResource(Res.string.table_path), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, modifier = Modifier.width(200.dp), diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAdvancedPanel.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentAdvancedPanel.kt similarity index 81% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAdvancedPanel.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentAdvancedPanel.kt index 8808fe0..9b80c97 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentAdvancedPanel.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentAdvancedPanel.kt @@ -26,10 +26,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* import kotlin.math.roundToInt data class AgentAdvancedSettings( @@ -56,12 +57,12 @@ fun AgentAdvancedPanel( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.advanced_settings), + text = stringResource(Res.string.advanced_settings), style = MaterialTheme.typography.titleSmall, ) Icon( imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + contentDescription = if (expanded) stringResource(Res.string.cd_collapse) else stringResource(Res.string.cd_expand), ) } @@ -72,36 +73,36 @@ fun AgentAdvancedPanel( ) { // Temperature slider (0-2) AdvancedSlider( - label = stringResource(R.string.label_temperature), + label = stringResource(Res.string.label_temperature), value = settings.temperature ?: 1.0f, onValueChange = { onSettingsChanged(settings.copy(temperature = roundToStep(it, 0.1f))) }, valueRange = 0f..2f, steps = 19, - description = stringResource(R.string.temperature_description), + description = stringResource(Res.string.temperature_description), ) // Top P slider (0-1) AdvancedSlider( - label = stringResource(R.string.label_top_p), + label = stringResource(Res.string.label_top_p), value = settings.topP ?: 1.0f, onValueChange = { onSettingsChanged(settings.copy(topP = roundToStep(it, 0.05f))) }, valueRange = 0f..1f, steps = 19, - description = stringResource(R.string.top_p_description), + description = stringResource(Res.string.top_p_description), ) // Max tokens input Column(modifier = Modifier.fillMaxWidth()) { Text( - text = stringResource(R.string.label_max_tokens), + text = stringResource(Res.string.label_max_tokens), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.max_tokens_description), + text = stringResource(Res.string.max_tokens_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -114,7 +115,7 @@ fun AgentAdvancedPanel( settings.copy(maxTokens = filtered.toIntOrNull()), ) }, - placeholder = { Text(stringResource(R.string.default_value)) }, + placeholder = { Text(stringResource(Res.string.default_value)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, modifier = Modifier.fillMaxWidth(), @@ -148,7 +149,7 @@ private fun AdvancedSlider( style = MaterialTheme.typography.bodyLarge, ) Text( - text = "%.2f".format(sliderValue), + text = formatFloat(sliderValue), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, ) @@ -174,3 +175,19 @@ private fun AdvancedSlider( private fun roundToStep(value: Float, step: Float): Float { return (value / step).roundToInt() * step } + +private fun formatFloat(value: Float): String { + val rounded = (value * 100).roundToInt() / 100.0 + val str = rounded.toString() + val dotIndex = str.indexOf('.') + return if (dotIndex < 0) { + "$str.00" + } else { + val decimals = str.length - dotIndex - 1 + when { + decimals >= 2 -> str.substring(0, dotIndex + 3) + decimals == 1 -> "${str}0" + else -> "$str.00" + } + } +} diff --git a/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt new file mode 100644 index 0000000..2d8db8f --- /dev/null +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.kt @@ -0,0 +1,12 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +expect fun AgentAvatarPicker( + avatarUrl: String?, + agentName: String, + onImageSelected: (Any) -> Unit, + modifier: Modifier = Modifier, +) diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCapabilitiesSection.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentCapabilitiesSection.kt similarity index 82% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCapabilitiesSection.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentCapabilitiesSection.kt index c376ce0..029c7b6 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCapabilitiesSection.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentCapabilitiesSection.kt @@ -15,10 +15,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* data class AgentCapabilities( val artifacts: Boolean = false, @@ -35,14 +36,14 @@ fun AgentCapabilitiesSection( ) { Column(modifier = modifier.fillMaxWidth()) { Text( - text = stringResource(R.string.label_capabilities), + text = stringResource(Res.string.label_capabilities), style = MaterialTheme.typography.titleSmall, ) Spacer(modifier = Modifier.height(8.dp)) CapabilityToggle( - label = stringResource(R.string.label_artifacts), - description = stringResource(R.string.artifacts_description), + label = stringResource(Res.string.label_artifacts), + description = stringResource(Res.string.artifacts_description), checked = capabilities.artifacts, onCheckedChange = { onCapabilitiesChanged(capabilities.copy(artifacts = it)) @@ -50,8 +51,8 @@ fun AgentCapabilitiesSection( ) CapabilityToggle( - label = stringResource(R.string.label_end_after_tools), - description = stringResource(R.string.end_after_tools_description), + label = stringResource(Res.string.label_end_after_tools), + description = stringResource(Res.string.end_after_tools_description), checked = capabilities.endAfterTools, onCheckedChange = { onCapabilitiesChanged(capabilities.copy(endAfterTools = it)) @@ -59,8 +60,8 @@ fun AgentCapabilitiesSection( ) CapabilityToggle( - label = stringResource(R.string.label_hide_sequential_outputs), - description = stringResource(R.string.hide_sequential_description), + label = stringResource(Res.string.label_hide_sequential_outputs), + description = stringResource(Res.string.hide_sequential_description), checked = capabilities.hideSequentialOutputs, onCheckedChange = { onCapabilitiesChanged(capabilities.copy(hideSequentialOutputs = it)) @@ -71,11 +72,11 @@ fun AgentCapabilitiesSection( Column(modifier = Modifier.fillMaxWidth()) { Text( - text = stringResource(R.string.label_recursion_limit), + text = stringResource(Res.string.label_recursion_limit), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.recursion_limit_description), + text = stringResource(Res.string.recursion_limit_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCategorySelector.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentCategorySelector.kt similarity index 91% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCategorySelector.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentCategorySelector.kt index 4dee579..3080f52 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCategorySelector.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentCategorySelector.kt @@ -14,9 +14,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import com.librechat.android.core.model.AgentCategory -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* /** * Maps category values to human-readable English labels. @@ -62,7 +63,7 @@ fun AgentCategorySelector( val displayValue = editorCategories.find { it.value == selectedCategory }?.displayLabel() ?: CATEGORY_DISPLAY_LABELS[selectedCategory] - ?: selectedCategory.ifEmpty { stringResource(R.string.general) } + ?: selectedCategory.ifEmpty { stringResource(Res.string.general) } ExposedDropdownMenuBox( expanded = expanded, @@ -73,7 +74,7 @@ fun AgentCategorySelector( value = displayValue, onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.label_category)) }, + label = { Text(stringResource(Res.string.label_category)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCodeInterpreterSection.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentCodeInterpreterSection.kt similarity index 83% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCodeInterpreterSection.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentCodeInterpreterSection.kt index 1fdb0fc..6234735 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentCodeInterpreterSection.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentCodeInterpreterSection.kt @@ -13,9 +13,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* @Composable fun AgentCodeInterpreterSection( @@ -38,11 +39,11 @@ fun AgentCodeInterpreterSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.label_code_interpreter), + text = stringResource(Res.string.label_code_interpreter), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.code_interpreter_description), + text = stringResource(Res.string.code_interpreter_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentFileSearchSection.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentFileSearchSection.kt similarity index 83% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentFileSearchSection.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentFileSearchSection.kt index a947542..66cbf15 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentFileSearchSection.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentFileSearchSection.kt @@ -13,9 +13,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* @Composable fun AgentFileSearchSection( @@ -38,11 +39,11 @@ fun AgentFileSearchSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.label_file_search), + text = stringResource(Res.string.label_file_search), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.file_search_description), + text = stringResource(Res.string.file_search_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentHandoffConfig.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentHandoffConfig.kt similarity index 89% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentHandoffConfig.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentHandoffConfig.kt index 506c79a..fbb0589 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentHandoffConfig.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentHandoffConfig.kt @@ -35,10 +35,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp import com.librechat.android.feature.agents.AgentHandoffDisplayData -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* @Composable fun AgentHandoffConfig( @@ -61,12 +62,12 @@ fun AgentHandoffConfig( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.handoff_agents_count, handoffAgentIds.size), + text = stringResource(Res.string.handoff_agents_count, handoffAgentIds.size), style = MaterialTheme.typography.titleSmall, ) Icon( imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + contentDescription = if (expanded) stringResource(Res.string.cd_collapse) else stringResource(Res.string.cd_expand), ) } @@ -76,14 +77,14 @@ fun AgentHandoffConfig( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = stringResource(R.string.handoff_description), + text = stringResource(Res.string.handoff_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) if (handoffAgentIds.isEmpty()) { Text( - text = stringResource(R.string.no_handoff_agents), + text = stringResource(Res.string.no_handoff_agents), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp), @@ -101,7 +102,7 @@ fun AgentHandoffConfig( IconButton(onClick = { onRemoveHandoff(agentId) }) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_remove_item, displayName), + contentDescription = stringResource(Res.string.cd_remove_item, displayName), ) } }, @@ -117,7 +118,7 @@ fun AgentHandoffConfig( contentDescription = null, ) Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.add_handoff_agent)) + Text(stringResource(Res.string.add_handoff_agent)) } } } @@ -147,12 +148,12 @@ private fun AddHandoffAgentDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.add_handoff_agent)) }, + title = { Text(stringResource(Res.string.add_handoff_agent)) }, text = { Column(modifier = Modifier.fillMaxWidth()) { if (availableAgents.isEmpty()) { Text( - text = stringResource(R.string.no_more_agents), + text = stringResource(Res.string.no_more_agents), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -166,7 +167,7 @@ private fun AddHandoffAgentDialog( value = selectedAgent?.name ?: "", onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.select_agent)) }, + label = { Text(stringResource(Res.string.select_agent)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) }, @@ -197,12 +198,12 @@ private fun AddHandoffAgentDialog( onClick = { selectedAgent?.let { onSelect(it.id) } }, enabled = selectedAgent != null, ) { - Text(stringResource(R.string.add)) + Text(stringResource(Res.string.add)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) + Text(stringResource(Res.string.cancel)) } }, ) diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentMcpToolsSelector.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentMcpToolsSelector.kt similarity index 91% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentMcpToolsSelector.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentMcpToolsSelector.kt index 5cff934..a32de16 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentMcpToolsSelector.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentMcpToolsSelector.kt @@ -21,10 +21,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp import com.librechat.android.core.model.mcp.McpTool -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* /** Hierarchical MCP tool picker grouped by server with checkbox selection. */ @Composable @@ -46,16 +47,16 @@ fun AgentMcpToolsSelector( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.mcp_tools_count, selectedToolNames.size), + text = stringResource(Res.string.mcp_tools_count, selectedToolNames.size), style = MaterialTheme.typography.titleSmall, ) Icon( imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + contentDescription = if (expanded) stringResource(Res.string.cd_collapse) else stringResource(Res.string.cd_expand), ) } - val unknownServerLabel = stringResource(R.string.unknown_server) + val unknownServerLabel = stringResource(Res.string.unknown_server) AnimatedVisibility(visible = expanded) { Column( @@ -64,7 +65,7 @@ fun AgentMcpToolsSelector( ) { if (mcpTools.isEmpty()) { Text( - text = stringResource(R.string.no_mcp_tools), + text = stringResource(Res.string.no_mcp_tools), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp), @@ -114,7 +115,7 @@ private fun McpServerToolsGroup( ) Icon( imageVector = if (serverExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (serverExpanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + contentDescription = if (serverExpanded) stringResource(Res.string.cd_collapse) else stringResource(Res.string.cd_expand), ) } diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentModelPicker.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentModelPicker.kt similarity index 90% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentModelPicker.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentModelPicker.kt index 7727798..aec3dd1 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentModelPicker.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentModelPicker.kt @@ -16,9 +16,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* data class ModelOption( val id: String, @@ -60,8 +61,8 @@ fun AgentModelPicker( OutlinedTextField( value = if (expanded) searchQuery else selectedModel.ifEmpty { "" }, onValueChange = { searchQuery = it }, - label = { Text(stringResource(R.string.model_required_label)) }, - placeholder = { Text(stringResource(R.string.select_model_hint)) }, + label = { Text(stringResource(Res.string.model_required_label)) }, + placeholder = { Text(stringResource(Res.string.select_model_hint)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, @@ -82,7 +83,7 @@ fun AgentModelPicker( DropdownMenuItem( text = { Text( - text = stringResource(R.string.no_models_found), + text = stringResource(Res.string.no_models_found), color = MaterialTheme.colorScheme.onSurfaceVariant, ) }, diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSharingSection.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentSharingSection.kt similarity index 90% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSharingSection.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentSharingSection.kt index f26bba0..718fc25 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSharingSection.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentSharingSection.kt @@ -29,9 +29,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* enum class AgentVisibility(val label: String) { PRIVATE("Private"), @@ -65,12 +66,12 @@ fun AgentSharingSection( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.sharing_and_permissions), + text = stringResource(Res.string.sharing_and_permissions), style = MaterialTheme.typography.titleSmall, ) Icon( imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + contentDescription = if (expanded) stringResource(Res.string.cd_collapse) else stringResource(Res.string.cd_expand), ) } @@ -90,7 +91,7 @@ fun AgentSharingSection( value = sharingState.visibility.label, onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.label_visibility)) }, + label = { Text(stringResource(Res.string.label_visibility)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) }, @@ -126,11 +127,11 @@ fun AgentSharingSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.label_collaborative), + text = stringResource(Res.string.label_collaborative), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.collaborative_description), + text = stringResource(Res.string.collaborative_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSupportContactSection.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentSupportContactSection.kt similarity index 81% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSupportContactSection.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentSupportContactSection.kt index 792068d..9bcde56 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentSupportContactSection.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentSupportContactSection.kt @@ -9,9 +9,10 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* @androidx.compose.runtime.Immutable data class SupportContactState( @@ -27,14 +28,14 @@ fun AgentSupportContactSection( ) { Column(modifier = modifier.fillMaxWidth()) { Text( - text = stringResource(R.string.label_support_contact), + text = stringResource(Res.string.label_support_contact), style = MaterialTheme.typography.titleSmall, ) Spacer(modifier = Modifier.height(8.dp)) // Name field Text( - text = stringResource(R.string.label_name), + text = stringResource(Res.string.label_name), style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.height(4.dp)) @@ -43,7 +44,7 @@ fun AgentSupportContactSection( onValueChange = { onSupportContactChanged(supportContact.copy(name = it)) }, - placeholder = { Text(stringResource(R.string.support_contact_placeholder)) }, + placeholder = { Text(stringResource(Res.string.support_contact_placeholder)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -52,7 +53,7 @@ fun AgentSupportContactSection( // Email field Text( - text = stringResource(R.string.label_email), + text = stringResource(Res.string.label_email), style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.height(4.dp)) diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentVersionHistory.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentVersionHistory.kt similarity index 88% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentVersionHistory.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentVersionHistory.kt index 207e8a9..9e15d32 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/AgentVersionHistory.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/AgentVersionHistory.kt @@ -21,9 +21,10 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* data class AgentVersion( val version: Int, @@ -51,14 +52,14 @@ fun AgentVersionHistory( .navigationBarsPadding(), ) { Text( - text = stringResource(R.string.version_history), + text = stringResource(Res.string.version_history), style = MaterialTheme.typography.titleLarge, ) Spacer(modifier = Modifier.height(16.dp)) if (versions.isEmpty()) { Text( - text = stringResource(R.string.no_version_history), + text = stringResource(Res.string.no_version_history), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -97,7 +98,7 @@ private fun VersionItem( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.version_number, version.version), + text = stringResource(Res.string.version_number, version.version), style = MaterialTheme.typography.bodyLarge, ) val timestamp = version.updatedAt @@ -110,7 +111,7 @@ private fun VersionItem( } if (version.isCurrent) { Text( - text = stringResource(R.string.version_current), + text = stringResource(Res.string.version_current), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, ) @@ -118,7 +119,7 @@ private fun VersionItem( } if (!version.isCurrent) { Button(onClick = onRevert) { - Text(stringResource(R.string.revert)) + Text(stringResource(Res.string.revert)) } } } diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/ToolSelectDialog.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/ToolSelectDialog.kt similarity index 92% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/ToolSelectDialog.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/ToolSelectDialog.kt index a12773d..1f5dbf9 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/components/ToolSelectDialog.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/components/ToolSelectDialog.kt @@ -42,14 +42,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import coil.compose.AsyncImage +import coil3.compose.AsyncImage import com.librechat.android.feature.agents.AgentToolDisplayData -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* /** * Full-screen dialog for selecting agent tools, matching the web app's ToolSelectDialog. @@ -100,11 +101,11 @@ fun ToolSelectDialog( ) { Column { Text( - text = stringResource(R.string.agent_tools_title), + text = stringResource(Res.string.agent_tools_title), style = MaterialTheme.typography.titleLarge, ) Text( - text = stringResource(R.string.select_tools_description), + text = stringResource(Res.string.select_tools_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -112,7 +113,7 @@ fun ToolSelectDialog( IconButton(onClick = onDismiss) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.close), + contentDescription = stringResource(Res.string.close), ) } } @@ -121,7 +122,7 @@ fun ToolSelectDialog( OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, - placeholder = { Text(stringResource(R.string.search_tools_hint)) }, + placeholder = { Text(stringResource(Res.string.search_tools_hint)) }, leadingIcon = { Icon( imageVector = Icons.Default.Search, @@ -146,9 +147,9 @@ fun ToolSelectDialog( ) { Text( text = if (searchQuery.isNotBlank()) { - stringResource(R.string.no_tools_matching, searchQuery) + stringResource(Res.string.no_tools_matching, searchQuery) } else { - stringResource(R.string.no_tools_available) + stringResource(Res.string.no_tools_available) }, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -222,7 +223,7 @@ private fun ToolCard( if (iconUrl != null) { AsyncImage( model = iconUrl, - contentDescription = stringResource(R.string.cd_tool_icon, tool.name ?: ""), + contentDescription = stringResource(Res.string.cd_tool_icon, tool.name ?: ""), modifier = Modifier .size(56.dp) .clip(RoundedCornerShape(8.dp)), @@ -244,7 +245,7 @@ private fun ToolCard( verticalArrangement = Arrangement.SpaceBetween, ) { Text( - text = tool.name ?: tool.toolId ?: stringResource(R.string.unknown_author), + text = tool.name ?: tool.toolId ?: stringResource(Res.string.unknown_author), style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -254,7 +255,7 @@ private fun ToolCard( OutlinedButton( onClick = onToggle, ) { - Text(stringResource(R.string.remove)) + Text(stringResource(Res.string.remove)) Spacer(modifier = Modifier.width(4.dp)) Icon( imageVector = Icons.Default.Remove, @@ -266,7 +267,7 @@ private fun ToolCard( FilledTonalButton( onClick = onToggle, ) { - Text(stringResource(R.string.add)) + Text(stringResource(Res.string.add)) Spacer(modifier = Modifier.width(4.dp)) Icon( imageVector = Icons.Default.Add, diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/di/AgentsModule.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/di/AgentsModule.kt similarity index 83% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/di/AgentsModule.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/di/AgentsModule.kt index 021b345..df1a605 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/di/AgentsModule.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/di/AgentsModule.kt @@ -3,12 +3,16 @@ 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.Module import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module +expect val agentsPlatformModule: Module + val agentsModule = module { + includes(agentsPlatformModule) + viewModelOf(::AgentMarketplaceViewModel) viewModelOf(::AgentDetailViewModel) viewModel { @@ -17,7 +21,7 @@ val agentsModule = module { agentRepository = get(), configRepository = get(), mcpRepository = get(), - appContext = androidContext(), + contentReader = get(), ) } } diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/navigation/AgentsNavigation.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/navigation/AgentsNavigation.kt similarity index 57% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/navigation/AgentsNavigation.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/navigation/AgentsNavigation.kt index 46498d6..e56281f 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/navigation/AgentsNavigation.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/navigation/AgentsNavigation.kt @@ -4,7 +4,6 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument -import com.librechat.android.core.ui.components.ScreenTransitionWrapper import com.librechat.android.feature.agents.screen.AgentDetailScreen import com.librechat.android.feature.agents.screen.AgentEditorScreen import com.librechat.android.feature.agents.screen.AgentMarketplaceScreen @@ -22,12 +21,11 @@ fun NavGraphBuilder.agentsGraph( onEditAgent: (String) -> Unit, ) { composable(AGENTS_ROUTE) { - ScreenTransitionWrapper(transition) { - AgentMarketplaceScreen( - onAgentClick = onAgentClick, - onCreateAgent = onCreateAgent, - ) - } + AgentMarketplaceScreen( + onAgentClick = onAgentClick, + onCreateAgent = onCreateAgent, + onBack = onBack, + ) } composable( route = AGENT_DETAIL_ROUTE, @@ -35,22 +33,18 @@ fun NavGraphBuilder.agentsGraph( navArgument("agentId") { type = NavType.StringType }, ), ) { - ScreenTransitionWrapper(transition) { - AgentDetailScreen( - onBack = onBack, - onStartChat = onStartChat, - onEdit = onEditAgent, - onDuplicated = onAgentClick, - ) - } + AgentDetailScreen( + onBack = onBack, + onStartChat = onStartChat, + onEdit = onEditAgent, + onDuplicated = onAgentClick, + ) } composable(AGENT_EDITOR_CREATE_ROUTE) { - ScreenTransitionWrapper(transition) { - AgentEditorScreen( - onBack = onBack, - onSaved = { agentId -> onAgentClick(agentId) }, - ) - } + AgentEditorScreen( + onBack = onBack, + onSaved = { agentId -> onAgentClick(agentId) }, + ) } composable( route = AGENT_EDITOR_EDIT_ROUTE, @@ -58,11 +52,9 @@ fun NavGraphBuilder.agentsGraph( navArgument("agentId") { type = NavType.StringType }, ), ) { - ScreenTransitionWrapper(transition) { - AgentEditorScreen( - onBack = onBack, - onSaved = { agentId -> onAgentClick(agentId) }, - ) - } + AgentEditorScreen( + onBack = onBack, + onSaved = { agentId -> onAgentClick(agentId) }, + ) } } diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentDetailScreen.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/screen/AgentDetailScreen.kt similarity index 88% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentDetailScreen.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/screen/AgentDetailScreen.kt index 925c0e7..4891361 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentDetailScreen.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/screen/AgentDetailScreen.kt @@ -42,19 +42,21 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.librechat.android.core.ui.components.AvatarImage +import com.librechat.android.core.ui.components.endpointIconPainter import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.LoadingIndicator -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* import com.librechat.android.feature.agents.viewmodel.AgentDetailEvent import com.librechat.android.feature.agents.viewmodel.AgentDetailViewModel import org.koin.compose.viewmodel.koinViewModel -import com.librechat.android.core.ui.R as CoreUiR + @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -81,9 +83,9 @@ fun AgentDetailScreen( if (uiState.showDeleteDialog) { AlertDialog( onDismissRequest = { viewModel.dismissDeleteConfirmation() }, - title = { Text(stringResource(R.string.delete_agent)) }, + title = { Text(stringResource(Res.string.delete_agent)) }, text = { - Text(stringResource(R.string.delete_agent_confirm, uiState.agent?.name ?: stringResource(R.string.delete_this_agent))) + Text(stringResource(Res.string.delete_agent_confirm, uiState.agent?.name ?: stringResource(Res.string.delete_this_agent))) }, confirmButton = { TextButton( @@ -92,12 +94,12 @@ fun AgentDetailScreen( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(R.string.delete)) + Text(stringResource(Res.string.delete)) } }, dismissButton = { TextButton(onClick = { viewModel.dismissDeleteConfirmation() }) { - Text(stringResource(R.string.cancel)) + Text(stringResource(Res.string.cancel)) } }, ) @@ -107,12 +109,12 @@ fun AgentDetailScreen( modifier = modifier, topBar = { TopAppBar( - title = { Text(uiState.agent?.name ?: stringResource(R.string.agent_title)) }, + title = { Text(uiState.agent?.name ?: stringResource(Res.string.agent_title)) }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -122,7 +124,7 @@ fun AgentDetailScreen( IconButton(onClick = { menuExpanded = true }) { Icon( imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.cd_more_actions), + contentDescription = stringResource(Res.string.cd_more_actions), ) } DropdownMenu( @@ -130,7 +132,7 @@ fun AgentDetailScreen( onDismissRequest = { menuExpanded = false }, ) { DropdownMenuItem( - text = { Text(stringResource(R.string.edit)) }, + text = { Text(stringResource(Res.string.edit)) }, onClick = { menuExpanded = false uiState.agent?.let { onEdit(it.id) } @@ -143,7 +145,7 @@ fun AgentDetailScreen( }, ) DropdownMenuItem( - text = { Text(stringResource(R.string.duplicate)) }, + text = { Text(stringResource(Res.string.duplicate)) }, onClick = { menuExpanded = false viewModel.duplicateAgent() @@ -159,7 +161,7 @@ fun AgentDetailScreen( DropdownMenuItem( text = { Text( - stringResource(R.string.delete), + stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error, ) }, @@ -192,7 +194,7 @@ fun AgentDetailScreen( uiState.error != null -> { ErrorBanner( - message = uiState.error ?: stringResource(R.string.error_unknown), + message = uiState.error ?: stringResource(Res.string.error_unknown), modifier = Modifier.padding(innerPadding), onRetry = { viewModel.loadAgent() }, ) @@ -212,7 +214,7 @@ fun AgentDetailScreen( imageUrl = agent.avatarUrl, size = 80.dp, fallbackText = agent.name, - fallbackIconRes = CoreUiR.drawable.ic_agents, + fallbackIconPainter = endpointIconPainter(com.librechat.android.core.model.EModelEndpoint.AGENTS), tintIcon = true, ) @@ -228,9 +230,9 @@ fun AgentDetailScreen( Text( text = stringResource( - R.string.by_author, + Res.string.by_author, agent.authorName ?: agent.author - ?: stringResource(R.string.unknown_author), + ?: stringResource(Res.string.unknown_author), ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -247,7 +249,7 @@ fun AgentDetailScreen( contentDescription = null, modifier = Modifier.padding(end = 8.dp), ) - Text(stringResource(R.string.start_chat)) + Text(stringResource(Res.string.start_chat)) } Spacer(modifier = Modifier.height(24.dp)) @@ -256,9 +258,9 @@ fun AgentDetailScreen( // Description section AgentDetailSection( - label = stringResource(R.string.label_description), + label = stringResource(Res.string.label_description), content = agent.description?.takeIf { it.isNotBlank() } - ?: stringResource(R.string.no_description), + ?: stringResource(Res.string.no_description), dimContent = agent.description.isNullOrBlank(), ) HorizontalDivider() @@ -267,7 +269,7 @@ fun AgentDetailScreen( val category = agent.category if (!category.isNullOrBlank() && category != "general") { AgentDetailSection( - label = stringResource(R.string.label_category), + label = stringResource(Res.string.label_category), content = category.replaceFirstChar { it.uppercase() }, ) HorizontalDivider() @@ -275,8 +277,8 @@ fun AgentDetailScreen( // Model section AgentDetailSection( - label = stringResource(R.string.label_model), - content = agent.model ?: stringResource(R.string.default_value), + label = stringResource(Res.string.label_model), + content = agent.model ?: stringResource(Res.string.default_value), ) HorizontalDivider() @@ -285,7 +287,7 @@ fun AgentDetailScreen( if (!tools.isNullOrEmpty()) { Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(R.string.label_tools), + text = stringResource(Res.string.label_tools), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth(), @@ -311,7 +313,7 @@ fun AgentDetailScreen( if (agent.conversationStarters.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(R.string.label_conversation_starters), + text = stringResource(Res.string.label_conversation_starters), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth(), diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentEditorScreen.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/screen/AgentEditorScreen.kt similarity index 90% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentEditorScreen.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/screen/AgentEditorScreen.kt index 6172ba2..8b97009 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentEditorScreen.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/screen/AgentEditorScreen.kt @@ -48,12 +48,13 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.LoadingIndicator -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* import com.librechat.android.feature.agents.components.AgentActionsPanel import com.librechat.android.feature.agents.components.AgentAdvancedPanel import com.librechat.android.feature.agents.components.AgentAvatarPicker @@ -98,16 +99,16 @@ fun AgentEditorScreen( if (uiState.showDeleteConfirm) { AlertDialog( onDismissRequest = viewModel::dismissDeleteConfirmation, - title = { Text(stringResource(R.string.delete_agent)) }, - text = { Text(stringResource(R.string.delete_agent_editor_confirm)) }, + title = { Text(stringResource(Res.string.delete_agent)) }, + text = { Text(stringResource(Res.string.delete_agent_editor_confirm)) }, confirmButton = { TextButton(onClick = viewModel::delete) { - Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + Text(stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = viewModel::dismissDeleteConfirmation) { - Text(stringResource(R.string.cancel)) + Text(stringResource(Res.string.cancel)) } }, ) @@ -117,16 +118,16 @@ fun AgentEditorScreen( if (uiState.showDuplicateConfirm) { AlertDialog( onDismissRequest = viewModel::dismissDuplicateConfirmation, - title = { Text(stringResource(R.string.duplicate_agent)) }, - text = { Text(stringResource(R.string.duplicate_agent_confirm)) }, + title = { Text(stringResource(Res.string.duplicate_agent)) }, + text = { Text(stringResource(Res.string.duplicate_agent_confirm)) }, confirmButton = { TextButton(onClick = viewModel::duplicate) { - Text(stringResource(R.string.duplicate)) + Text(stringResource(Res.string.duplicate)) } }, dismissButton = { TextButton(onClick = viewModel::dismissDuplicateConfirmation) { - Text(stringResource(R.string.cancel)) + Text(stringResource(Res.string.cancel)) } }, ) @@ -157,13 +158,13 @@ fun AgentEditorScreen( topBar = { TopAppBar( title = { - Text(if (uiState.isEditMode) stringResource(R.string.edit_agent) else stringResource(R.string.create_agent)) + Text(if (uiState.isEditMode) stringResource(Res.string.edit_agent) else stringResource(Res.string.create_agent)) }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -173,7 +174,7 @@ fun AgentEditorScreen( IconButton(onClick = { menuExpanded = true }) { Icon( imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.cd_more_options), + contentDescription = stringResource(Res.string.cd_more_options), ) } DropdownMenu( @@ -181,7 +182,7 @@ fun AgentEditorScreen( onDismissRequest = { menuExpanded = false }, ) { DropdownMenuItem( - text = { Text(stringResource(R.string.duplicate)) }, + text = { Text(stringResource(Res.string.duplicate)) }, onClick = { menuExpanded = false viewModel.showDuplicateConfirmation() @@ -194,7 +195,7 @@ fun AgentEditorScreen( }, ) DropdownMenuItem( - text = { Text(stringResource(R.string.version_history)) }, + text = { Text(stringResource(Res.string.version_history)) }, onClick = { menuExpanded = false viewModel.showVersionHistory() @@ -210,7 +211,7 @@ fun AgentEditorScreen( DropdownMenuItem( text = { Text( - stringResource(R.string.delete), + stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error, ) }, @@ -250,7 +251,7 @@ fun AgentEditorScreen( ) { if (uiState.error != null) { ErrorBanner( - message = uiState.error ?: stringResource(R.string.error_unknown), + message = uiState.error ?: stringResource(Res.string.error_unknown), onRetry = { viewModel.dismissError() }, ) Spacer(modifier = Modifier.height(8.dp)) @@ -284,7 +285,7 @@ fun AgentEditorScreen( ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = stringResource(R.string.tap_to_change_avatar), + text = stringResource(Res.string.tap_to_change_avatar), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -297,9 +298,9 @@ fun AgentEditorScreen( value = uiState.name, onValueChange = viewModel::onNameChanged, label = { - Text(stringResource(R.string.agent_name_label)) + Text(stringResource(Res.string.agent_name_label)) }, - placeholder = { Text(stringResource(R.string.agent_name_placeholder)) }, + placeholder = { Text(stringResource(Res.string.agent_name_placeholder)) }, isError = uiState.nameError != null, supportingText = uiState.nameError?.let { error -> { Text(error) } @@ -314,8 +315,8 @@ fun AgentEditorScreen( OutlinedTextField( value = uiState.description, onValueChange = viewModel::onDescriptionChanged, - label = { Text(stringResource(R.string.agent_description_label)) }, - placeholder = { Text(stringResource(R.string.agent_description_placeholder)) }, + label = { Text(stringResource(Res.string.agent_description_label)) }, + placeholder = { Text(stringResource(Res.string.agent_description_placeholder)) }, minLines = 2, maxLines = 4, modifier = Modifier.fillMaxWidth(), @@ -337,8 +338,8 @@ fun AgentEditorScreen( OutlinedTextField( value = uiState.instructions, onValueChange = viewModel::onInstructionsChanged, - label = { Text(stringResource(R.string.agent_instructions_label)) }, - placeholder = { Text(stringResource(R.string.agent_instructions_placeholder)) }, + label = { Text(stringResource(Res.string.agent_instructions_label)) }, + placeholder = { Text(stringResource(Res.string.agent_instructions_placeholder)) }, minLines = 4, maxLines = 10, modifier = Modifier.fillMaxWidth(), @@ -358,7 +359,7 @@ fun AgentEditorScreen( // 7. Capabilities section Text( - text = stringResource(R.string.label_capabilities), + text = stringResource(Res.string.label_capabilities), style = MaterialTheme.typography.titleSmall, ) Spacer(modifier = Modifier.height(8.dp)) @@ -394,7 +395,7 @@ fun AgentEditorScreen( // 9. Tools & Actions section Text( - text = stringResource(R.string.label_tools_and_actions), + text = stringResource(Res.string.label_tools_and_actions), style = MaterialTheme.typography.titleSmall, ) Spacer(modifier = Modifier.height(8.dp)) @@ -425,13 +426,13 @@ fun AgentEditorScreen( val actionId = action.actionId if (actionId != null) { val authLabel = when (action.authType) { - "service_http" -> stringResource(R.string.auth_api_key) - "oauth" -> stringResource(R.string.auth_oauth) - else -> stringResource(R.string.auth_none) + "service_http" -> stringResource(Res.string.auth_api_key) + "oauth" -> stringResource(Res.string.auth_oauth) + else -> stringResource(Res.string.auth_none) } SelectedToolRow( - toolName = action.domain ?: stringResource(R.string.label_action), - toolDescription = stringResource(R.string.action_auth_info, authLabel, action.functionCount), + toolName = action.domain ?: stringResource(Res.string.label_action), + toolDescription = stringResource(Res.string.action_auth_info, authLabel, action.functionCount), onRemove = { viewModel.deleteAction(actionId) }, ) } @@ -451,7 +452,7 @@ fun AgentEditorScreen( modifier = Modifier.size(16.dp), ) Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.add_tools)) + Text(stringResource(Res.string.add_tools)) } } @@ -476,7 +477,7 @@ fun AgentEditorScreen( // 11. Conversation starters Text( - text = stringResource(R.string.label_conversation_starters), + text = stringResource(Res.string.label_conversation_starters), style = MaterialTheme.typography.titleSmall, ) Spacer(modifier = Modifier.height(8.dp)) @@ -492,7 +493,7 @@ fun AgentEditorScreen( ) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.remove), + contentDescription = stringResource(Res.string.remove), ) } }, @@ -506,7 +507,7 @@ fun AgentEditorScreen( OutlinedTextField( value = newStarter, onValueChange = { newStarter = it }, - placeholder = { Text(stringResource(R.string.add_starter_hint)) }, + placeholder = { Text(stringResource(Res.string.add_starter_hint)) }, singleLine = true, modifier = Modifier.weight(1f), ) @@ -520,7 +521,7 @@ fun AgentEditorScreen( ) { Icon( imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.cd_add_starter), + contentDescription = stringResource(Res.string.cd_add_starter), ) } } @@ -569,7 +570,7 @@ fun AgentEditorScreen( Spacer(modifier = Modifier.width(8.dp)) } Text( - if (uiState.isEditMode) stringResource(R.string.save_changes) else stringResource(R.string.create_agent), + if (uiState.isEditMode) stringResource(Res.string.save_changes) else stringResource(Res.string.create_agent), ) } @@ -614,7 +615,7 @@ private fun SelectedToolRow( IconButton(onClick = onRemove) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_remove_item, toolName), + contentDescription = stringResource(Res.string.cd_remove_item, toolName), modifier = Modifier.size(18.dp), ) } diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentMarketplaceScreen.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/screen/AgentMarketplaceScreen.kt similarity index 90% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentMarketplaceScreen.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/screen/AgentMarketplaceScreen.kt index ea11330..fbd5883 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/screen/AgentMarketplaceScreen.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/screen/AgentMarketplaceScreen.kt @@ -41,20 +41,22 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.librechat.android.core.ui.components.AvatarImage +import com.librechat.android.core.ui.components.endpointIconPainter import com.librechat.android.core.ui.components.EmptyState import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.LibreChatTopBar import com.librechat.android.core.ui.components.LoadingIndicator import com.librechat.android.feature.agents.AgentCardDisplayData -import com.librechat.android.feature.agents.R +import librechat_android.feature.agents.generated.resources.Res +import librechat_android.feature.agents.generated.resources.* import com.librechat.android.feature.agents.viewmodel.AgentMarketplaceViewModel import org.koin.compose.viewmodel.koinViewModel -import com.librechat.android.core.ui.R as CoreUiR + @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -62,6 +64,7 @@ fun AgentMarketplaceScreen( onAgentClick: (String) -> Unit, onCreateAgent: () -> Unit, modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, viewModel: AgentMarketplaceViewModel = koinViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -88,7 +91,10 @@ fun AgentMarketplaceScreen( Scaffold( modifier = modifier, topBar = { - LibreChatTopBar(title = stringResource(R.string.agents)) + LibreChatTopBar( + title = stringResource(Res.string.agents), + onNavigateBack = onBack, + ) }, floatingActionButton = { FloatingActionButton( @@ -96,7 +102,7 @@ fun AgentMarketplaceScreen( ) { Icon( imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.cd_create_agent), + contentDescription = stringResource(Res.string.cd_create_agent), ) } }, @@ -116,11 +122,11 @@ fun AgentMarketplaceScreen( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), - placeholder = { Text(stringResource(R.string.search_agents_hint)) }, + placeholder = { Text(stringResource(Res.string.search_agents_hint)) }, leadingIcon = { Icon( imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.cd_search), + contentDescription = stringResource(Res.string.cd_search), ) }, singleLine = true, @@ -155,14 +161,14 @@ fun AgentMarketplaceScreen( !uiState.isLoading && uiState.filteredAgents.isEmpty() && uiState.error == null -> { EmptyState( title = if (uiState.searchQuery.isNotBlank() || uiState.selectedCategory != null) { - stringResource(R.string.no_agents_found) + stringResource(Res.string.no_agents_found) } else { - stringResource(R.string.no_agents_available) + stringResource(Res.string.no_agents_available) }, description = if (uiState.searchQuery.isNotBlank()) { - stringResource(R.string.try_different_search) + stringResource(Res.string.try_different_search) } else { - stringResource(R.string.check_back_later) + stringResource(Res.string.check_back_later) }, icon = Icons.Default.SmartToy, ) @@ -171,7 +177,7 @@ fun AgentMarketplaceScreen( else -> { if (uiState.error != null) { ErrorBanner( - message = uiState.error ?: stringResource(R.string.error_unknown), + message = uiState.error ?: stringResource(Res.string.error_unknown), onRetry = { viewModel.dismissError() viewModel.loadAgents() @@ -248,7 +254,7 @@ private fun AgentCard( imageUrl = agent.avatarUrl, size = 40.dp, fallbackText = agent.name, - fallbackIconRes = CoreUiR.drawable.ic_agents, + fallbackIconPainter = endpointIconPainter(com.librechat.android.core.model.EModelEndpoint.AGENTS), tintIcon = true, ) } @@ -272,7 +278,7 @@ private fun AgentCard( } Spacer(modifier = Modifier.height(4.dp)) Text( - text = agent.authorName ?: agent.author ?: stringResource(R.string.unknown_author), + text = agent.authorName ?: agent.author ?: stringResource(Res.string.unknown_author), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, maxLines = 1, diff --git a/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/util/ContentReader.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/util/ContentReader.kt new file mode 100644 index 0000000..19fdc46 --- /dev/null +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/util/ContentReader.kt @@ -0,0 +1,10 @@ +package com.librechat.android.feature.agents.util + +/** + * Reads bytes and MIME type from a platform-specific content URI. + * On Android, wraps ContentResolver. On iOS, stubs (avatar picker is Android-only). + */ +interface ContentReader { + fun readBytes(uri: Any): ByteArray? + fun getMimeType(uri: Any): String? +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/util/OpenApiSpecParser.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/util/OpenApiSpecParser.kt similarity index 85% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/util/OpenApiSpecParser.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/util/OpenApiSpecParser.kt index a732a3b..9268c5c 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/util/OpenApiSpecParser.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/util/OpenApiSpecParser.kt @@ -11,8 +11,6 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import org.snakeyaml.engine.v2.api.Load -import org.snakeyaml.engine.v2.api.LoadSettings /** * Result of parsing an OpenAPI spec. @@ -345,23 +343,10 @@ object OpenApiSpecParser { jsonError = e } - // Fall back to YAML (using snakeyaml-engine which is Android-compatible) + // Fall back to YAML parsing (pure Kotlin, cross-platform) try { - val settings = LoadSettings.builder().build() - val loader = Load(settings) - val parsed = loader.loadFromString(spec) - ?: throw IllegalArgumentException("YAML document is empty") - - if (parsed !is Map<*, *>) { - throw IllegalArgumentException( - "Expected a YAML mapping (object) at the root, but got ${parsed::class.simpleName}", - ) - } - - val element = yamlToJsonElement(parsed) - return element.jsonObject + return parseYamlToJsonObject(spec) } catch (yamlError: Exception) { - // Both JSON and YAML parsing failed — provide a helpful combined message throw IllegalArgumentException( "Not valid JSON or YAML.\n" + "JSON error: ${jsonError.message}\n" + @@ -370,43 +355,4 @@ object OpenApiSpecParser { ) } } - - /** - * Recursively converts a SnakeYAML-parsed object (Map, List, or scalar) to a - * kotlinx.serialization [JsonElement]. - * - * SnakeYAML parses YAML into standard Java types: - * - Maps → [JsonObject] - * - Lists → [JsonArray] - * - Strings → [JsonPrimitive] (string) - * - Numbers (Int, Long, Float, Double, BigInteger) → [JsonPrimitive] (number) - * - Booleans → [JsonPrimitive] (boolean) - * - null → [JsonNull] - */ - @Suppress("CyclomaticComplexity") - private fun yamlToJsonElement(value: Any?): JsonElement { - return when (value) { - null -> JsonNull - is Map<*, *> -> { - val entries = value.entries.associate { (k, v) -> - (k?.toString() ?: "null") to yamlToJsonElement(v) - } - JsonObject(entries) - } - is List<*> -> { - JsonArray(value.map { yamlToJsonElement(it) }) - } - is Boolean -> JsonPrimitive(value) - is Int -> JsonPrimitive(value) - is Long -> JsonPrimitive(value) - is Float -> JsonPrimitive(value) - is Double -> JsonPrimitive(value) - is Number -> JsonPrimitive(value.toLong()) - is String -> JsonPrimitive(value) - else -> { - // Fallback: convert unknown types to their string representation - JsonPrimitive(value.toString()) - } - } - } } diff --git a/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/util/YamlParser.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/util/YamlParser.kt new file mode 100644 index 0000000..1ea6dd9 --- /dev/null +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/util/YamlParser.kt @@ -0,0 +1,394 @@ +package com.librechat.android.feature.agents.util + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Parses a YAML string into a [JsonObject]. + * Pure Kotlin implementation — no external YAML library required. + * Handles the YAML subset used by OpenAPI specs: maps, lists, scalars, and nested structures. + */ +fun parseYamlToJsonObject(yaml: String): JsonObject { + val parser = YamlParser(yaml) + val element = parser.parseDocument() + return element as? JsonObject + ?: throw IllegalArgumentException( + "Expected a YAML mapping (object) at the root, but got ${element::class.simpleName}", + ) +} + +private class YamlParser(input: String) { + private val lines: List = input.lines() + private var pos: Int = 0 + + fun parseDocument(): JsonElement { + skipBlanksAndComments() + if (pos >= lines.size) return JsonObject(emptyMap()) + return parseValue(currentIndent()) + } + + private fun parseValue(baseIndent: Int): JsonElement { + if (pos >= lines.size) return JsonNull + val line = lines[pos] + val trimmed = line.trimStart() + + // List item at current level + if (trimmed.startsWith("- ") || trimmed == "-") { + return parseList(baseIndent) + } + + // Map entry (key: value) + if (trimmed.contains(':')) { + return parseMap(baseIndent) + } + + // Bare scalar + pos++ + return scalarToJsonPrimitive(trimmed) + } + + private fun parseMap(baseIndent: Int): JsonObject { + val entries = mutableMapOf() + while (pos < lines.size) { + skipBlanksAndComments() + if (pos >= lines.size) break + val indent = currentIndent() + if (indent < baseIndent) break + if (indent > baseIndent) break // belongs to parent + + val line = lines[pos] + val trimmed = line.trimStart() + + // Must be a map entry, not a list item + if (trimmed.startsWith("- ")) break + + val colonIdx = findKeyColonIndex(trimmed) + if (colonIdx < 0) break + + val key = trimmed.substring(0, colonIdx).trim().unquoteYaml() + val afterColon = trimmed.substring(colonIdx + 1).trim() + + pos++ + + val value: JsonElement = when { + // Block scalar indicators + afterColon == "|" || afterColon == ">" || afterColon == "|-" || afterColon == ">-" -> { + parseBlockScalar(baseIndent, fold = afterColon.startsWith(">"), strip = afterColon.endsWith("-")) + } + // Inline value present + afterColon.isNotEmpty() -> { + parseInlineValue(afterColon) + } + // Value on next line(s) — nested map, list, or multiline + else -> { + skipBlanksAndComments() + if (pos < lines.size && currentIndent() > baseIndent) { + parseValue(currentIndent()) + } else { + JsonNull + } + } + } + entries[key] = value + } + return JsonObject(entries) + } + + private fun parseList(baseIndent: Int): JsonArray { + val items = mutableListOf() + while (pos < lines.size) { + skipBlanksAndComments() + if (pos >= lines.size) break + val indent = currentIndent() + if (indent != baseIndent) break + + val trimmed = lines[pos].trimStart() + if (!trimmed.startsWith("- ") && trimmed != "-") break + + val afterDash = if (trimmed == "-") "" else trimmed.substring(2).trim() + pos++ + + val item: JsonElement = when { + afterDash.isEmpty() -> { + // Value on next line(s) + skipBlanksAndComments() + if (pos < lines.size && currentIndent() > baseIndent) { + parseValue(currentIndent()) + } else { + JsonNull + } + } + afterDash.startsWith("{") || afterDash.startsWith("[") -> { + parseInlineValue(afterDash) + } + afterDash.contains(":") && !isQuotedScalar(afterDash) -> { + // Inline map entry after dash, e.g. "- key: value" + // Rewind and parse as nested map at dash+2 indent + pos-- + val dashIndent = currentIndent() + // Replace "- " with " " conceptually by parsing the content after dash + parseListItemMap(dashIndent) + } + else -> { + scalarToJsonPrimitive(afterDash.unquoteYaml()) + } + } + items.add(item) + } + return JsonArray(items) + } + + /** + * Parses a map that starts inline after a list dash: `- key: value` + */ + private fun parseListItemMap(dashIndent: Int): JsonObject { + val entries = mutableMapOf() + val contentIndent = dashIndent + 2 // content after "- " + + // First line: "- key: value" + val firstLine = lines[pos] + val trimmed = firstLine.trimStart() + val afterDash = trimmed.substring(2).trim() + val colonIdx = findKeyColonIndex(afterDash) + if (colonIdx >= 0) { + val key = afterDash.substring(0, colonIdx).trim().unquoteYaml() + val afterColon = afterDash.substring(colonIdx + 1).trim() + pos++ + val value = if (afterColon.isNotEmpty()) { + parseInlineValue(afterColon) + } else { + skipBlanksAndComments() + if (pos < lines.size && currentIndent() > dashIndent) { + parseValue(currentIndent()) + } else { + JsonNull + } + } + entries[key] = value + } else { + pos++ + } + + // Continuation lines at contentIndent + while (pos < lines.size) { + skipBlanksAndComments() + if (pos >= lines.size) break + val indent = currentIndent() + if (indent < contentIndent) break + + val line = lines[pos].trimStart() + if (line.startsWith("- ")) break // next list item + + val ci = findKeyColonIndex(line) + if (ci < 0) break + + val key = line.substring(0, ci).trim().unquoteYaml() + val afterColon = line.substring(ci + 1).trim() + pos++ + val value = if (afterColon.isNotEmpty()) { + parseInlineValue(afterColon) + } else { + skipBlanksAndComments() + if (pos < lines.size && currentIndent() > indent) { + parseValue(currentIndent()) + } else { + JsonNull + } + } + entries[key] = value + } + + return JsonObject(entries) + } + + private fun parseBlockScalar(baseIndent: Int, fold: Boolean, strip: Boolean): JsonPrimitive { + val lines = mutableListOf() + val contentIndent = if (pos < this.lines.size) { + // Detect indent from first content line + skipBlanksAndComments() + if (pos < this.lines.size) currentIndent() else baseIndent + 2 + } else { + baseIndent + 2 + } + + while (pos < this.lines.size) { + val line = this.lines[pos] + if (line.isBlank()) { + lines.add("") + pos++ + continue + } + val indent = line.length - line.trimStart().length + if (indent < contentIndent) break + lines.add(line.substring(contentIndent)) + pos++ + } + + // Remove trailing blank lines if strip mode + if (strip) { + while (lines.isNotEmpty() && lines.last().isEmpty()) { + lines.removeAt(lines.lastIndex) + } + } + + val text = if (fold) { + lines.joinToString(" ").replace(" ", "\n") + } else { + lines.joinToString("\n") + } + + return JsonPrimitive(if (strip) text else text.trimEnd('\n') + "\n") + } + + private fun parseInlineValue(value: String): JsonElement { + if (value.startsWith("{")) return parseInlineFlowMap(value) + if (value.startsWith("[")) return parseInlineFlowList(value) + return scalarToJsonPrimitive(value.unquoteYaml()) + } + + private fun parseInlineFlowMap(text: String): JsonObject { + val inner = text.removeSurrounding("{", "}").trim() + if (inner.isEmpty()) return JsonObject(emptyMap()) + val entries = mutableMapOf() + for (part in splitFlowItems(inner)) { + val ci = part.indexOf(':') + if (ci >= 0) { + val key = part.substring(0, ci).trim().unquoteYaml() + val v = part.substring(ci + 1).trim() + entries[key] = parseInlineValue(v.unquoteYaml().let { if (it != v) it else v }) + } + } + return JsonObject(entries) + } + + private fun parseInlineFlowList(text: String): JsonArray { + val inner = text.removeSurrounding("[", "]").trim() + if (inner.isEmpty()) return JsonArray(emptyList()) + return JsonArray(splitFlowItems(inner).map { parseInlineValue(it.trim()) }) + } + + private fun splitFlowItems(text: String): List { + val items = mutableListOf() + var depth = 0 + var current = StringBuilder() + var inQuote = false + var quoteChar = ' ' + for (ch in text) { + when { + inQuote -> { + current.append(ch) + if (ch == quoteChar) inQuote = false + } + ch == '"' || ch == '\'' -> { + current.append(ch) + inQuote = true + quoteChar = ch + } + ch == '{' || ch == '[' -> { + depth++ + current.append(ch) + } + ch == '}' || ch == ']' -> { + depth-- + current.append(ch) + } + ch == ',' && depth == 0 -> { + items.add(current.toString()) + current = StringBuilder() + } + else -> current.append(ch) + } + } + if (current.isNotEmpty()) items.add(current.toString()) + return items + } + + private fun skipBlanksAndComments() { + while (pos < lines.size) { + val line = lines[pos] + val trimmed = line.trimStart() + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + pos++ + } else if (trimmed.startsWith("---") || trimmed.startsWith("...")) { + pos++ // skip document markers + } else { + break + } + } + } + + private fun currentIndent(): Int { + if (pos >= lines.size) return 0 + val line = lines[pos] + return line.length - line.trimStart().length + } + + /** + * Finds the colon that separates key from value, ignoring colons inside quoted strings + * and colons in URLs (e.g., "http://"). + */ + private fun findKeyColonIndex(text: String): Int { + var inQuote = false + var quoteChar = ' ' + for (i in text.indices) { + val ch = text[i] + when { + inQuote -> { + if (ch == quoteChar) inQuote = false + } + ch == '"' || ch == '\'' -> { + inQuote = true + quoteChar = ch + } + ch == ':' -> { + // Key colon must be followed by space, end-of-string, or newline + if (i + 1 >= text.length || text[i + 1] == ' ') { + return i + } + } + } + } + return -1 + } + + private fun isQuotedScalar(text: String): Boolean { + return (text.startsWith("\"") && text.endsWith("\"")) || + (text.startsWith("'") && text.endsWith("'")) + } +} + +private fun scalarToJsonPrimitive(content: String): JsonElement { + // Null + if (content == "null" || content == "~") return JsonNull + + // Boolean + if (content.equals("true", ignoreCase = true) || content.equals("false", ignoreCase = true)) { + return JsonPrimitive(content.toBoolean()) + } + + // Empty string treated as a string primitive + if (content.isEmpty()) { + return JsonPrimitive(content) + } + + // Integer + content.toLongOrNull()?.let { return JsonPrimitive(it) } + + // Floating point + content.toDoubleOrNull()?.let { return JsonPrimitive(it) } + + // String + return JsonPrimitive(content) +} + +private fun String.unquoteYaml(): String { + if (length >= 2) { + if ((startsWith("\"") && endsWith("\"")) || (startsWith("'") && endsWith("'"))) { + return substring(1, length - 1) + } + } + return this +} diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentDetailViewModel.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/viewmodel/AgentDetailViewModel.kt similarity index 100% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentDetailViewModel.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/viewmodel/AgentDetailViewModel.kt diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt similarity index 92% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt index 45aaa04..183e81f 100644 --- a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt +++ b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/viewmodel/AgentEditorViewModel.kt @@ -1,11 +1,10 @@ package com.librechat.android.feature.agents.viewmodel -import android.content.Context -import android.net.Uri import androidx.compose.runtime.Immutable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.librechat.android.feature.agents.util.ContentReader import com.librechat.android.core.common.result.Result import com.librechat.android.core.data.repository.AgentRepository import com.librechat.android.core.data.repository.ConfigRepository @@ -46,7 +45,7 @@ import kotlinx.serialization.json.floatOrNull import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import timber.log.Timber +import co.touchlab.kermit.Logger @Immutable data class AgentEditorUiState( @@ -107,7 +106,7 @@ class AgentEditorViewModel( private val agentRepository: AgentRepository, private val configRepository: ConfigRepository, private val mcpRepository: McpRepository, - private val appContext: Context, + private val contentReader: ContentReader, ) : ViewModel() { private val editAgentId: String? = savedStateHandle["agentId"] @@ -159,7 +158,7 @@ class AgentEditorViewModel( private fun loadAgent(agentId: String) { viewModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true, error = null) - Timber.d("AgentEditor: Loading agent for editing: %s", agentId) + Logger.d { "AgentEditor: Loading agent for editing: $agentId" } // Use getAgentForEditing which calls the /expanded endpoint. // The standard getAgent endpoint (GET /api/agents/:id) only returns // basic view-only fields (id, name, description, avatar, model, provider). @@ -168,52 +167,34 @@ class AgentEditorViewModel( when (val result = agentRepository.getAgentForEditing(agentId)) { is Result.Success -> { val agent = result.data - Timber.d( + Logger.d { "AgentEditor: Loaded agent fields BEFORE mapping - " + - "name=%s, description=%s, instructions=%s, model=%s, " + - "provider=%s, category=%s, tools=%s, " + - "conversationStarters=%s, avatarUrl=%s, " + - "artifacts=%s, recursionLimit=%s, " + - "hideSequentialOutputs=%s, endAfterTools=%s, " + - "isPublic=%s, isCollaborative=%s, " + - "agentIds=%s, supportContact=%s, " + - "modelParameters=%s", - agent.name, agent.description, agent.instructions, - agent.model, agent.provider, agent.category, - agent.tools, agent.conversationStarters, - agent.avatarUrl, agent.artifacts, - agent.recursionLimit, agent.hideSequentialOutputs, - agent.endAfterTools, agent.isPublic, - agent.isCollaborative, agent.agentIds, - agent.supportContact, agent.modelParameters, - ) + "name=${agent.name}, description=${agent.description}, instructions=${agent.instructions}, model=${agent.model}, " + + "provider=${agent.provider}, category=${agent.category}, tools=${agent.tools}, " + + "conversationStarters=${agent.conversationStarters}, avatarUrl=${agent.avatarUrl}, " + + "artifacts=${agent.artifacts}, recursionLimit=${agent.recursionLimit}, " + + "hideSequentialOutputs=${agent.hideSequentialOutputs}, endAfterTools=${agent.endAfterTools}, " + + "isPublic=${agent.isPublic}, isCollaborative=${agent.isCollaborative}, " + + "agentIds=${agent.agentIds}, supportContact=${agent.supportContact}, " + + "modelParameters=${agent.modelParameters}" + } val newState = _uiState.value .applyAgentData(agent) .copy(isLoading = false) - Timber.d( + Logger.d { "AgentEditor: UI state AFTER mapping - " + - "name=%s, description=%s, instructions=%s, model=%s, " + - "provider=%s, category=%s, selectedTools=%s, " + - "conversationStarters=%s, avatarUrl=%s, " + - "codeInterpreterEnabled=%s, fileSearchEnabled=%s, " + - "capabilities=%s, advancedSettings=%s, " + - "sharingState=%s, handoffAgentIds=%s, " + - "supportContact=%s", - newState.name, newState.description, newState.instructions, - newState.model, newState.provider, newState.category, - newState.selectedTools, newState.conversationStarters, - newState.avatarUrl, newState.codeInterpreterEnabled, - newState.fileSearchEnabled, newState.capabilities, - newState.advancedSettings, newState.sharingState, - newState.handoffAgentIds, newState.supportContact, - ) + "name=${newState.name}, description=${newState.description}, instructions=${newState.instructions}, model=${newState.model}, " + + "provider=${newState.provider}, category=${newState.category}, selectedTools=${newState.selectedTools}, " + + "conversationStarters=${newState.conversationStarters}, avatarUrl=${newState.avatarUrl}, " + + "codeInterpreterEnabled=${newState.codeInterpreterEnabled}, fileSearchEnabled=${newState.fileSearchEnabled}, " + + "capabilities=${newState.capabilities}, advancedSettings=${newState.advancedSettings}, " + + "sharingState=${newState.sharingState}, handoffAgentIds=${newState.handoffAgentIds}, " + + "supportContact=${newState.supportContact}" + } _uiState.value = newState } is Result.Error -> { - Timber.e( - "AgentEditor: Failed to load agent %s: %s", - agentId, result.message, - ) + Logger.e { "AgentEditor: Failed to load agent $agentId: ${result.message}" } _uiState.value = _uiState.value.copy( isLoading = false, error = result.message ?: "Failed to load agent", @@ -533,13 +514,12 @@ class AgentEditorViewModel( // --- Avatar --- - fun uploadAvatar(uri: Uri) { + fun uploadAvatar(uri: Any) { val agentId = _uiState.value.agentId ?: return viewModelScope.launch { try { - val inputStream = appContext.contentResolver.openInputStream(uri) ?: return@launch - val bytes = inputStream.use { it.readBytes() } - val mimeType = appContext.contentResolver.getType(uri) ?: "image/png" + val bytes = contentReader.readBytes(uri) ?: return@launch + val mimeType = contentReader.getMimeType(uri) ?: "image/png" when (val result = agentRepository.uploadAgentAvatar(agentId, bytes, mimeType)) { is Result.Success -> { diff --git a/feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModel.kt b/feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModel.kt similarity index 100% rename from feature/agents/src/main/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModel.kt rename to feature/agents/src/commonMain/kotlin/com/librechat/android/feature/agents/viewmodel/AgentMarketplaceViewModel.kt diff --git a/feature/agents/src/iosMain/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.ios.kt b/feature/agents/src/iosMain/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.ios.kt new file mode 100644 index 0000000..687a66e --- /dev/null +++ b/feature/agents/src/iosMain/kotlin/com/librechat/android/feature/agents/components/AgentAvatarPicker.ios.kt @@ -0,0 +1,36 @@ +package com.librechat.android.feature.agents.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +@Composable +actual fun AgentAvatarPicker( + avatarUrl: String?, + agentName: String, + onImageSelected: (Any) -> Unit, + modifier: Modifier, +) { + // iOS: placeholder avatar display only (no image picker yet) + Box( + modifier = modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = agentName.take(1).uppercase().ifEmpty { "?" }, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} diff --git a/feature/agents/src/iosMain/kotlin/com/librechat/android/feature/agents/di/AgentsPlatformModule.ios.kt b/feature/agents/src/iosMain/kotlin/com/librechat/android/feature/agents/di/AgentsPlatformModule.ios.kt new file mode 100644 index 0000000..fb2bcad --- /dev/null +++ b/feature/agents/src/iosMain/kotlin/com/librechat/android/feature/agents/di/AgentsPlatformModule.ios.kt @@ -0,0 +1,72 @@ +package com.librechat.android.feature.agents.di + +import co.touchlab.kermit.Logger +import com.librechat.android.feature.agents.util.ContentReader +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import org.koin.core.module.Module +import org.koin.dsl.bind +import org.koin.dsl.module +import platform.Foundation.NSData +import platform.Foundation.NSURL +import platform.Foundation.dataWithContentsOfURL +import platform.Foundation.lastPathComponent +import platform.Foundation.pathExtension +import platform.posix.memcpy + +actual val agentsPlatformModule: Module = module { + single { + @OptIn(ExperimentalForeignApi::class) + object : ContentReader { + override fun readBytes(uri: Any): ByteArray? { + val nsUrl = uri as? NSURL + if (nsUrl == null) { + Logger.w("AgentsContentReader") { "readBytes called with non-NSURL: ${uri::class}" } + return null + } + val data = NSData.dataWithContentsOfURL(nsUrl) + if (data == null) { + Logger.w("AgentsContentReader") { "Failed to read data from: $nsUrl" } + return null + } + val size = data.length.toInt() + if (size == 0) return ByteArray(0) + val result = ByteArray(size) + result.usePinned { pinned -> + memcpy(pinned.addressOf(0), data.bytes, data.length) + } + return result + } + + override fun getMimeType(uri: Any): String? { + val nsUrl = uri as? NSURL ?: return null + val ext = nsUrl.pathExtension?.lowercase() ?: return null + return mimeTypeFromExtension(ext) + } + } + } bind ContentReader::class +} + +private fun mimeTypeFromExtension(ext: String): String? = when (ext) { + "json" -> "application/json" + "yaml", "yml" -> "application/x-yaml" + "txt" -> "text/plain" + "pdf" -> "application/pdf" + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + "gif" -> "image/gif" + "webp" -> "image/webp" + "svg" -> "image/svg+xml" + "mp3" -> "audio/mpeg" + "wav" -> "audio/wav" + "mp4" -> "video/mp4" + "html", "htm" -> "text/html" + "css" -> "text/css" + "js" -> "application/javascript" + "xml" -> "application/xml" + "zip" -> "application/zip" + "csv" -> "text/csv" + "md" -> "text/markdown" + else -> null +} diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 36ef1b7..96a8f6b 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -1,13 +1,22 @@ plugins { - id("librechat.android.feature") + id("librechat.kmp.feature") } android { namespace = "com.librechat.android.feature.auth" } -dependencies { - implementation(project(":core:network")) - implementation(libs.browser) - testImplementation(libs.kotlinx.serialization.json) +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":core:network")) + } + androidMain.dependencies { + implementation(libs.browser) + } + named("androidUnitTest").dependencies { + implementation(libs.kotlinx.serialization.json) + implementation(libs.koin.test) + } + } } diff --git a/feature/auth/src/androidMain/kotlin/com/librechat/android/feature/auth/di/AuthPlatformModule.kt b/feature/auth/src/androidMain/kotlin/com/librechat/android/feature/auth/di/AuthPlatformModule.kt new file mode 100644 index 0000000..55f669d --- /dev/null +++ b/feature/auth/src/androidMain/kotlin/com/librechat/android/feature/auth/di/AuthPlatformModule.kt @@ -0,0 +1,10 @@ +package com.librechat.android.feature.auth.di + +import com.librechat.android.feature.auth.oauth.AndroidOAuthLauncher +import com.librechat.android.feature.auth.oauth.OAuthLauncher +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val authPlatformModule: Module = module { + single { AndroidOAuthLauncher(get()) } +} diff --git a/feature/auth/src/androidMain/kotlin/com/librechat/android/feature/auth/oauth/AndroidOAuthLauncher.kt b/feature/auth/src/androidMain/kotlin/com/librechat/android/feature/auth/oauth/AndroidOAuthLauncher.kt new file mode 100644 index 0000000..17b340f --- /dev/null +++ b/feature/auth/src/androidMain/kotlin/com/librechat/android/feature/auth/oauth/AndroidOAuthLauncher.kt @@ -0,0 +1,32 @@ +package com.librechat.android.feature.auth.oauth + +import android.content.Context +import android.net.Uri +import android.webkit.CookieManager +import androidx.browser.customtabs.CustomTabsIntent + +class AndroidOAuthLauncher( + private val context: Context, +) : OAuthLauncher { + + override fun launchOAuth(provider: String, serverUrl: String) { + val oauthUrl = "$serverUrl/api/oauth/$provider" + val customTabsIntent = CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + customTabsIntent.launchUrl(context, Uri.parse(oauthUrl)) + } + + override fun extractTokenFromCookies(serverUrl: String): String? { + val cookies = CookieManager.getInstance().getCookie(serverUrl) ?: return null + return cookies.split(";") + .map { it.trim() } + .firstOrNull { it.startsWith("refreshToken=") } + ?.substringAfter("refreshToken=") + } + + override fun clearOAuthCookie(serverUrl: String) { + CookieManager.getInstance() + .setCookie(serverUrl, "refreshToken=; expires=Thu, 01 Jan 1970 00:00:00 GMT") + } +} diff --git a/feature/auth/src/test/kotlin/com/librechat/android/feature/auth/di/AuthModuleVerificationTest.kt b/feature/auth/src/androidUnitTest/kotlin/com/librechat/android/feature/auth/di/AuthModuleVerificationTest.kt similarity index 91% rename from feature/auth/src/test/kotlin/com/librechat/android/feature/auth/di/AuthModuleVerificationTest.kt rename to feature/auth/src/androidUnitTest/kotlin/com/librechat/android/feature/auth/di/AuthModuleVerificationTest.kt index 6316e58..bf555a0 100644 --- a/feature/auth/src/test/kotlin/com/librechat/android/feature/auth/di/AuthModuleVerificationTest.kt +++ b/feature/auth/src/androidUnitTest/kotlin/com/librechat/android/feature/auth/di/AuthModuleVerificationTest.kt @@ -16,6 +16,7 @@ class AuthModuleVerificationTest { com.librechat.android.core.data.repository.ConfigRepository::class, com.librechat.android.core.data.repository.UserRepository::class, com.librechat.android.core.network.client.SecureTokenStorage::class, + com.librechat.android.feature.auth.oauth.OAuthLauncher::class, ), ) } diff --git a/feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModelTest.kt b/feature/auth/src/androidUnitTest/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModelTest.kt similarity index 97% rename from feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModelTest.kt rename to feature/auth/src/androidUnitTest/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModelTest.kt index 63f9700..8075340 100644 --- a/feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModelTest.kt +++ b/feature/auth/src/androidUnitTest/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModelTest.kt @@ -8,7 +8,7 @@ import com.librechat.android.core.data.repository.ConfigRepository import com.librechat.android.core.model.LoginOutcome import com.librechat.android.core.model.StartupConfig import com.librechat.android.core.model.User -import com.librechat.android.feature.auth.oauth.OAuthManager +import com.librechat.android.feature.auth.oauth.OAuthLauncher import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -31,7 +31,7 @@ class LoginViewModelTest { private val authRepository = mockk(relaxed = true) private val configRepository = mockk(relaxed = true) - private val oAuthManager = mockk(relaxed = true) + private val oAuthLauncher = mockk(relaxed = true) private val serverDataStore = mockk(relaxed = true) private val configFlow = MutableStateFlow(null) @@ -52,7 +52,7 @@ class LoginViewModelTest { private fun createViewModel() = LoginViewModel( authRepository = authRepository, configRepository = configRepository, - oAuthManager = oAuthManager, + oAuthLauncher = oAuthLauncher, serverDataStore = serverDataStore, ) diff --git a/feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModelTest.kt b/feature/auth/src/androidUnitTest/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModelTest.kt similarity index 100% rename from feature/auth/src/test/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModelTest.kt rename to feature/auth/src/androidUnitTest/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModelTest.kt diff --git a/feature/auth/src/main/res/values/strings.xml b/feature/auth/src/commonMain/composeResources/values/strings.xml similarity index 100% rename from feature/auth/src/main/res/values/strings.xml rename to feature/auth/src/commonMain/composeResources/values/strings.xml diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/di/AuthModule.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/di/AuthModule.kt similarity index 88% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/di/AuthModule.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/di/AuthModule.kt index 535049c..05a298f 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/di/AuthModule.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/di/AuthModule.kt @@ -1,6 +1,5 @@ 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 @@ -9,13 +8,14 @@ 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.Module import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module -val authModule = module { - singleOf(::OAuthManager) +expect val authPlatformModule: Module +val authModule = module { + includes(authPlatformModule) viewModelOf(::ServerUrlViewModel) viewModelOf(::LoginViewModel) viewModelOf(::RegisterViewModel) diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/navigation/AuthNavigation.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/navigation/AuthNavigation.kt similarity index 87% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/navigation/AuthNavigation.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/navigation/AuthNavigation.kt index dc2b309..d8ff3fd 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/navigation/AuthNavigation.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/navigation/AuthNavigation.kt @@ -1,6 +1,5 @@ package com.librechat.android.feature.auth.navigation -import android.net.Uri import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType @@ -16,7 +15,6 @@ import com.librechat.android.feature.auth.screen.ServerUrlScreen import com.librechat.android.feature.auth.screen.TermsScreen import com.librechat.android.feature.auth.screen.TwoFactorScreen import com.librechat.android.feature.auth.screen.VerifyEmailScreen - const val AUTH_GRAPH_ROUTE = "auth_graph" const val SERVER_URL_ROUTE = "server_url" const val LOGIN_ROUTE = "login" @@ -28,11 +26,28 @@ const val TERMS_ROUTE = "auth/terms" const val RESET_PASSWORD_ROUTE = "reset_password/{userId}/{token}" fun NavController.navigateToVerifyEmail(email: String) { - navigate("verify_email/${Uri.encode(email)}") + navigate("verify_email/${encodeNavArg(email)}") } fun NavController.navigateToResetPassword(userId: String, token: String) { - navigate("reset_password/${Uri.encode(userId)}/${Uri.encode(token)}") + navigate("reset_password/${encodeNavArg(userId)}/${encodeNavArg(token)}") +} + +private fun encodeNavArg(value: String): String = buildString { + for (c in value) { + when { + c.isLetterOrDigit() || c in "-._~" -> append(c) + else -> { + val bytes = c.toString().encodeToByteArray() + for (b in bytes) { + append('%') + append( + (b.toInt() and 0xFF).toString(16).uppercase().padStart(2, '0'), + ) + } + } + } + } } fun NavGraphBuilder.authGraph( @@ -88,11 +103,9 @@ fun NavGraphBuilder.authGraph( composable( route = VERIFY_EMAIL_ROUTE, arguments = listOf(navArgument("email") { type = NavType.StringType }), - ) { backStackEntry -> - val email = backStackEntry.arguments?.getString("email") ?: "" + ) { ScreenTransitionWrapper(transition) { VerifyEmailScreen( - email = email, onVerified = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, onBack = { navController.popBackStack() }, ) diff --git a/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/oauth/OAuthLauncher.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/oauth/OAuthLauncher.kt new file mode 100644 index 0000000..d9f53b8 --- /dev/null +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/oauth/OAuthLauncher.kt @@ -0,0 +1,17 @@ +package com.librechat.android.feature.auth.oauth + +/** + * Platform-abstracted OAuth launcher. + * Android: Chrome Custom Tabs + CookieManager + * iOS: ASWebAuthenticationSession + */ +interface OAuthLauncher { + /** Open the OAuth flow for the given provider. */ + fun launchOAuth(provider: String, serverUrl: String) + + /** After OAuth redirect, extract refresh token from cookies/callback. */ + fun extractTokenFromCookies(serverUrl: String): String? + + /** Clear OAuth cookie/state after successful extraction. */ + fun clearOAuthCookie(serverUrl: String) +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ForgotPasswordScreen.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/ForgotPasswordScreen.kt similarity index 77% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ForgotPasswordScreen.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/ForgotPasswordScreen.kt index fbda978..18649e8 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ForgotPasswordScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/ForgotPasswordScreen.kt @@ -1,5 +1,6 @@ package com.librechat.android.feature.auth.screen +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -23,12 +24,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.librechat.android.feature.auth.R +import librechat_android.feature.auth.generated.resources.Res +import librechat_android.feature.auth.generated.resources.* import com.librechat.android.feature.auth.viewmodel.ForgotPasswordViewModel import org.koin.compose.viewmodel.koinViewModel @@ -42,12 +46,13 @@ fun ForgotPasswordScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() Scaffold( + containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( - title = { Text(stringResource(R.string.reset_password_title)) }, + title = { Text(stringResource(Res.string.reset_password_title)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) } }, ) @@ -56,6 +61,7 @@ fun ForgotPasswordScreen( Column( modifier = modifier .fillMaxSize() + .background(MaterialTheme.colorScheme.background) .padding(padding) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -63,29 +69,29 @@ fun ForgotPasswordScreen( ) { if (uiState.isSent) { Text( - text = stringResource(R.string.check_your_email), + text = stringResource(Res.string.check_your_email), style = MaterialTheme.typography.headlineMedium, modifier = Modifier.semantics { heading() }, ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(R.string.reset_link_sent, uiState.email), + text = stringResource(Res.string.reset_link_sent, uiState.email), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(24.dp)) Button(onClick = onBack) { - Text(stringResource(R.string.back_to_sign_in)) + Text(stringResource(Res.string.back_to_sign_in)) } } else { Text( - text = stringResource(R.string.forgot_your_password), + text = stringResource(Res.string.forgot_your_password), style = MaterialTheme.typography.headlineMedium, modifier = Modifier.semantics { heading() }, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.enter_email_for_reset), + text = stringResource(Res.string.enter_email_for_reset), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -94,9 +100,13 @@ fun ForgotPasswordScreen( OutlinedTextField( value = uiState.email, onValueChange = viewModel::onEmailChanged, - label = { Text(stringResource(R.string.email_label)) }, + label = { Text(stringResource(Res.string.email_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + autoCorrectEnabled = false, + ), isError = uiState.error != null, supportingText = uiState.error?.let { error -> { Text(text = error, color = MaterialTheme.colorScheme.error) } @@ -118,7 +128,7 @@ fun ForgotPasswordScreen( strokeWidth = 2.dp, ) } else { - Text(stringResource(R.string.send_reset_link)) + Text(stringResource(Res.string.send_reset_link)) } } } diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/LoginScreen.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/LoginScreen.kt similarity index 80% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/LoginScreen.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/LoginScreen.kt index 94eba17..a241d01 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/LoginScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/LoginScreen.kt @@ -1,5 +1,6 @@ package com.librechat.android.feature.auth.screen +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -20,17 +21,18 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.librechat.android.feature.auth.R +import librechat_android.feature.auth.generated.resources.Res +import librechat_android.feature.auth.generated.resources.* import com.librechat.android.feature.auth.viewmodel.LoginViewModel import org.koin.compose.viewmodel.koinViewModel @@ -44,7 +46,6 @@ fun LoginScreen( viewModel: LoginViewModel = koinViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current // Check for OAuth result when returning from Chrome Custom Tab LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { @@ -67,13 +68,15 @@ fun LoginScreen( Column( modifier = modifier .fillMaxSize() + .background(MaterialTheme.colorScheme.background) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( - text = stringResource(R.string.login_title), + text = stringResource(Res.string.login_title), style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.semantics { heading() }, ) @@ -82,9 +85,13 @@ fun LoginScreen( OutlinedTextField( value = uiState.email, onValueChange = viewModel::onEmailChanged, - label = { Text(stringResource(R.string.email_label)) }, + label = { Text(stringResource(Res.string.email_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + autoCorrectEnabled = false, + ), enabled = !uiState.isLoading, ) @@ -93,10 +100,14 @@ fun LoginScreen( OutlinedTextField( value = uiState.password, onValueChange = viewModel::onPasswordChanged, - label = { Text(stringResource(R.string.password_label)) }, + label = { Text(stringResource(Res.string.password_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, - visualTransformation = PasswordVisualTransformation(), + visualTransformation = passwordMaskTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + ), enabled = !uiState.isLoading, ) @@ -123,21 +134,21 @@ fun LoginScreen( strokeWidth = 2.dp, ) } else { - Text(stringResource(R.string.continue_button)) + Text(stringResource(Res.string.continue_button)) } } Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = onNavigateToForgotPassword) { - Text(stringResource(R.string.forgot_password)) + Text(stringResource(Res.string.forgot_password)) } Spacer(modifier = Modifier.height(8.dp)) if (uiState.registrationEnabled) { TextButton(onClick = onNavigateToRegister) { - Text(stringResource(R.string.no_account_sign_up)) + Text(stringResource(Res.string.no_account_sign_up)) } } @@ -151,7 +162,7 @@ fun LoginScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.or_continue_with), + text = stringResource(Res.string.or_continue_with), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -162,7 +173,7 @@ fun LoginScreen( socialLogins.forEach { provider -> OutlinedButton( - onClick = { viewModel.launchOAuth(context, provider) }, + onClick = { viewModel.launchOAuth(provider) }, modifier = Modifier.fillMaxWidth(), enabled = !uiState.isLoading, ) { diff --git a/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/PasswordMaskTransformation.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/PasswordMaskTransformation.kt new file mode 100644 index 0000000..05b75c6 --- /dev/null +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/PasswordMaskTransformation.kt @@ -0,0 +1,21 @@ +package com.librechat.android.feature.auth.screen + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +/** + * Custom password mask that avoids [androidx.compose.ui.text.input.PasswordVisualTransformation]. + * CMP on iOS detects PasswordVisualTransformation and sets isSecureTextEntry on the native + * UITextField, which can cause a phantom pre-filled character from iOS keychain autofill. + * This transformation performs identical masking without triggering that platform behavior. + */ +private const val MASK_CHAR = '\u2022' + +fun passwordMaskTransformation(): VisualTransformation = VisualTransformation { text -> + TransformedText( + AnnotatedString(MASK_CHAR.toString().repeat(text.length)), + OffsetMapping.Identity, + ) +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/RegisterScreen.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/RegisterScreen.kt similarity index 73% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/RegisterScreen.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/RegisterScreen.kt index c8a5f7f..1e58607 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/RegisterScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/RegisterScreen.kt @@ -1,5 +1,6 @@ package com.librechat.android.feature.auth.screen +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -20,13 +21,15 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.librechat.android.feature.auth.R +import librechat_android.feature.auth.generated.resources.Res +import librechat_android.feature.auth.generated.resources.* import com.librechat.android.feature.auth.viewmodel.RegisterViewModel import org.koin.compose.viewmodel.koinViewModel @@ -48,14 +51,16 @@ fun RegisterScreen( Column( modifier = modifier .fillMaxSize() + .background(MaterialTheme.colorScheme.background) .padding(24.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( - text = stringResource(R.string.register_title), + text = stringResource(Res.string.register_title), style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.semantics { heading() }, ) @@ -64,7 +69,7 @@ fun RegisterScreen( OutlinedTextField( value = uiState.name, onValueChange = viewModel::onNameChanged, - label = { Text(stringResource(R.string.full_name_label)) }, + label = { Text(stringResource(Res.string.full_name_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, enabled = !uiState.isLoading, @@ -75,7 +80,7 @@ fun RegisterScreen( OutlinedTextField( value = uiState.username, onValueChange = viewModel::onUsernameChanged, - label = { Text(stringResource(R.string.username_label)) }, + label = { Text(stringResource(Res.string.username_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, enabled = !uiState.isLoading, @@ -86,9 +91,13 @@ fun RegisterScreen( OutlinedTextField( value = uiState.email, onValueChange = viewModel::onEmailChanged, - label = { Text(stringResource(R.string.email_label)) }, + label = { Text(stringResource(Res.string.email_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + autoCorrectEnabled = false, + ), enabled = !uiState.isLoading, ) @@ -97,10 +106,14 @@ fun RegisterScreen( OutlinedTextField( value = uiState.password, onValueChange = viewModel::onPasswordChanged, - label = { Text(stringResource(R.string.password_label)) }, + label = { Text(stringResource(Res.string.password_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, - visualTransformation = PasswordVisualTransformation(), + visualTransformation = passwordMaskTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + ), enabled = !uiState.isLoading, ) @@ -109,10 +122,14 @@ fun RegisterScreen( OutlinedTextField( value = uiState.confirmPassword, onValueChange = viewModel::onConfirmPasswordChanged, - label = { Text(stringResource(R.string.confirm_password_label)) }, + label = { Text(stringResource(Res.string.confirm_password_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, - visualTransformation = PasswordVisualTransformation(), + visualTransformation = passwordMaskTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + ), enabled = !uiState.isLoading, ) @@ -139,14 +156,14 @@ fun RegisterScreen( strokeWidth = 2.dp, ) } else { - Text(stringResource(R.string.sign_up)) + Text(stringResource(Res.string.sign_up)) } } Spacer(modifier = Modifier.height(16.dp)) TextButton(onClick = onNavigateToLogin) { - Text(stringResource(R.string.have_account_sign_in)) + Text(stringResource(Res.string.have_account_sign_in)) } } } diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ResetPasswordScreen.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/ResetPasswordScreen.kt similarity index 75% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ResetPasswordScreen.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/ResetPasswordScreen.kt index f203059..211b5df 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ResetPasswordScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/ResetPasswordScreen.kt @@ -1,5 +1,6 @@ package com.librechat.android.feature.auth.screen +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -23,13 +24,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.librechat.android.feature.auth.R +import librechat_android.feature.auth.generated.resources.Res +import librechat_android.feature.auth.generated.resources.* import com.librechat.android.feature.auth.viewmodel.ResetPasswordViewModel import org.koin.compose.viewmodel.koinViewModel @@ -45,14 +48,15 @@ fun ResetPasswordScreen( Scaffold( modifier = modifier, + containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( - title = { Text(stringResource(R.string.reset_password_screen_title)) }, + title = { Text(stringResource(Res.string.reset_password_screen_title)) }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), + contentDescription = stringResource(Res.string.back), ) } }, @@ -62,6 +66,7 @@ fun ResetPasswordScreen( Column( modifier = Modifier .fillMaxSize() + .background(MaterialTheme.colorScheme.background) .padding(innerPadding) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -69,7 +74,7 @@ fun ResetPasswordScreen( ) { if (uiState.isReset) { Text( - text = stringResource(R.string.password_reset_successful), + text = stringResource(Res.string.password_reset_successful), style = MaterialTheme.typography.headlineMedium, modifier = Modifier.semantics { heading() }, ) @@ -77,7 +82,7 @@ fun ResetPasswordScreen( Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(R.string.password_reset_message), + text = stringResource(Res.string.password_reset_message), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -88,11 +93,11 @@ fun ResetPasswordScreen( onClick = onResetComplete, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(R.string.back_to_sign_in)) + Text(stringResource(Res.string.back_to_sign_in)) } } else { Text( - text = stringResource(R.string.set_new_password), + text = stringResource(Res.string.set_new_password), style = MaterialTheme.typography.headlineMedium, modifier = Modifier.semantics { heading() }, ) @@ -100,7 +105,7 @@ fun ResetPasswordScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.enter_new_password_hint), + text = stringResource(Res.string.enter_new_password_hint), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -110,10 +115,14 @@ fun ResetPasswordScreen( OutlinedTextField( value = uiState.password, onValueChange = viewModel::onPasswordChanged, - label = { Text(stringResource(R.string.new_password_label)) }, + label = { Text(stringResource(Res.string.new_password_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, - visualTransformation = PasswordVisualTransformation(), + visualTransformation = passwordMaskTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + ), enabled = !uiState.isLoading, ) @@ -122,10 +131,14 @@ fun ResetPasswordScreen( OutlinedTextField( value = uiState.confirmPassword, onValueChange = viewModel::onConfirmPasswordChanged, - label = { Text(stringResource(R.string.confirm_new_password_label)) }, + label = { Text(stringResource(Res.string.confirm_new_password_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, - visualTransformation = PasswordVisualTransformation(), + visualTransformation = passwordMaskTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + ), enabled = !uiState.isLoading, ) @@ -152,7 +165,7 @@ fun ResetPasswordScreen( strokeWidth = 2.dp, ) } else { - Text(stringResource(R.string.reset_password_button)) + Text(stringResource(Res.string.reset_password_button)) } } } diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ServerUrlScreen.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/ServerUrlScreen.kt similarity index 79% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ServerUrlScreen.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/ServerUrlScreen.kt index f9d757c..af02616 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/ServerUrlScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/ServerUrlScreen.kt @@ -1,5 +1,6 @@ package com.librechat.android.feature.auth.screen +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -19,12 +20,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.librechat.android.feature.auth.R +import librechat_android.feature.auth.generated.resources.Res +import librechat_android.feature.auth.generated.resources.* import com.librechat.android.feature.auth.viewmodel.ServerUrlViewModel import org.koin.compose.viewmodel.koinViewModel @@ -36,12 +38,6 @@ fun ServerUrlScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { - if (viewModel.skipIfAlreadyConfigured()) { - onServerValidated() - } - } - LaunchedEffect(uiState.isValidated) { if (uiState.isValidated) { onServerValidated() @@ -58,20 +54,22 @@ fun ServerUrlScreen( Column( modifier = modifier .fillMaxSize() + .background(MaterialTheme.colorScheme.background) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( - text = stringResource(R.string.connect_to_librechat), + text = stringResource(Res.string.connect_to_librechat), style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.semantics { heading() }, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.enter_server_url_hint), + text = stringResource(Res.string.enter_server_url_hint), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -81,8 +79,8 @@ fun ServerUrlScreen( OutlinedTextField( value = uiState.url, onValueChange = viewModel::onUrlChanged, - label = { Text(stringResource(R.string.server_url_label)) }, - placeholder = { Text(stringResource(R.string.server_url_placeholder)) }, + label = { Text(stringResource(Res.string.server_url_label)) }, + placeholder = { Text(stringResource(Res.string.server_url_placeholder)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, isError = uiState.error != null, @@ -106,7 +104,7 @@ fun ServerUrlScreen( strokeWidth = 2.dp, ) } else { - Text(stringResource(R.string.server_url_connect)) + Text(stringResource(Res.string.server_url_connect)) } } } @@ -119,21 +117,21 @@ private fun HttpWarningDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.insecure_connection_title)) }, + title = { Text(stringResource(Res.string.insecure_connection_title)) }, text = { - Text(stringResource(R.string.insecure_connection_message)) + Text(stringResource(Res.string.insecure_connection_message)) }, confirmButton = { TextButton(onClick = onConfirm) { Text( - text = stringResource(R.string.connect_anyway), + text = stringResource(Res.string.connect_anyway), color = MaterialTheme.colorScheme.error, ) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) + Text(stringResource(Res.string.cancel)) } }, ) diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TermsScreen.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/TermsScreen.kt similarity index 92% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TermsScreen.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/TermsScreen.kt index e93055b..5bff0d4 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TermsScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/TermsScreen.kt @@ -26,13 +26,14 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource 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.lifecycle.compose.collectAsStateWithLifecycle -import com.librechat.android.feature.auth.R +import librechat_android.feature.auth.generated.resources.Res +import librechat_android.feature.auth.generated.resources.* import com.librechat.android.feature.auth.viewmodel.TermsViewModel import org.koin.compose.viewmodel.koinViewModel @@ -58,12 +59,12 @@ fun TermsScreen( modifier = modifier, topBar = { TopAppBar( - title = { Text(stringResource(R.string.terms_title)) }, + title = { Text(stringResource(Res.string.terms_title)) }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), + contentDescription = stringResource(Res.string.back), ) } }, @@ -105,14 +106,14 @@ fun TermsScreen( ) Spacer(modifier = Modifier.height(16.dp)) OutlinedButton(onClick = viewModel::loadTerms) { - Text(stringResource(R.string.retry)) + Text(stringResource(Res.string.retry)) } } } } else -> { Text( - text = stringResource(R.string.terms_title), + text = stringResource(Res.string.terms_title), style = MaterialTheme.typography.headlineSmall, modifier = Modifier .padding(horizontal = 24.dp, vertical = 16.dp) @@ -153,7 +154,7 @@ fun TermsScreen( strokeWidth = 2.dp, ) } else { - Text(stringResource(R.string.i_accept)) + Text(stringResource(Res.string.i_accept)) } } } diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt similarity index 89% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt index 9c6e980..b5c5acd 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/TwoFactorScreen.kt @@ -31,14 +31,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.heading 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.lifecycle.compose.collectAsStateWithLifecycle -import com.librechat.android.feature.auth.R +import librechat_android.feature.auth.generated.resources.Res +import librechat_android.feature.auth.generated.resources.* import com.librechat.android.feature.auth.viewmodel.TwoFactorViewModel import org.koin.compose.viewmodel.koinViewModel @@ -60,12 +61,12 @@ fun TwoFactorScreen( modifier = modifier, topBar = { TopAppBar( - title = { Text(stringResource(R.string.two_factor_title)) }, + title = { Text(stringResource(Res.string.two_factor_title)) }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), + contentDescription = stringResource(Res.string.back), ) } }, @@ -82,9 +83,9 @@ fun TwoFactorScreen( ) { Text( text = if (uiState.isBackupMode) { - stringResource(R.string.enter_backup_code) + stringResource(Res.string.enter_backup_code) } else { - stringResource(R.string.enter_verification_code) + stringResource(Res.string.enter_verification_code) }, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.semantics { heading() }, @@ -94,9 +95,9 @@ fun TwoFactorScreen( Text( text = if (uiState.isBackupMode) { - stringResource(R.string.backup_code_prompt) + stringResource(Res.string.backup_code_prompt) } else { - stringResource(R.string.authenticator_prompt) + stringResource(Res.string.authenticator_prompt) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -143,7 +144,7 @@ fun TwoFactorScreen( strokeWidth = 2.dp, ) } else { - Text(stringResource(R.string.verify)) + Text(stringResource(Res.string.verify)) } } } @@ -152,7 +153,7 @@ fun TwoFactorScreen( TextButton(onClick = viewModel::toggleBackupMode) { Text( - if (uiState.isBackupMode) stringResource(R.string.use_authenticator_code) else stringResource(R.string.use_backup_code), + if (uiState.isBackupMode) stringResource(Res.string.use_authenticator_code) else stringResource(Res.string.use_backup_code), ) } } @@ -215,6 +216,6 @@ private fun BackupCodeInput( modifier = modifier.fillMaxWidth(), enabled = enabled, singleLine = true, - label = { Text(stringResource(R.string.backup_code_label)) }, + label = { Text(stringResource(Res.string.backup_code_label)) }, ) } diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/VerifyEmailScreen.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/VerifyEmailScreen.kt similarity index 86% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/VerifyEmailScreen.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/VerifyEmailScreen.kt index eeef00c..6f4a168 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/screen/VerifyEmailScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/screen/VerifyEmailScreen.kt @@ -24,30 +24,27 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource 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.lifecycle.compose.collectAsStateWithLifecycle -import com.librechat.android.feature.auth.R +import librechat_android.feature.auth.generated.resources.Res +import librechat_android.feature.auth.generated.resources.* import com.librechat.android.feature.auth.viewmodel.VerifyEmailViewModel import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun VerifyEmailScreen( - email: String, onVerified: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: VerifyEmailViewModel = koinViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - LaunchedEffect(email) { - viewModel.setEmail(email) - } + val email = uiState.email LaunchedEffect(uiState.isVerified) { if (uiState.isVerified) { @@ -60,12 +57,12 @@ fun VerifyEmailScreen( modifier = modifier, topBar = { TopAppBar( - title = { Text(stringResource(R.string.verify_email_title)) }, + title = { Text(stringResource(Res.string.verify_email_title)) }, navigationIcon = { IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), + contentDescription = stringResource(Res.string.back), ) } }, @@ -81,7 +78,7 @@ fun VerifyEmailScreen( verticalArrangement = Arrangement.Center, ) { Text( - text = stringResource(R.string.check_your_email), + text = stringResource(Res.string.check_your_email), style = MaterialTheme.typography.headlineMedium, modifier = Modifier.semantics { heading() }, ) @@ -89,7 +86,7 @@ fun VerifyEmailScreen( Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(R.string.verification_link_sent), + text = stringResource(Res.string.verification_link_sent), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -108,7 +105,7 @@ fun VerifyEmailScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.click_link_to_verify), + text = stringResource(Res.string.click_link_to_verify), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -127,7 +124,7 @@ fun VerifyEmailScreen( if (uiState.resendSuccess) { Spacer(modifier = Modifier.height(12.dp)) Text( - text = stringResource(R.string.verification_email_sent), + text = stringResource(Res.string.verification_email_sent), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center, @@ -147,9 +144,9 @@ fun VerifyEmailScreen( strokeWidth = 2.dp, ) } else if (uiState.resendCooldownSeconds > 0) { - Text(stringResource(R.string.resend_in_seconds, uiState.resendCooldownSeconds)) + Text(stringResource(Res.string.resend_in_seconds, uiState.resendCooldownSeconds)) } else { - Text(stringResource(R.string.resend_verification_email)) + Text(stringResource(Res.string.resend_verification_email)) } } @@ -159,7 +156,7 @@ fun VerifyEmailScreen( onClick = onBack, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(R.string.back_to_sign_in)) + Text(stringResource(Res.string.back_to_sign_in)) } } } diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ForgotPasswordViewModel.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/ForgotPasswordViewModel.kt similarity index 100% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ForgotPasswordViewModel.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/ForgotPasswordViewModel.kt diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModel.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModel.kt similarity index 92% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModel.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModel.kt index 2952ab5..70ed83d 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/LoginViewModel.kt @@ -1,6 +1,5 @@ package com.librechat.android.feature.auth.viewmodel -import android.content.Context import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -9,7 +8,7 @@ import com.librechat.android.core.data.datastore.ServerDataStore import com.librechat.android.core.data.repository.AuthRepository import com.librechat.android.core.data.repository.ConfigRepository import com.librechat.android.core.model.LoginOutcome -import com.librechat.android.feature.auth.oauth.OAuthManager +import com.librechat.android.feature.auth.oauth.OAuthLauncher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -31,7 +30,7 @@ data class LoginUiState( class LoginViewModel( private val authRepository: AuthRepository, private val configRepository: ConfigRepository, - private val oAuthManager: OAuthManager, + private val oAuthLauncher: OAuthLauncher, private val serverDataStore: ServerDataStore, ) : ViewModel() { @@ -98,18 +97,19 @@ class LoginViewModel( } } - fun launchOAuth(context: Context, provider: String) { - oAuthManager.launchOAuth(context, provider) + fun launchOAuth(provider: String) { + val serverUrl = serverDataStore.getBaseUrl() + oAuthLauncher.launchOAuth(provider, serverUrl) } fun checkOAuthResult() { val serverUrl = serverDataStore.getBaseUrl() if (serverUrl.isBlank()) return - val refreshToken = oAuthManager.extractTokenFromCookies(serverUrl) ?: return + val refreshToken = oAuthLauncher.extractTokenFromCookies(serverUrl) ?: return // Clear the cookie immediately to avoid re-reading on next onResume - oAuthManager.clearOAuthCookie(serverUrl) + oAuthLauncher.clearOAuthCookie(serverUrl) viewModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true, error = null) diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModel.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModel.kt similarity index 100% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModel.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/RegisterViewModel.kt diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ResetPasswordViewModel.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/ResetPasswordViewModel.kt similarity index 100% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ResetPasswordViewModel.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/ResetPasswordViewModel.kt diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ServerUrlViewModel.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/ServerUrlViewModel.kt similarity index 97% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ServerUrlViewModel.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/ServerUrlViewModel.kt index 62c8caf..1a55513 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/ServerUrlViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/ServerUrlViewModel.kt @@ -107,7 +107,4 @@ class ServerUrlViewModel( } } - fun skipIfAlreadyConfigured(): Boolean { - return _uiState.value.hasExistingUrl - } } diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TermsViewModel.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/TermsViewModel.kt similarity index 96% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TermsViewModel.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/TermsViewModel.kt index 6e1591d..327c8d2 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TermsViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/TermsViewModel.kt @@ -18,7 +18,6 @@ data class TermsUiState( val error: String? = null, ) -/** Loads and accepts server terms of service; use consumeAccepted() to reset the navigation trigger. */ class TermsViewModel( private val userRepository: UserRepository, ) : ViewModel() { diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TwoFactorViewModel.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/TwoFactorViewModel.kt similarity index 100% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/TwoFactorViewModel.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/TwoFactorViewModel.kt diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/VerifyEmailViewModel.kt b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/VerifyEmailViewModel.kt similarity index 94% rename from feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/VerifyEmailViewModel.kt rename to feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/VerifyEmailViewModel.kt index 52b8bf9..efbe5ed 100644 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/viewmodel/VerifyEmailViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/com/librechat/android/feature/auth/viewmodel/VerifyEmailViewModel.kt @@ -1,6 +1,7 @@ package com.librechat.android.feature.auth.viewmodel import androidx.compose.runtime.Immutable +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.librechat.android.core.common.result.Result @@ -23,10 +24,13 @@ data class VerifyEmailUiState( ) class VerifyEmailViewModel( + savedStateHandle: SavedStateHandle, private val userRepository: UserRepository, ) : ViewModel() { - private val _uiState = MutableStateFlow(VerifyEmailUiState()) + private val _uiState = MutableStateFlow( + VerifyEmailUiState(email = savedStateHandle["email"] ?: ""), + ) val uiState: StateFlow = _uiState.asStateFlow() private var cooldownJob: Job? = null diff --git a/feature/auth/src/iosMain/kotlin/com/librechat/android/feature/auth/di/AuthPlatformModule.kt b/feature/auth/src/iosMain/kotlin/com/librechat/android/feature/auth/di/AuthPlatformModule.kt new file mode 100644 index 0000000..4029a9f --- /dev/null +++ b/feature/auth/src/iosMain/kotlin/com/librechat/android/feature/auth/di/AuthPlatformModule.kt @@ -0,0 +1,10 @@ +package com.librechat.android.feature.auth.di + +import com.librechat.android.feature.auth.oauth.IosOAuthLauncher +import com.librechat.android.feature.auth.oauth.OAuthLauncher +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val authPlatformModule: Module = module { + single { IosOAuthLauncher() } +} diff --git a/feature/auth/src/iosMain/kotlin/com/librechat/android/feature/auth/oauth/IosOAuthLauncher.kt b/feature/auth/src/iosMain/kotlin/com/librechat/android/feature/auth/oauth/IosOAuthLauncher.kt new file mode 100644 index 0000000..02d2c75 --- /dev/null +++ b/feature/auth/src/iosMain/kotlin/com/librechat/android/feature/auth/oauth/IosOAuthLauncher.kt @@ -0,0 +1,81 @@ +package com.librechat.android.feature.auth.oauth + +import co.touchlab.kermit.Logger +import platform.AuthenticationServices.ASPresentationAnchor +import platform.AuthenticationServices.ASWebAuthenticationPresentationContextProvidingProtocol +import platform.AuthenticationServices.ASWebAuthenticationSession +import platform.Foundation.NSHTTPCookie +import platform.Foundation.NSHTTPCookieStorage +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIWindow +import platform.UIKit.UIWindowScene +import platform.darwin.NSObject + +class IosOAuthLauncher : OAuthLauncher { + + // Retain the session to prevent deallocation during auth flow + private var authSession: ASWebAuthenticationSession? = null + + override fun launchOAuth(provider: String, serverUrl: String) { + val oauthUrl = "$serverUrl/api/oauth/$provider" + val url = NSURL.URLWithString(oauthUrl) ?: run { + Logger.e { "Invalid OAuth URL: $oauthUrl" } + return + } + + val session = ASWebAuthenticationSession( + uRL = url, + callbackURLScheme = "librechat", + completionHandler = { _, error -> + if (error != null) { + Logger.d { "ASWebAuthenticationSession ended: ${error.localizedDescription}" } + } + authSession = null + }, + ) + + session.prefersEphemeralWebBrowserSession = false + + val window = getKeyWindow() + if (window != null) { + val contextProvider = object : NSObject(), ASWebAuthenticationPresentationContextProvidingProtocol { + override fun presentationAnchorForWebAuthenticationSession( + session: ASWebAuthenticationSession, + ): ASPresentationAnchor { + return window + } + } + session.presentationContextProvider = contextProvider + } + + authSession = session + session.start() + } + + override fun extractTokenFromCookies(serverUrl: String): String? { + val url = NSURL.URLWithString(serverUrl) ?: return null + val cookies = NSHTTPCookieStorage.sharedHTTPCookieStorage.cookiesForURL(url) ?: return null + @Suppress("UNCHECKED_CAST") + val cookieList = cookies as List + return cookieList.firstOrNull { it.name == "refreshToken" }?.value + } + + override fun clearOAuthCookie(serverUrl: String) { + val url = NSURL.URLWithString(serverUrl) ?: return + val cookies = NSHTTPCookieStorage.sharedHTTPCookieStorage.cookiesForURL(url) ?: return + @Suppress("UNCHECKED_CAST") + val cookieList = cookies as List + cookieList.filter { it.name == "refreshToken" }.forEach { + NSHTTPCookieStorage.sharedHTTPCookieStorage.deleteCookie(it) + } + } + + private fun getKeyWindow(): UIWindow? { + val scene = UIApplication.sharedApplication.connectedScenes + .firstOrNull() as? UIWindowScene + return scene?.windows?.firstOrNull { + (it as? UIWindow)?.isKeyWindow() == true + } as? UIWindow + } +} diff --git a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/oauth/OAuthManager.kt b/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/oauth/OAuthManager.kt deleted file mode 100644 index 37e3f3c..0000000 --- a/feature/auth/src/main/kotlin/com/librechat/android/feature/auth/oauth/OAuthManager.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.librechat.android.feature.auth.oauth - -import android.content.Context -import android.net.Uri -import android.webkit.CookieManager -import androidx.browser.customtabs.CustomTabsIntent -import com.librechat.android.core.data.datastore.ServerDataStore -class OAuthManager( - private val serverDataStore: ServerDataStore, -) { - /** - * Opens Chrome Custom Tab for OAuth provider. - * URL: {serverUrl}/api/oauth/{provider} - * The server handles the OAuth flow and redirects back with cookies set. - */ - fun launchOAuth(context: Context, provider: String) { - val serverUrl = serverDataStore.getBaseUrl() - val oauthUrl = "$serverUrl/api/oauth/$provider" - val customTabsIntent = CustomTabsIntent.Builder() - .setShowTitle(true) - .build() - customTabsIntent.launchUrl(context, Uri.parse(oauthUrl)) - } - - /** - * After OAuth redirect, extract refresh token from cookies. - * Called from Activity.onResume() after Custom Tab closes. - * - * CookieManager shares cookie jar with Chrome Custom Tabs when the - * default browser is Chrome. httpOnly cookies are readable via native - * CookieManager.getCookie() despite the httpOnly flag. - */ - fun extractTokenFromCookies(serverUrl: String): String? { - val cookies = CookieManager.getInstance().getCookie(serverUrl) ?: return null - return cookies.split(";") - .map { it.trim() } - .firstOrNull { it.startsWith("refreshToken=") } - ?.substringAfter("refreshToken=") - } - - /** - * Clear the refresh token cookie after successful extraction to avoid - * stale cookie reads on subsequent onResume calls. - */ - fun clearOAuthCookie(serverUrl: String) { - CookieManager.getInstance() - .setCookie(serverUrl, "refreshToken=; expires=Thu, 01 Jan 1970 00:00:00 GMT") - } -} diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts index bd0b9b0..3fb90c5 100644 --- a/feature/chat/build.gradle.kts +++ b/feature/chat/build.gradle.kts @@ -1,20 +1,30 @@ plugins { - id("librechat.android.feature") + id("librechat.kmp.feature") } android { namespace = "com.librechat.android.feature.chat" } -dependencies { - implementation(project(":core:network")) - implementation(libs.timber) - implementation(libs.kotlinx.serialization.json) - implementation(libs.coil.compose) - implementation(libs.media3.exoplayer) - implementation(libs.media3.ui) - implementation(libs.media3.datasource) - implementation(libs.fuzzywuzzy) - implementation(libs.markdown.renderer.m3) - implementation(libs.markdown.renderer.coil3) +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":core:network")) + implementation(libs.kermit) + implementation(libs.kotlinx.serialization.json) + implementation(libs.coil3.compose) + implementation(libs.coil3.network.ktor) + implementation(libs.markdown.renderer.m3) + implementation(libs.markdown.renderer.coil3) + } + androidMain.dependencies { + implementation(libs.koin.android) + implementation(libs.media3.exoplayer) + implementation(libs.media3.ui) + implementation(libs.media3.datasource) + } + getByName("androidUnitTest").dependencies { + implementation(libs.koin.test) + } + } } diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/ShareIntentConsumer.kt b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/ShareIntentConsumer.kt similarity index 100% rename from feature/chat/src/main/kotlin/com/librechat/android/feature/chat/ShareIntentConsumer.kt rename to feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/ShareIntentConsumer.kt diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/audio/VoiceRecorder.kt b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/audio/VoiceRecorder.kt similarity index 95% rename from feature/chat/src/main/kotlin/com/librechat/android/feature/chat/audio/VoiceRecorder.kt rename to feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/audio/VoiceRecorder.kt index a971a20..b7343b0 100644 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/audio/VoiceRecorder.kt +++ b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/audio/VoiceRecorder.kt @@ -3,7 +3,7 @@ package com.librechat.android.feature.chat.audio import android.content.Context import android.media.MediaRecorder import android.os.Build -import timber.log.Timber +import co.touchlab.kermit.Logger import java.io.File /** @@ -60,7 +60,7 @@ class VoiceRecorder(private val context: Context) { mediaRecorder = recorder isCurrentlyRecording = true } catch (e: Exception) { - Timber.e(e, "Failed to start voice recording") + Logger.e(e) { "Failed to start voice recording" } cleanup() throw e } @@ -83,7 +83,7 @@ class VoiceRecorder(private val context: Context) { outputFile = null bytes } catch (e: Exception) { - Timber.e(e, "Failed to stop voice recording") + Logger.e(e) { "Failed to stop voice recording" } cleanup() null } diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContent.kt b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/AudioContent.kt similarity index 98% rename from feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContent.kt rename to feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/AudioContent.kt index dc3a9ed..8aa5e21 100644 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContent.kt +++ b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/AudioContent.kt @@ -24,10 +24,10 @@ import androidx.media3.ui.PlayerView @androidx.annotation.OptIn(UnstableApi::class) @Composable -fun AudioContent( +actual fun AudioContent( data: String?, format: String?, - modifier: Modifier = Modifier, + modifier: Modifier, ) { if (data.isNullOrBlank()) return diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContentPlayer.kt b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/AudioContentPlayer.kt similarity index 93% rename from feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContentPlayer.kt rename to feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/AudioContentPlayer.kt index beb44f4..0d693f6 100644 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/AudioContentPlayer.kt +++ b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/AudioContentPlayer.kt @@ -32,19 +32,20 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.chat.R +import librechat_android.feature.chat.generated.resources.Res +import librechat_android.feature.chat.generated.resources.* import kotlinx.coroutines.delay -import timber.log.Timber +import co.touchlab.kermit.Logger import java.io.File import java.util.Locale /** Plays audio via MediaPlayer with play/pause and seekbar. Polls progress every 250ms. Releases on dispose. */ @Composable -fun AudioContentPlayer( +actual fun AudioContentPlayer( audioUrl: String, - modifier: Modifier = Modifier, + modifier: Modifier, ) { val context = LocalContext.current var isPlaying by remember { mutableStateOf(false) } @@ -66,7 +67,7 @@ fun AudioContentPlayer( durationMs = it.duration } setOnErrorListener { _, what, extra -> - Timber.e("MediaPlayer error: what=$what, extra=$extra") + Logger.e { "MediaPlayer error: what=$what, extra=$extra" } isPlaying = false true } @@ -81,7 +82,7 @@ fun AudioContentPlayer( mediaPlayer.setDataSource(audioUrl) mediaPlayer.prepareAsync() } catch (e: Exception) { - Timber.e(e, "Failed to set data source") + Logger.e(e) { "Failed to set data source" } } } @@ -144,7 +145,7 @@ fun AudioContentPlayer( ) { Icon( imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = stringResource(if (isPlaying) R.string.cd_pause else R.string.cd_play), + contentDescription = stringResource(if (isPlaying) Res.string.cd_pause else Res.string.cd_play), modifier = Modifier.size(24.dp), ) } @@ -182,9 +183,9 @@ fun AudioContentPlayer( /** Variant of [AudioContentPlayer] that writes raw bytes to a temp file before playback. */ @Composable -fun AudioContentPlayerFromBytes( +actual fun AudioContentPlayerFromBytes( audioBytes: ByteArray, - modifier: Modifier = Modifier, + modifier: Modifier, ) { val context = LocalContext.current val tempFile = remember(audioBytes) { diff --git a/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/ChatInput.kt b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/ChatInput.kt new file mode 100644 index 0000000..990531d --- /dev/null +++ b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/ChatInput.kt @@ -0,0 +1,415 @@ +package com.librechat.android.feature.chat.components + +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import org.jetbrains.compose.resources.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import com.librechat.android.feature.chat.McpServerDisplayData +import com.librechat.android.feature.chat.PromptMentionDisplayData +import librechat_android.feature.chat.generated.resources.Res +import librechat_android.feature.chat.generated.resources.* +import java.io.File + +@Composable +fun ChatInput( + inputText: String, + isStreaming: Boolean, + onInputChanged: (String) -> Unit, + onSend: () -> Unit, + onStop: () -> Unit, + modifier: Modifier = Modifier, + attachedFiles: List = emptyList(), + onFilesSelected: (List) -> Unit = {}, + onRemoveFile: (AttachedFile) -> Unit = {}, + promptSuggestions: List = emptyList(), + onPromptSelected: (PromptMentionDisplayData) -> Unit = {}, + onSlashCommandSelected: (PromptMentionDisplayData) -> Unit = {}, + isRecording: Boolean = false, + isTranscribing: Boolean = false, + onStartRecording: () -> Unit = {}, + onStopRecording: () -> Unit = {}, + onImagePasted: ((Uri) -> Unit)? = null, + enabledTools: Set = emptySet(), + onToggleTool: (String) -> Unit = {}, + mcpServers: List = emptyList(), + selectedMcpServerNames: Set = emptySet(), + onToggleMcpServer: (String) -> Unit = {}, + onOpenModelParameters: () -> Unit = {}, + onOpenModelSelector: () -> Unit = {}, + selectedModelDisplay: String? = null, + isCodeInterpreterAvailable: Boolean = true, +) { + val cdOpenToolsMenu = stringResource(Res.string.cd_open_tools_menu) + val cdPasteImage = stringResource(Res.string.cd_paste_image) + val cdMessageInput = stringResource(Res.string.cd_message_input) + val cdStopVoiceRec = stringResource(Res.string.cd_stop_voice_recording) + val cdStartVoiceRec = stringResource(Res.string.cd_start_voice_recording) + val context = LocalContext.current + + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenMultipleDocuments(), + ) { uris -> + if (uris.isNotEmpty()) { + onFilesSelected(uris) + } + } + + // Photo picker (gallery) launcher using modern PickMultipleVisualMedia + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(), + ) { uris -> + if (uris.isNotEmpty()) { + onFilesSelected(uris) + } + } + + // Camera launcher: stores the photo in a temp file via FileProvider + var cameraPhotoUri by remember { mutableStateOf(null) } + + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture(), + ) { success -> + val uri = cameraPhotoUri + if (success && uri != null) { + onFilesSelected(listOf(uri)) + } + cameraPhotoUri = null + } + + // Camera permission launcher + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + val photoFile = createCameraPhotoFile(context) + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + photoFile, + ) + cameraPhotoUri = uri + cameraLauncher.launch(uri) + } + } + + val onTakePhoto: () -> Unit = { + val hasCameraPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + if (hasCameraPermission) { + val photoFile = createCameraPhotoFile(context) + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + photoFile, + ) + cameraPhotoUri = uri + cameraLauncher.launch(uri) + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + val onPickPhotos: () -> Unit = { + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + } + + val focusRequester = remember { FocusRequester() } + var showToolsSheet by remember { mutableStateOf(false) } + + // Use TextFieldValue internally so we can control cursor position. + // When inputText changes externally (e.g. STT result, prompt insertion), + // place the cursor at the end of the new text. + var textFieldValue by remember { + mutableStateOf(TextFieldValue(inputText, selection = TextRange(inputText.length))) + } + if (textFieldValue.text != inputText) { + textFieldValue = TextFieldValue(inputText, selection = TextRange(inputText.length)) + } + + val hasActiveTools by remember(enabledTools, selectedMcpServerNames) { + derivedStateOf { + enabledTools.isNotEmpty() || selectedMcpServerNames.isNotEmpty() + } + } + + // Detect @mention query from input text + val mentionQuery by remember(inputText) { + derivedStateOf { parseMentionQuery(inputText) } + } + + val filteredPrompts by remember(mentionQuery, promptSuggestions) { + derivedStateOf { + val query = mentionQuery + if (query != null) filterMatchingPrompts(query, promptSuggestions) else emptyList() + } + } + + // Detect slash command query: "/" at position 0 + val slashQuery by remember(inputText) { + derivedStateOf { parseSlashQuery(inputText) } + } + + val filteredSlashCommands by remember(slashQuery, promptSuggestions) { + derivedStateOf { + val query = slashQuery + if (query != null) filterMatchingSlashCommands(query, promptSuggestions) else emptyList() + } + } + + val state = ChatInputState( + inputText = inputText, + isStreaming = isStreaming, + isRecording = isRecording, + isTranscribing = isTranscribing, + enabledTools = enabledTools, + mcpServers = mcpServers, + selectedMcpServerNames = selectedMcpServerNames, + selectedModelDisplay = selectedModelDisplay, + isCodeInterpreterAvailable = isCodeInterpreterAvailable, + attachedFiles = attachedFiles, + ) + + CommonChatInputCore( + state = state, + onSend = onSend, + onStop = onStop, + onRemoveFile = onRemoveFile, + modifier = modifier, + columnModifier = Modifier.navigationBarsPadding(), + leadingButtons = { + // "+" button to open tools bottom sheet + Box { + FilledTonalIconButton( + onClick = { showToolsSheet = true }, + modifier = Modifier + .size(48.dp) + .semantics { + contentDescription = cdOpenToolsMenu + role = Role.Button + }, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + ) + } + // Active tools badge + if (hasActiveTools) { + Box( + modifier = Modifier + .size(10.dp) + .align(Alignment.TopEnd) + .offset(x = (-2).dp, y = 2.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + ) + } + } + + // Paste image button (shown only when clipboard has image content) + if (onImagePasted != null) { + IconButton( + onClick = { + // Clipboard image pasting is handled by the screen + onImagePasted(Uri.EMPTY) + }, + modifier = Modifier + .size(48.dp) + .semantics { + contentDescription = cdPasteImage + role = Role.Button + }, + ) { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.width(8.dp)) + }, + textFieldContent = { + Box(modifier = Modifier.weight(1f)) { + OutlinedTextField( + value = textFieldValue, + onValueChange = { newValue -> + textFieldValue = newValue + onInputChanged(newValue.text) + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp, max = 160.dp) + .focusRequester(focusRequester) + .semantics { + contentDescription = cdMessageInput + }, + placeholder = { + ChatInputPlaceholder( + isRecording = isRecording, + selectedModelDisplay = selectedModelDisplay, + ) + }, + textStyle = MaterialTheme.typography.bodyLarge, + shape = ChatInputDefaults.shape, + colors = ChatInputDefaults.textFieldColors(), + keyboardOptions = ChatInputDefaults.keyboardOptions, + keyboardActions = KeyboardActions.Default, + maxLines = 6, + trailingIcon = { + IconButton( + onClick = { + if (isRecording) { + onStopRecording() + } else { + onStartRecording() + } + }, + modifier = Modifier + .size(40.dp) + .semantics { + contentDescription = if (isRecording) { + cdStopVoiceRec + } else { + cdStartVoiceRec + } + role = Role.Button + }, + enabled = !isTranscribing, + ) { + VoiceMicIndicator( + isRecording = isRecording, + isTranscribing = isTranscribing, + ) + } + }, + ) + + // @mention dropdown + DropdownMenu( + expanded = filteredPrompts.isNotEmpty() && filteredSlashCommands.isEmpty(), + onDismissRequest = { /* Dismissed by typing or selecting */ }, + ) { + filteredPrompts.forEach { group -> + DropdownMenuItem( + text = { + Column { + Text( + text = group.name, + style = MaterialTheme.typography.bodyMedium, + ) + val oneliner = group.oneliner + if (!oneliner.isNullOrBlank()) { + Text( + text = oneliner, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + onClick = { onPromptSelected(group) }, + ) + } + } + + // Slash command dropdown + SlashCommandMenu( + filteredCommands = filteredSlashCommands, + onCommandSelected = onSlashCommandSelected, + ) + } + }, + trailingSpacer = { + Spacer(modifier = Modifier.width(8.dp)) + }, + ) + + // Tools bottom sheet + if (showToolsSheet) { + ChatToolsBottomSheet( + enabledTools = enabledTools, + onToggleTool = onToggleTool, + mcpServers = mcpServers, + selectedMcpServerNames = selectedMcpServerNames, + onToggleMcpServer = onToggleMcpServer, + onAttachFiles = { filePickerLauncher.launch(arrayOf("*/*")) }, + onTakePhoto = onTakePhoto, + onPickPhotos = onPickPhotos, + onOpenModelParameters = onOpenModelParameters, + onOpenModelSelector = onOpenModelSelector, + selectedModelDisplay = selectedModelDisplay, + onDismiss = { showToolsSheet = false }, + isCodeInterpreterAvailable = isCodeInterpreterAvailable, + ) + } +} + +/** + * Creates a temporary file for the camera to write a photo into. + * Stored in the app's cache directory under `camera_photos/` which is + * registered in the FileProvider paths XML. + */ +private fun createCameraPhotoFile(context: android.content.Context): File { + val cameraDir = File(context.cacheDir, "camera_photos") + if (!cameraDir.exists()) { + cameraDir.mkdirs() + } + return File.createTempFile("photo_", ".jpg", cameraDir) +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ContentPartRenderer.kt b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/ContentPartRenderer.kt similarity index 95% rename from feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ContentPartRenderer.kt rename to feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/ContentPartRenderer.kt index fbec98c..8a87f74 100644 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ContentPartRenderer.kt +++ b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/ContentPartRenderer.kt @@ -40,18 +40,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import coil.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImage import com.librechat.android.core.common.ToolConstants import com.librechat.android.core.model.AgentToolCall import com.librechat.android.core.model.ContentType import com.librechat.android.core.model.MessageContentPart -import com.librechat.android.feature.chat.R +import librechat_android.feature.chat.generated.resources.Res +import librechat_android.feature.chat.generated.resources.* import com.librechat.android.feature.chat.components.artifact.ArtifactButton import com.librechat.android.feature.chat.components.artifact.ArtifactPanel import com.librechat.android.feature.chat.components.artifact.ArtifactSegment @@ -69,17 +70,17 @@ import kotlinx.serialization.json.jsonPrimitive private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true } @Composable -fun ContentPartRenderer( +actual fun ContentPartRenderer( part: MessageContentPart, - modifier: Modifier = Modifier, - baseUrl: String = "", - fontSizeMultiplier: Float = 1.0f, - useKatex: Boolean = false, - attachments: List = emptyList(), - showImageDescriptions: Boolean = true, - searchQuery: String? = null, - searchFocusedOccurrence: Int = -1, - onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, + modifier: Modifier, + baseUrl: String, + fontSizeMultiplier: Float, + useKatex: Boolean, + attachments: List, + showImageDescriptions: Boolean, + searchQuery: String?, + searchFocusedOccurrence: Int, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?, ) { val constrainedModifier = modifier.fillMaxWidth() when (part.type) { @@ -251,7 +252,7 @@ fun ContentPartRenderer( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = stringResource(R.string.video_not_supported), + text = stringResource(Res.string.video_not_supported), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -269,7 +270,7 @@ fun ContentPartRenderer( val agentUpdate = part.agentUpdate AgentHandoffCard( handoff = AgentHandoff( - fromAgent = agentUpdate?.agentId?.let { stringResource(R.string.cd_agent_handoff).format(it, "") }, + fromAgent = agentUpdate?.agentId?.let { stringResource(Res.string.cd_agent_handoff).format(it, "") }, toAgent = part.agentId?.let { "Agent $it" }, reason = agentUpdate?.runId?.let { "Run: $it" }, ), @@ -282,7 +283,7 @@ fun ContentPartRenderer( @Composable private fun TextContentPart( text: String, - modifier: Modifier = Modifier, + modifier: Modifier, fontSizeMultiplier: Float = 1.0f, useKatex: Boolean = false, searchQuery: String? = null, @@ -350,7 +351,7 @@ private fun TextContentPart( @Composable private fun ThinkingContentPart( thinkingText: String, - modifier: Modifier = Modifier, + modifier: Modifier, fontSizeMultiplier: Float = 1.0f, useKatex: Boolean = false, searchQuery: String? = null, @@ -359,7 +360,7 @@ private fun ThinkingContentPart( ) { var isExpanded by remember { mutableStateOf(false) } val thinkingToggleCd = - stringResource(if (isExpanded) R.string.cd_collapse_thinking else R.string.cd_expand_thinking) + stringResource(if (isExpanded) Res.string.cd_collapse_thinking else Res.string.cd_expand_thinking) Column( modifier = modifier @@ -381,20 +382,20 @@ private fun ThinkingContentPart( ) { Icon( imageVector = Icons.Default.Psychology, - contentDescription = stringResource(R.string.cd_thinking_indicator), + contentDescription = stringResource(Res.string.cd_thinking_indicator), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = stringResource(R.string.label_thinking), + text = stringResource(Res.string.label_thinking), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f), ) Icon( imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = stringResource(if (isExpanded) R.string.cd_collapse else R.string.cd_expand), + contentDescription = stringResource(if (isExpanded) Res.string.cd_collapse else Res.string.cd_expand), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -425,7 +426,7 @@ private fun ToolCallContentPart( toolName: String, args: String?, output: String?, - modifier: Modifier = Modifier, + modifier: Modifier, ) { var isExpanded by remember { mutableStateOf(false) } @@ -438,7 +439,7 @@ private fun ToolCallContentPart( ) { Column(modifier = Modifier.padding(12.dp)) { val toolCallCd = - stringResource(if (isExpanded) R.string.cd_collapse_tool_call else R.string.cd_expand_tool_call, toolName) + stringResource(if (isExpanded) Res.string.cd_collapse_tool_call else Res.string.cd_expand_tool_call, toolName) Row( modifier = Modifier .fillMaxWidth() @@ -451,7 +452,7 @@ private fun ToolCallContentPart( ) { Icon( imageVector = Icons.Default.Build, - contentDescription = stringResource(R.string.cd_tool_call), + contentDescription = stringResource(Res.string.cd_tool_call), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -464,7 +465,7 @@ private fun ToolCallContentPart( ) Icon( imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = stringResource(if (isExpanded) R.string.cd_collapse else R.string.cd_expand), + contentDescription = stringResource(if (isExpanded) Res.string.cd_collapse else Res.string.cd_expand), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -479,7 +480,7 @@ private fun ToolCallContentPart( if (!args.isNullOrBlank()) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.label_input), + text = stringResource(Res.string.label_input), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -489,7 +490,7 @@ private fun ToolCallContentPart( if (!output.isNullOrBlank()) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.label_output), + text = stringResource(Res.string.label_output), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -505,7 +506,7 @@ private fun ToolCallContentPart( @Composable private fun ImageContentPart( imageUrl: String?, - modifier: Modifier = Modifier, + modifier: Modifier, ) { if (imageUrl == null) return @@ -513,7 +514,7 @@ private fun ImageContentPart( SubcomposeAsyncImage( model = imageUrl, - contentDescription = stringResource(R.string.cd_embedded_image), + contentDescription = stringResource(Res.string.cd_embedded_image), contentScale = ContentScale.FillWidth, modifier = modifier .fillMaxWidth() @@ -550,7 +551,7 @@ private fun ImageContentPart( ) { Icon( imageVector = Icons.Default.BrokenImage, - contentDescription = stringResource(R.string.cd_failed_to_load_image), + contentDescription = stringResource(Res.string.cd_failed_to_load_image), modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -569,7 +570,7 @@ private fun ImageContentPart( @Composable private fun ErrorContentPart( errorText: String, - modifier: Modifier = Modifier, + modifier: Modifier, ) { Row( modifier = modifier diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/FullscreenImageViewer.kt b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/FullscreenImageViewer.kt similarity index 88% rename from feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/FullscreenImageViewer.kt rename to feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/FullscreenImageViewer.kt index 81ce2ab..3eca464 100644 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/FullscreenImageViewer.kt +++ b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/FullscreenImageViewer.kt @@ -5,7 +5,6 @@ import android.content.ContentValues import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap -import android.graphics.drawable.BitmapDrawable import android.os.Build import android.os.Environment import android.provider.MediaStore @@ -44,26 +43,28 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import androidx.core.content.FileProvider -import coil.compose.AsyncImage -import coil.imageLoader -import coil.request.ImageRequest -import com.librechat.android.feature.chat.R +import coil3.compose.AsyncImage +import coil3.SingletonImageLoader +import coil3.request.ImageRequest +import coil3.toBitmap +import librechat_android.feature.chat.generated.resources.Res +import librechat_android.feature.chat.generated.resources.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @Composable -fun FullscreenImageViewer( +actual fun FullscreenImageViewer( imageUrl: String, onDismiss: () -> Unit, - modifier: Modifier = Modifier, + modifier: Modifier, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -75,11 +76,11 @@ fun FullscreenImageViewer( val request = ImageRequest.Builder(context) .data(imageUrl) .build() - val result = context.imageLoader.execute(request) - val bitmap = (result.drawable as? BitmapDrawable)?.bitmap + val result = SingletonImageLoader.get(context).execute(request) + val bitmap = result.image?.toBitmap() if (bitmap == null) { withContext(Dispatchers.Main) { - Toast.makeText(context, context.getString(R.string.toast_failed_to_load_image), Toast.LENGTH_SHORT) + Toast.makeText(context, "Failed to load image", Toast.LENGTH_SHORT) .show() } return@launch @@ -110,7 +111,7 @@ fun FullscreenImageViewer( withContext(Dispatchers.Main) { Toast.makeText( context, - context.getString(R.string.toast_failed_to_save_image), + "Failed to save image", Toast.LENGTH_SHORT, ).show() } @@ -133,14 +134,14 @@ fun FullscreenImageViewer( } withContext(Dispatchers.Main) { - Toast.makeText(context, context.getString(R.string.toast_image_saved), Toast.LENGTH_SHORT) + Toast.makeText(context, "Image saved to gallery", Toast.LENGTH_SHORT) .show() } } catch (e: Exception) { withContext(Dispatchers.Main) { Toast.makeText( context, - context.getString(R.string.toast_failed_to_save_with_error, e.localizedMessage ?: ""), + "Failed to save image: ${e.localizedMessage ?: ""}", Toast.LENGTH_SHORT, ).show() } @@ -159,7 +160,7 @@ fun FullscreenImageViewer( saveImageToGallery() } else if (!isGranted) { pendingSaveAfterPermission = false - Toast.makeText(context, context.getString(R.string.toast_storage_permission_required), Toast.LENGTH_SHORT) + Toast.makeText(context, "Storage permission is required to save images", Toast.LENGTH_SHORT) .show() } } @@ -196,7 +197,7 @@ fun FullscreenImageViewer( AsyncImage( model = imageUrl, - contentDescription = stringResource(R.string.cd_fullscreen_image), + contentDescription = stringResource(Res.string.cd_fullscreen_image), contentScale = ContentScale.Fit, modifier = Modifier .fillMaxSize() @@ -231,7 +232,7 @@ fun FullscreenImageViewer( ) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_close), + contentDescription = stringResource(Res.string.cd_close), ) } @@ -251,7 +252,7 @@ fun FullscreenImageViewer( ) { Icon( imageVector = Icons.Outlined.SaveAlt, - contentDescription = stringResource(R.string.cd_save_to_device), + contentDescription = stringResource(Res.string.cd_save_to_device), ) } @@ -264,8 +265,8 @@ fun FullscreenImageViewer( val request = ImageRequest.Builder(context) .data(imageUrl) .build() - val result = context.imageLoader.execute(request) - val bitmap = (result.drawable as? BitmapDrawable)?.bitmap + val result = SingletonImageLoader.get(context).execute(request) + val bitmap = result.image?.toBitmap() ?: return@launch val imagesDir = File(context.cacheDir, "shared_images") @@ -311,7 +312,7 @@ fun FullscreenImageViewer( ) { Icon( imageVector = Icons.Default.Share, - contentDescription = stringResource(R.string.cd_share_image), + contentDescription = stringResource(Res.string.cd_share_image), ) } } diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LatexBlock.kt b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/LatexBlock.kt similarity index 95% rename from feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LatexBlock.kt rename to feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/LatexBlock.kt index e785881..343a7c7 100644 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/LatexBlock.kt +++ b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/LatexBlock.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import timber.log.Timber +import co.touchlab.kermit.Logger /** * Escapes a LaTeX string for safe embedding inside a JavaScript string literal. @@ -112,7 +112,7 @@ new MutationObserver(reportHeight).observe(document.body, { childList: true, sub @Composable private fun NativeLatexBlock( latex: String, - modifier: Modifier = Modifier, + modifier: Modifier, ) { Text( text = latex, @@ -136,7 +136,7 @@ private fun NativeLatexBlock( @Composable private fun NativeLatexInline( latex: String, - modifier: Modifier = Modifier, + modifier: Modifier, ) { Text( text = latex, @@ -159,7 +159,7 @@ private fun NativeLatexInline( @Composable private fun KatexLatexBlock( latex: String, - modifier: Modifier = Modifier, + modifier: Modifier, ) { val textColorArgb = MaterialTheme.colorScheme.onSurface.toArgb() val textColorCss = remember(textColorArgb) { argbToCss(textColorArgb) } @@ -202,7 +202,7 @@ private fun KatexLatexBlock( error: SslError?, ) { handler?.cancel() - Timber.w("SSL error in LaTeX block WebView: ${error?.primaryError}") + Logger.w { "SSL error in LaTeX block WebView: ${error?.primaryError}" } } } loadDataWithBaseURL( @@ -234,7 +234,7 @@ private fun KatexLatexBlock( @Composable private fun KatexLatexInline( latex: String, - modifier: Modifier = Modifier, + modifier: Modifier, ) { val textColorArgb = MaterialTheme.colorScheme.onSurface.toArgb() val textColorCss = remember(textColorArgb) { argbToCss(textColorArgb) } @@ -274,7 +274,7 @@ private fun KatexLatexInline( error: SslError?, ) { handler?.cancel() - Timber.w("SSL error in LaTeX inline WebView: ${error?.primaryError}") + Logger.w { "SSL error in LaTeX inline WebView: ${error?.primaryError}" } } } loadDataWithBaseURL( @@ -310,10 +310,10 @@ private fun KatexLatexInline( * When false, shows the raw LaTeX source as styled monospace text (fast, no WebView). */ @Composable -fun LatexBlock( +actual fun LatexBlock( latex: String, - modifier: Modifier = Modifier, - useKatex: Boolean = false, + modifier: Modifier, + useKatex: Boolean, ) { if (useKatex) { KatexLatexBlock(latex = latex, modifier = modifier) @@ -328,10 +328,10 @@ fun LatexBlock( * When false, shows the raw LaTeX source as styled monospace text (fast, no WebView). */ @Composable -fun LatexInline( +actual fun LatexInline( latex: String, - modifier: Modifier = Modifier, - useKatex: Boolean = false, + modifier: Modifier, + useKatex: Boolean, ) { if (useKatex) { KatexLatexInline(latex = latex, modifier = modifier) diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MarkdownContent.kt b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/MarkdownContent.kt similarity index 98% rename from feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MarkdownContent.kt rename to feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/MarkdownContent.kt index b0227c6..6f7be13 100644 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MarkdownContent.kt +++ b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/MarkdownContent.kt @@ -44,7 +44,7 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle @@ -60,7 +60,8 @@ import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import com.librechat.android.feature.chat.R +import librechat_android.feature.chat.generated.resources.Res +import librechat_android.feature.chat.generated.resources.* import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownTypography @@ -86,14 +87,14 @@ import com.mikepenz.markdown.m3.markdownTypography * laid out. Used by the parent to fine-tune scroll position within a long message. */ @Composable -fun MarkdownContent( +actual fun MarkdownContent( text: String, - modifier: Modifier = Modifier, - fontSizeMultiplier: Float = 1.0f, - useKatex: Boolean = false, - searchQuery: String? = null, - searchFocusedOccurrence: Int = -1, - onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, + modifier: Modifier, + fontSizeMultiplier: Float, + useKatex: Boolean, + searchQuery: String?, + searchFocusedOccurrence: Int, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?, ) { val segments = remember(text) { parseMarkdownSegments(text) } val isSearchActive = !searchQuery.isNullOrBlank() @@ -751,7 +752,7 @@ private fun MarkdownTableWithFullscreen( headers: List, alignments: List, rows: List>, - modifier: Modifier = Modifier, + modifier: Modifier, fontSizeMultiplier: Float = 1.0f, ) { var showFullscreen by remember { mutableStateOf(false) } @@ -776,7 +777,7 @@ private fun MarkdownTableWithFullscreen( ) { Icon( imageVector = Icons.Default.Fullscreen, - contentDescription = stringResource(R.string.cd_expand_table), + contentDescription = stringResource(Res.string.cd_expand_table), modifier = Modifier.size(18.dp), ) } @@ -816,12 +817,12 @@ private fun FullscreenTableDialog( .background(MaterialTheme.colorScheme.surface), ) { TopAppBar( - title = { Text(stringResource(R.string.dialog_table)) }, + title = { Text(stringResource(Res.string.dialog_table)) }, navigationIcon = { IconButton(onClick = onDismiss) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_close), + contentDescription = stringResource(Res.string.cd_close), ) } }, diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MermaidDiagram.kt b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/MermaidDiagram.kt similarity index 80% rename from feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MermaidDiagram.kt rename to feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/MermaidDiagram.kt index 9ee48de..a915460 100644 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MermaidDiagram.kt +++ b/feature/chat/src/androidMain/kotlin/com/librechat/android/feature/chat/components/MermaidDiagram.kt @@ -42,18 +42,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import com.librechat.android.feature.chat.R -import timber.log.Timber +import librechat_android.feature.chat.generated.resources.Res +import librechat_android.feature.chat.generated.resources.* +import co.touchlab.kermit.Logger @Composable -fun MermaidDiagram( +actual fun MermaidDiagram( code: String, - modifier: Modifier = Modifier, + modifier: Modifier, ) { var showCode by remember { mutableStateOf(false) } var showFullscreen by remember { mutableStateOf(false) } @@ -80,7 +81,7 @@ fun MermaidDiagram( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.label_mermaid), + text = stringResource(Res.string.label_mermaid), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -90,19 +91,19 @@ fun MermaidDiagram( ) { Icon( imageVector = if (showCode) Icons.Default.Image else Icons.Default.Code, - contentDescription = stringResource(if (showCode) R.string.cd_show_diagram else R.string.cd_view_code), + contentDescription = stringResource(if (showCode) Res.string.cd_show_diagram else Res.string.cd_view_code), modifier = Modifier.size(16.dp), ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = stringResource(if (showCode) R.string.label_diagram else R.string.code), + text = stringResource(if (showCode) Res.string.label_diagram else Res.string.code), style = MaterialTheme.typography.labelSmall, ) } IconButton(onClick = { showFullscreen = true }) { Icon( imageVector = Icons.Default.Fullscreen, - contentDescription = stringResource(R.string.cd_fullscreen), + contentDescription = stringResource(Res.string.cd_fullscreen), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -158,7 +159,7 @@ fun MermaidDiagram( ) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_close_fullscreen), + contentDescription = stringResource(Res.string.cd_close_fullscreen), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -172,7 +173,7 @@ fun MermaidDiagram( private fun MermaidWebView( code: String, isDarkTheme: Boolean, - modifier: Modifier = Modifier, + modifier: Modifier, ) { val bgColor = MaterialTheme.colorScheme.surfaceContainerHighest.toArgb() val escapedCode = remember(code) { @@ -189,6 +190,8 @@ private fun MermaidWebView( buildMermaidHtml(escapedCode, theme) } + var loadedHtml by remember { mutableStateOf("") } + AndroidView( modifier = modifier, factory = { context -> @@ -212,7 +215,7 @@ private fun MermaidWebView( error: SslError?, ) { handler?.cancel() - Timber.w("SSL error in Mermaid WebView: ${error?.primaryError}") + Logger.w { "SSL error in Mermaid WebView: ${error?.primaryError}" } } } loadDataWithBaseURL( @@ -222,17 +225,21 @@ private fun MermaidWebView( "UTF-8", null, ) + loadedHtml = html } }, update = { webView -> webView.setBackgroundColor(bgColor) - webView.loadDataWithBaseURL( - "https://cdn.jsdelivr.net", - html, - "text/html", - "UTF-8", - null, - ) + if (html != loadedHtml) { + webView.loadDataWithBaseURL( + "https://cdn.jsdelivr.net", + html, + "text/html", + "UTF-8", + null, + ) + loadedHtml = html + } }, onRelease = { webView -> webView.stopLoading() @@ -276,6 +283,37 @@ private fun buildMermaidHtml(escapedCode: String, theme: String): String {
+ + + + + + + """.trimIndent() +} + +@OptIn(ExperimentalForeignApi::class) +@Composable +actual fun MermaidDiagram( + code: String, + modifier: Modifier, +) { + var showCode by remember { mutableStateOf(false) } + var showFullscreen by remember { mutableStateOf(false) } + + val isDarkTheme = MaterialTheme.colorScheme.surface.let { color -> + val r = (color.red * 255).toInt() + val g = (color.green * 255).toInt() + val b = (color.blue * 255).toInt() + (r * 0.299 + g * 0.587 + b * 0.114) < 128 + } + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.label_mermaid), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = { showCode = !showCode }) { + Icon( + imageVector = if (showCode) Icons.Default.Image else Icons.Default.Code, + contentDescription = stringResource(if (showCode) Res.string.cd_show_diagram else Res.string.cd_view_code), + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(if (showCode) Res.string.label_diagram else Res.string.label_code), + style = MaterialTheme.typography.labelSmall, + ) + } + IconButton(onClick = { showFullscreen = true }) { + Icon( + imageVector = Icons.Default.Fullscreen, + contentDescription = stringResource(Res.string.cd_fullscreen), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Content + AnimatedContent( + targetState = showCode, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "mermaid_content", + ) { isCode -> + if (isCode) { + CodeBlock(code = code, language = "mermaid") + } else { + MermaidWKWebView( + code = code, + isDarkTheme = isDarkTheme, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .padding(8.dp), + ) + } + } + } + + if (showFullscreen) { + Dialog( + onDismissRequest = { showFullscreen = false }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + MermaidWKWebView( + code = code, + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize().padding(16.dp), + ) + IconButton( + onClick = { showFullscreen = false }, + modifier = Modifier.align(Alignment.TopEnd).padding(8.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(Res.string.cd_close_fullscreen), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +@OptIn(ExperimentalForeignApi::class) +@Composable +private fun MermaidWKWebView( + code: String, + isDarkTheme: Boolean, + modifier: Modifier, +) { + val escapedCode = remember(code) { + code.replace("\\", "\\\\") + .replace("`", "\\`") + .replace("$", "\\$") + .replace("\"", "\\\"") + .replace("<", "\\u003c") + .replace("\n", "\\n") + } + val theme = if (isDarkTheme) "dark" else "default" + val html = remember(escapedCode, theme) { buildMermaidHtml(escapedCode, theme) } + + UIKitView( + modifier = modifier, + factory = { + val config = WKWebViewConfiguration() + val webView = WKWebView(frame = kotlinx.cinterop.cValue { }, configuration = config) + webView.setOpaque(false) + webView.loadHTMLString(html, baseURL = NSURL.URLWithString("https://cdn.jsdelivr.net")) + webView + }, + update = { webView -> + webView.loadHTMLString(html, baseURL = NSURL.URLWithString("https://cdn.jsdelivr.net")) + }, + ) +} + +private fun buildMermaidHtml(escapedCode: String, theme: String): String { + return """ + + + + + + + +
+ + + + + """.trimIndent() +} + +@Composable +actual fun FullscreenImageViewer( + imageUrl: String, + onDismiss: () -> Unit, + modifier: Modifier, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.9f)), + ) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + AsyncImage( + model = imageUrl, + contentDescription = stringResource(Res.string.cd_fullscreen_image), + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offset.x, + translationY = offset.y, + ) + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(0.5f, 5f) + offset = Offset( + x = offset.x + pan.x, + y = offset.y + pan.y, + ) + } + }, + ) + + // Close button + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + .padding(16.dp) + .size(40.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.5f), + contentColor = Color.White, + ), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(Res.string.cd_close), + ) + } + + // Share button + IconButton( + onClick = { + shareUrl(imageUrl) + }, + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(16.dp) + .size(40.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.5f), + contentColor = Color.White, + ), + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(Res.string.cd_share_image), + ) + } + } + } +} + +private fun getRootViewController(): platform.UIKit.UIViewController? { + val scene = UIApplication.sharedApplication.connectedScenes.firstOrNull() as? platform.UIKit.UIWindowScene + return scene?.keyWindow?.rootViewController +} + +private fun shareUrl(url: String) { + val viewController = getRootViewController() ?: return + val activityVC = UIActivityViewController( + activityItems = listOf(url), + applicationActivities = null, + ) + viewController.presentViewController(activityVC, animated = true, completion = null) +} + +actual fun shareArtifact(title: String, content: String, language: String) { + val viewController = getRootViewController() ?: return + val activityVC = UIActivityViewController( + activityItems = listOf(content), + applicationActivities = null, + ) + viewController.presentViewController(activityVC, animated = true, completion = null) +} + +private fun formatDurationIos(seconds: Int): String { + val m = seconds / 60 + val s = seconds % 60 + return "$m:${s.toString().padStart(2, '0')}" +} diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/components/artifact/PlatformArtifactPanel.ios.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/components/artifact/PlatformArtifactPanel.ios.kt new file mode 100644 index 0000000..52ed994 --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/components/artifact/PlatformArtifactPanel.ios.kt @@ -0,0 +1,204 @@ +package com.librechat.android.feature.chat.components.artifact + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.interop.UIKitView +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.librechat.android.feature.chat.components.shareArtifact +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSURL +import platform.WebKit.WKWebView +import platform.WebKit.WKWebViewConfiguration + +@OptIn(ExperimentalForeignApi::class) +@Composable +actual fun ArtifactPanel( + artifact: Artifact, + onDismiss: () -> Unit, + modifier: Modifier, + versions: List, +) { + var currentVersionIndex by remember { + mutableIntStateOf(versions.indexOfFirst { it.identifier == artifact.identifier && it.version == artifact.version }.coerceAtLeast(0)) + } + val currentArtifact = versions.getOrElse(currentVersionIndex) { artifact } + + val isDarkTheme = MaterialTheme.colorScheme.surface.let { color -> + val r = (color.red * 255).toInt() + val g = (color.green * 255).toInt() + val b = (color.blue * 255).toInt() + (r * 0.299 + g * 0.587 + b * 0.114) < 128 + } + + val html = remember(currentArtifact, isDarkTheme) { + buildArtifactHtml(currentArtifact, isDarkTheme) + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + text = currentArtifact.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (versions.size > 1) { + ArtifactVersionNav( + currentIndex = currentVersionIndex, + totalVersions = versions.size, + onPrevious = { + if (currentVersionIndex > 0) currentVersionIndex-- + }, + onNext = { + if (currentVersionIndex < versions.size - 1) currentVersionIndex++ + }, + ) + } + IconButton( + onClick = { + shareArtifact( + title = currentArtifact.title, + content = currentArtifact.content, + language = currentArtifact.language ?: "", + ) + }, + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + + // WebView content + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + UIKitView( + modifier = Modifier.fillMaxSize(), + factory = { + val config = WKWebViewConfiguration().apply { + defaultWebpagePreferences.allowsContentJavaScript = true + } + val webView = WKWebView( + frame = kotlinx.cinterop.cValue { }, + configuration = config, + ) + webView.setOpaque(false) + webView.loadHTMLString( + html, + baseURL = NSURL.URLWithString("https://cdn.jsdelivr.net"), + ) + webView + }, + update = { webView -> + webView.loadHTMLString( + html, + baseURL = NSURL.URLWithString("https://cdn.jsdelivr.net"), + ) + }, + ) + } + } + } +} + +private fun buildArtifactHtml(artifact: Artifact, isDarkTheme: Boolean): String { + return when { + artifact.type.contains("mermaid") -> { + MermaidWebContent.buildHtml(artifact.content, isDarkTheme) + } + artifact.type.contains("markdown") || artifact.type == "text/md" -> { + MarkdownWebContent.buildHtml(artifact.content, isDarkTheme) + } + artifact.type.contains("html") || artifact.type.contains("react") || artifact.type.contains("svg") -> { + // Render HTML/React/SVG directly + val bgColor = if (isDarkTheme) "#1C1B1F" else "#FFFBFE" + val fgColor = if (isDarkTheme) "#E6E1E5" else "#1C1B1F" + """ + + + + + + + ${artifact.content} + + """.trimIndent() + } + else -> { + // Plain text / code + val bgColor = if (isDarkTheme) "#1C1B1F" else "#FFFBFE" + val fgColor = if (isDarkTheme) "#E6E1E5" else "#1C1B1F" + val escaped = artifact.content + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + """ + + + + + + +
$escaped
+ + """.trimIndent() + } + } +} diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/di/ChatPlatformModule.ios.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/di/ChatPlatformModule.ios.kt new file mode 100644 index 0000000..9f34952 --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/di/ChatPlatformModule.ios.kt @@ -0,0 +1,39 @@ +package com.librechat.android.feature.chat.di + +import com.librechat.android.feature.chat.viewmodel.ChatViewModel +import com.librechat.android.feature.chat.viewmodel.delegate.IosDelegateFactory +import com.librechat.android.feature.chat.viewmodel.delegate.PlatformDelegateFactory +import org.koin.core.module.Module +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +actual val chatPlatformModule: Module = module { + single { + IosDelegateFactory( + fileRepository = get(), + speechRepository = get(), + settingsDataStore = get(), + ) + } + + viewModel { + ChatViewModel( + savedStateHandle = get(), + agentRepository = get(), + chatRepository = get(), + messageRepository = get(), + configRepository = get(), + conversationRepository = get(), + draftRepository = get(), + presetRepository = get(), + promptRepository = get(), + shareRepository = get(), + mcpRepository = get(), + userRepository = get(), + connectivityObserver = get(), + serverDataStore = get(), + settingsDataStore = get(), + platformDelegateFactory = get(), + ) + } +} diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/screen/PlatformScreens.ios.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/screen/PlatformScreens.ios.kt new file mode 100644 index 0000000..7d13a8a --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/screen/PlatformScreens.ios.kt @@ -0,0 +1,622 @@ +package com.librechat.android.feature.chat.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Compare +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.FileOpen +import androidx.compose.material.icons.outlined.SaveAs +import androidx.compose.material.icons.outlined.Share +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.core.common.EndpointConstants +import com.librechat.android.core.data.datastore.ChatFontSize +import com.librechat.android.core.data.datastore.LatexRenderer +import com.librechat.android.feature.chat.components.InConvoSearchBar +import com.librechat.android.feature.chat.components.IosChatInput +import com.librechat.android.feature.chat.components.LandingContent +import com.librechat.android.feature.chat.components.MessageList +import com.librechat.android.feature.chat.components.ModelSelectorButton +import com.librechat.android.feature.chat.components.ModelSelectorSheet +import com.librechat.android.feature.chat.components.PresetPicker +import com.librechat.android.feature.chat.components.SavePresetDialog +import com.librechat.android.feature.chat.components.TempChatToggle +import com.librechat.android.feature.chat.util.clipboardHasImage +import com.librechat.android.feature.chat.util.openCamera +import com.librechat.android.feature.chat.util.openDocumentPicker +import com.librechat.android.feature.chat.util.openPhotoPicker +import com.librechat.android.feature.chat.util.readClipboardImage +import com.librechat.android.feature.chat.viewmodel.ChatScreenState +import com.librechat.android.feature.chat.viewmodel.ChatViewModel +import librechat_android.feature.chat.generated.resources.Res +import librechat_android.feature.chat.generated.resources.* +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun ChatScreen( + modifier: Modifier, + onConversationStarted: ((String) -> Unit)?, + onNavigateToConversation: ((String) -> Unit)?, + onOpenDrawer: (() -> Unit)?, + onNavigateToPromptsLibrary: (() -> Unit)?, + onNavigateBack: (() -> Unit)?, +) { + val viewModel: ChatViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle() + val prefs by viewModel.chatPreferences.collectAsStateWithLifecycle() + + val isLandingPage = uiState.screenState == ChatScreenState.LANDING + val isLoading = uiState.screenState == ChatScreenState.LOADING + val hasMessages = uiState.displayMessages.isNotEmpty() || uiState.isStreaming + + val useKatex = prefs.latexRenderer == LatexRenderer.KATEX + val fontSizeMultiplier = when (uiState.chatFontSize) { + ChatFontSize.SMALL -> 0.85f + ChatFontSize.MEDIUM -> 1.0f + ChatFontSize.LARGE -> 1.2f + } + + // Resolve agent name for model display + val agentName = if (uiState.selectedEndpoint == EndpointConstants.AGENTS && uiState.selectedModel != null) { + uiState.agents.find { it.id == uiState.selectedModel }?.name + } else { + null + } + val displayModel = agentName ?: uiState.selectedModel + + // Navigate when a new conversation is created from landing page. + // Navigate first, then reset — matches Android order so the new + // ViewModel can resume the active stream before the old one cancels it. + if (onConversationStarted != null) { + LaunchedEffect(uiState.pendingNavigationConversationId) { + val navId = uiState.pendingNavigationConversationId + if (navId != null) { + onConversationStarted(navId) + viewModel.onPendingNavigationHandled() + } + } + } + + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + var showOptionsMenu by remember { mutableStateOf(false) } + var showModelSheet by remember { mutableStateOf(false) } + var showPresetPicker by remember { mutableStateOf(false) } + var showSavePresetDialog by remember { mutableStateOf(false) } + var showRenameDialog by remember { mutableStateOf(false) } + + // Check clipboard for image content + var hasClipboardImage by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + hasClipboardImage = clipboardHasImage() + } + + // Show errors in snackbar (matches Android behavior) + LaunchedEffect(uiState.error) { + val error = uiState.error + if (error != null) { + snackbarHostState.showSnackbar( + message = error, + actionLabel = "Dismiss", + ) + viewModel.dismissError() + } + } + + Scaffold( + modifier = modifier.imePadding(), + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + Column { + TopAppBar( + title = { + val title = uiState.conversationTitle + if (!isLandingPage && title != null) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else if (!isLandingPage) { + // Model selector only in active chat (not landing page) + ModelSelectorButton( + modelName = displayModel, + onClick = { showModelSheet = true }, + ) + } + }, + navigationIcon = { + if (onOpenDrawer != null) { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(Res.string.cd_open_drawer), + ) + } + } + }, + actions = { + // Temp chat toggle — only on landing page + if (isLandingPage) { + TempChatToggle( + isTemporary = uiState.isTemporaryChat, + onToggle = viewModel::toggleTemporaryChat, + ) + } + // Options menu + Box { + IconButton(onClick = { showOptionsMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(Res.string.cd_more_options), + ) + } + DropdownMenu( + expanded = showOptionsMenu, + onDismissRequest = { showOptionsMenu = false }, + shape = RoundedCornerShape(16.dp), + ) { + // Search — only in active conversation + if (uiState.conversationId != null) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.menu_search)) }, + onClick = { + showOptionsMenu = false + viewModel.openSearch() + }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = null) + }, + ) + } + // Presets + DropdownMenuItem( + text = { Text(stringResource(Res.string.load_preset)) }, + onClick = { + showOptionsMenu = false + showPresetPicker = true + }, + leadingIcon = { + Icon(Icons.Outlined.FileOpen, contentDescription = null) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.save_as_preset)) }, + onClick = { + showOptionsMenu = false + showSavePresetDialog = true + }, + leadingIcon = { + Icon(Icons.Outlined.SaveAs, contentDescription = null) + }, + ) + // Prompts Library + if (onNavigateToPromptsLibrary != null) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.prompts_library)) }, + onClick = { + showOptionsMenu = false + onNavigateToPromptsLibrary() + }, + leadingIcon = { + Icon(Icons.Outlined.AutoAwesome, contentDescription = null) + }, + ) + } + // Compare Models + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(Res.string.compare_models), + modifier = Modifier.weight(1f), + ) + if (uiState.comparisonState.isEnabled) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Filled.Check, + contentDescription = stringResource(Res.string.cd_comparison_enabled), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + }, + onClick = { + showOptionsMenu = false + viewModel.toggleComparison() + }, + leadingIcon = { + Icon(Icons.Outlined.Compare, contentDescription = null) + }, + ) + // Conversation-specific actions + if (uiState.conversationId != null) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + Text( + text = uiState.conversationTitle ?: stringResource(Res.string.new_chat), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + if (uiState.sharedLinksEnabled) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.action_share)) }, + onClick = { + showOptionsMenu = false + viewModel.shareConversation() + }, + leadingIcon = { + Icon(Icons.Outlined.Share, contentDescription = null) + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(Res.string.menu_rename)) }, + onClick = { + showOptionsMenu = false + showRenameDialog = true + }, + leadingIcon = { + Icon(Icons.Outlined.Edit, contentDescription = null) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.action_duplicate)) }, + onClick = { + showOptionsMenu = false + viewModel.duplicateConversation() + }, + leadingIcon = { + Icon(Icons.Outlined.ContentCopy, contentDescription = null) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.action_archive)) }, + onClick = { + showOptionsMenu = false + viewModel.archiveConversation() + }, + leadingIcon = { + Icon(Icons.Outlined.Archive, contentDescription = null) + }, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + DropdownMenuItem( + text = { + Text( + stringResource(Res.string.delete), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + showOptionsMenu = false + viewModel.deleteConversation() + }, + leadingIcon = { + Icon( + Icons.Outlined.DeleteOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + + // In-conversation search bar + AnimatedVisibility( + visible = uiState.isSearchOpen, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + InConvoSearchBar( + query = uiState.searchQuery, + onQueryChanged = viewModel::onSearchQueryChanged, + currentMatchIndex = uiState.currentSearchMatchIndex, + totalMatches = uiState.searchMatchIndices.size, + onPreviousMatch = viewModel::previousSearchMatch, + onNextMatch = viewModel::nextSearchMatch, + onClose = viewModel::closeSearch, + ) + } + } + }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = paddingValues.calculateTopPadding()), + ) { + when { + isLandingPage && !hasMessages -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + LandingContent( + selectedModel = uiState.selectedModel, + selectedAgentName = agentName, + ) + } + } + isLoading && uiState.displayMessages.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(36.dp)) + } + } + else -> { + MessageList( + displayMessages = uiState.displayMessages, + isStreaming = uiState.isStreaming, + streamingContent = uiState.streamingContent, + activeToolCalls = uiState.activeToolCalls, + onSiblingNavigation = viewModel::switchBranch, + onEditMessage = viewModel::startEditing, + onRegenerateMessage = { messageId -> viewModel.regenerateMessage(messageId) }, + onCopyMessage = { messageId -> viewModel.getMessageText(messageId) }, + onFeedback = viewModel::submitFeedback, + onContinue = { viewModel.continueGeneration() }, + onReadAloud = viewModel::readAloud, + onFork = viewModel::showForkOptions, + currentlyReadingMessageId = uiState.currentlyReadingMessageId, + editingMessageId = uiState.editingMessageId, + editingText = uiState.editingText, + onEditTextChanged = viewModel::onEditTextChanged, + onEditSaveAndSubmit = viewModel::submitEdit, + onEditSaveOnly = viewModel::saveEditOnly, + onEditCancel = viewModel::cancelEditing, + baseUrl = uiState.serverUrl, + fontSizeMultiplier = fontSizeMultiplier, + isRefreshing = uiState.isRefreshingMessages, + onRefresh = viewModel::refreshMessages, + userAvatarUrl = uiState.userAvatarUrl, + userName = uiState.userName, + selectedEndpoint = uiState.selectedEndpoint, + streamingSenderName = run { + val model = uiState.selectedModel + if (uiState.selectedEndpoint == EndpointConstants.AGENTS && model != null) { + uiState.agents.find { it.id == model }?.name ?: model + } else { + model ?: stringResource(Res.string.sender_assistant) + } + }, + showImageDescriptions = prefs.showImageDescriptions, + chatLayoutStyle = prefs.chatLayoutStyle, + showAvatars = prefs.showAvatars, + showBubbles = prefs.showBubbles, + useKatex = useKatex, + searchQuery = if (uiState.isSearchOpen) uiState.searchQuery else null, + searchMatchIndices = uiState.searchMatchIndices, + currentSearchMatchIndex = uiState.currentSearchMatchIndex, + searchScrollToIndex = uiState.searchScrollToIndex, + onSearchScrollHandled = viewModel::onSearchScrollHandled, + modifier = Modifier.fillMaxSize(), + ) + } + } + + // ChatInput overlays at the bottom so gradient shows content behind + IosChatInput( + inputText = uiState.inputText, + isStreaming = uiState.isStreaming, + onInputChanged = viewModel::onInputChanged, + onSend = { + viewModel.sendMessage() + }, + onStop = viewModel::stopGeneration, + enabledTools = uiState.effectiveEnabledTools, + onToggleTool = viewModel::toggleTool, + mcpServers = uiState.mcpServers, + selectedMcpServerNames = uiState.selectedMcpServerNames, + onToggleMcpServer = viewModel::toggleMcpServer, + isRecording = uiState.isRecording, + isTranscribing = uiState.isTranscribing, + onStartRecording = viewModel::startRecording, + onStopRecording = viewModel::stopRecording, + onOpenModelParameters = viewModel::showModelParameters, + onOpenModelSelector = { showModelSheet = true }, + selectedModelDisplay = displayModel, + isCodeInterpreterAvailable = uiState.isCodeInterpreterAvailable, + attachedFiles = attachedFiles, + onRemoveFile = viewModel::removeFile, + hasClipboardImage = hasClipboardImage, + onPasteImage = { + val imageData = readClipboardImage() + if (imageData != null) { + viewModel.onFilesSelected(listOf(imageData)) + } + // Re-check clipboard after paste + hasClipboardImage = clipboardHasImage() + }, + onAttachFiles = { + openDocumentPicker { files -> + if (files.isNotEmpty()) { + viewModel.onFilesSelected(files) + } + } + }, + onTakePhoto = { + openCamera { files -> + if (files.isNotEmpty()) { + viewModel.onFilesSelected(files) + } + } + }, + onPickPhotos = { + openPhotoPicker { files -> + if (files.isNotEmpty()) { + viewModel.onFilesSelected(files) + } + } + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } + + // Model selector bottom sheet + if (showModelSheet) { + ModelSelectorSheet( + endpointConfigs = uiState.endpointConfigs, + availableModels = uiState.availableModels, + agents = uiState.agents, + selectedEndpoint = uiState.selectedEndpoint, + selectedModel = uiState.selectedModel, + onModelSelected = { endpoint, model -> + viewModel.onModelSelected(endpoint, model) + showModelSheet = false + }, + onDismiss = { showModelSheet = false }, + serverUrl = uiState.serverUrl, + ) + } + + // Preset picker dialog + if (showPresetPicker) { + PresetPicker( + presets = uiState.presets, + onPresetSelected = { preset -> + viewModel.loadPreset(preset) + showPresetPicker = false + }, + onDismiss = { showPresetPicker = false }, + onEditPreset = { preset -> + viewModel.loadPreset(preset) + showPresetPicker = false + }, + onDeletePreset = { preset -> + preset.presetId?.let { viewModel.deletePreset(it) } + }, + ) + } + + // Save preset dialog + if (showSavePresetDialog) { + SavePresetDialog( + currentEndpoint = uiState.selectedEndpoint ?: "", + currentModel = uiState.selectedModel, + onSave = { name -> + viewModel.savePreset(name) + showSavePresetDialog = false + }, + onDismiss = { showSavePresetDialog = false }, + ) + } + + // Rename dialog + if (showRenameDialog) { + var title by remember { mutableStateOf(uiState.conversationTitle ?: "") } + AlertDialog( + onDismissRequest = { showRenameDialog = false }, + title = { Text(stringResource(Res.string.dialog_title_rename)) }, + text = { + Column { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(Res.string.hint_title)) }, + ) + } + }, + confirmButton = { + TextButton( + onClick = { + viewModel.renameConversation(title) + showRenameDialog = false + }, + enabled = title.isNotBlank(), + ) { + Text(stringResource(Res.string.action_rename)) + } + }, + dismissButton = { + TextButton(onClick = { showRenameDialog = false }) { + Text(stringResource(Res.string.cancel)) + } + }, + ) + } +} + +@Composable +actual fun NewChatScreen( + onConversationStarted: (String) -> Unit, + modifier: Modifier, + onOpenDrawer: (() -> Unit)?, + onNavigateToPromptsLibrary: (() -> Unit)?, +) { + ChatScreen( + modifier = modifier, + onConversationStarted = onConversationStarted, + onOpenDrawer = onOpenDrawer, + onNavigateToPromptsLibrary = onNavigateToPromptsLibrary, + ) +} diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/IosClipboardImageReader.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/IosClipboardImageReader.kt new file mode 100644 index 0000000..e3c2e5f --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/IosClipboardImageReader.kt @@ -0,0 +1,61 @@ +package com.librechat.android.feature.chat.util + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.CoreGraphics.CGImageGetHeight +import platform.CoreGraphics.CGImageGetWidth +import platform.Foundation.NSData +import platform.Foundation.NSDate +import platform.Foundation.timeIntervalSince1970 +import platform.UIKit.UIImage +import platform.UIKit.UIImagePNGRepresentation +import platform.UIKit.UIPasteboard +import platform.posix.memcpy + +/** + * Checks whether the system clipboard currently contains an image. + */ +fun clipboardHasImage(): Boolean { + return UIPasteboard.generalPasteboard.hasImages +} + +/** + * Reads the first image from the system clipboard and returns it as [IosImageData], + * or null if no image is available. + * + * The image is converted to PNG format for consistent server-side handling. + */ +@OptIn(ExperimentalForeignApi::class) +fun readClipboardImage(): IosImageData? { + val image: UIImage = UIPasteboard.generalPasteboard.image ?: return null + + val pngData: NSData = UIImagePNGRepresentation(image) ?: return null + val bytes = pngData.toByteArray() ?: return null + if (bytes.isEmpty()) return null + + val cgImage = image.CGImage + val width = if (cgImage != null) CGImageGetWidth(cgImage).toInt() else null + val height = if (cgImage != null) CGImageGetHeight(cgImage).toInt() else null + + val timestamp = NSDate().timeIntervalSince1970.toLong() + + return IosImageData( + bytes = bytes, + filename = "clipboard_${timestamp}.png", + mimeType = "image/png", + width = width, + height = height, + ) +} + +@OptIn(ExperimentalForeignApi::class) +private fun NSData.toByteArray(): ByteArray? { + val length = this.length.toInt() + if (length == 0) return null + val bytes = ByteArray(length) + bytes.usePinned { pinned -> + memcpy(pinned.addressOf(0), this.bytes, this.length) + } + return bytes +} diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/IosFileData.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/IosFileData.kt new file mode 100644 index 0000000..f2d0f64 --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/IosFileData.kt @@ -0,0 +1,11 @@ +package com.librechat.android.feature.chat.util + +/** + * Wrapper for general file data from iOS sources (document picker, etc.). + * For non-image files. Passed through [PlatformFileHandler.onFilesSelected]. + */ +class IosFileData( + val bytes: ByteArray, + val filename: String, + val mimeType: String, +) diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/IosFilePicker.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/IosFilePicker.kt new file mode 100644 index 0000000..d1ab07d --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/IosFilePicker.kt @@ -0,0 +1,324 @@ +package com.librechat.android.feature.chat.util + +import co.touchlab.kermit.Logger +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.CoreGraphics.CGImageGetHeight +import platform.CoreGraphics.CGImageGetWidth +import platform.Foundation.NSData +import platform.Foundation.NSDate +import platform.Foundation.NSURL +import platform.Foundation.dataWithContentsOfURL +import platform.Foundation.lastPathComponent +import platform.Foundation.timeIntervalSince1970 +import platform.PhotosUI.PHPickerConfiguration +import platform.PhotosUI.PHPickerFilter +import platform.PhotosUI.PHPickerResult +import platform.PhotosUI.PHPickerViewController +import platform.PhotosUI.PHPickerViewControllerDelegateProtocol +import platform.UIKit.UIApplication +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIImage +import platform.UIKit.UIImageJPEGRepresentation +import platform.UIKit.UIImagePickerController +import platform.UIKit.UIImagePickerControllerDelegateProtocol +import platform.UIKit.UIImagePickerControllerOriginalImage +import platform.UIKit.UIImagePickerControllerSourceType +import platform.UIKit.UINavigationControllerDelegateProtocol +import platform.UIKit.UIViewController +import platform.UIKit.UIWindowScene +import platform.UniformTypeIdentifiers.UTType +import platform.UniformTypeIdentifiers.UTTypeContent +import platform.darwin.NSObject +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue +import platform.posix.memcpy + +/** + * Strong reference to the currently active picker delegate. + * Prevents Kotlin/Native GC from collecting the delegate while the picker is open. + * Only one picker can be open at a time, so a single reference suffices. + */ +private var activePickerDelegate: NSObject? = null + +private fun getRootViewController(): UIViewController? { + val scene = UIApplication.sharedApplication.connectedScenes.firstOrNull() as? UIWindowScene + return scene?.keyWindow?.rootViewController?.let { root -> + var top = root + while (top.presentedViewController != null) { + top = top.presentedViewController!! + } + top + } +} + +/** + * Opens UIDocumentPickerViewController for general file selection. + * Calls [onResult] with a list of [IosFileData] or [IosImageData]. + */ +fun openDocumentPicker(onResult: (List) -> Unit) { + dispatch_async(dispatch_get_main_queue()) { + val viewController = getRootViewController() ?: run { + Logger.w { "IosFilePicker: no root view controller" } + return@dispatch_async + } + + val types = listOf(UTTypeContent ?: UTType.typeWithIdentifier("public.content")!!) + val picker = UIDocumentPickerViewController(forOpeningContentTypes = types) + picker.allowsMultipleSelection = true + + val delegate = DocumentPickerDelegate(onResult) + picker.delegate = delegate + activePickerDelegate = delegate + + viewController.presentViewController(picker, animated = true, completion = null) + } +} + +/** + * Opens PHPickerViewController for photo/image selection. + */ +fun openPhotoPicker(onResult: (List) -> Unit) { + dispatch_async(dispatch_get_main_queue()) { + val viewController = getRootViewController() ?: run { + Logger.w { "IosFilePicker: no root view controller" } + return@dispatch_async + } + + val config = PHPickerConfiguration() + config.selectionLimit = 10 + config.filter = PHPickerFilter.imagesFilter + + val picker = PHPickerViewController(configuration = config) + val delegate = PhotoPickerDelegate(onResult) + picker.delegate = delegate + activePickerDelegate = delegate + + viewController.presentViewController(picker, animated = true, completion = null) + } +} + +/** + * Opens UIImagePickerController for camera capture. + * On simulator (no camera hardware), calls onResult with empty list. + */ +fun openCamera(onResult: (List) -> Unit) { + dispatch_async(dispatch_get_main_queue()) { + val viewController = getRootViewController() ?: run { + Logger.w { "IosFilePicker: no root view controller" } + return@dispatch_async + } + + if (!UIImagePickerController.isSourceTypeAvailable(UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypeCamera)) { + Logger.w { "IosFilePicker: camera not available (simulator?)" } + onResult(emptyList()) + return@dispatch_async + } + + val picker = UIImagePickerController() + picker.sourceType = UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypeCamera + picker.allowsEditing = false + + val delegate = CameraDelegate(onResult) + picker.delegate = delegate + activePickerDelegate = delegate + + viewController.presentViewController(picker, animated = true, completion = null) + } +} + +// ── Delegates ────────────────────────────────────────────────── + +private class DocumentPickerDelegate( + private val onResult: (List) -> Unit, +) : NSObject(), UIDocumentPickerDelegateProtocol { + + @OptIn(ExperimentalForeignApi::class) + override fun documentPicker( + controller: UIDocumentPickerViewController, + didPickDocumentsAtURLs: List<*>, + ) { + val results = mutableListOf() + for (item in didPickDocumentsAtURLs) { + val url = item as? NSURL ?: continue + val accessing = url.startAccessingSecurityScopedResource() + try { + val data = NSData.dataWithContentsOfURL(url) ?: continue + val bytes = data.toByteArray() ?: continue + val filename = url.lastPathComponent ?: "file" + val mimeType = guessMimeType(filename) + + if (mimeType.startsWith("image/")) { + val image = UIImage(data = data) + val cgImage = image?.CGImage + results.add( + IosImageData( + bytes = bytes, + filename = filename, + mimeType = mimeType, + width = cgImage?.let { CGImageGetWidth(it).toInt() }, + height = cgImage?.let { CGImageGetHeight(it).toInt() }, + ), + ) + } else { + results.add(IosFileData(bytes = bytes, filename = filename, mimeType = mimeType)) + } + } finally { + if (accessing) url.stopAccessingSecurityScopedResource() + } + } + activePickerDelegate = null + onResult(results) + } + + override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { + activePickerDelegate = null + } +} + +private class PhotoPickerDelegate( + private val onResult: (List) -> Unit, +) : NSObject(), PHPickerViewControllerDelegateProtocol { + + @OptIn(ExperimentalForeignApi::class) + override fun picker(picker: PHPickerViewController, didFinishPicking: List<*>) { + picker.dismissViewControllerAnimated(true, completion = null) + + if (didFinishPicking.isEmpty()) { + activePickerDelegate = null + onResult(emptyList()) + return + } + + val results = mutableListOf() + var remaining = didFinishPicking.size + + for (item in didFinishPicking) { + val pickerResult = item as? PHPickerResult + if (pickerResult == null) { + remaining-- + if (remaining == 0) onResult(results) + continue + } + val provider = pickerResult.itemProvider + if (provider.hasItemConformingToTypeIdentifier("public.image")) { + provider.loadDataRepresentationForTypeIdentifier("public.image") { data, error -> + val imageData = data?.let { nsData -> + val bytes = nsData.toByteArray() + if (bytes != null) { + val filename = provider.suggestedName ?: "photo" + val image = UIImage(data = nsData) + val cgImage = image?.CGImage + val ext = if (filename.contains(".")) "" else ".jpg" + IosImageData( + bytes = bytes, + filename = "$filename$ext", + mimeType = "image/jpeg", + width = cgImage?.let { CGImageGetWidth(it).toInt() }, + height = cgImage?.let { CGImageGetHeight(it).toInt() }, + ) + } else null + } + if (imageData == null) { + Logger.e { "PHPicker: failed to load image: ${error?.localizedDescription}" } + } + dispatch_async(dispatch_get_main_queue()) { + if (imageData != null) results.add(imageData) + remaining-- + if (remaining == 0) { + activePickerDelegate = null + onResult(results) + } + } + } + } else { + remaining-- + if (remaining == 0) onResult(results) + } + } + } +} + +@OptIn(ExperimentalForeignApi::class) +private class CameraDelegate( + private val onResult: (List) -> Unit, +) : NSObject(), UIImagePickerControllerDelegateProtocol, UINavigationControllerDelegateProtocol { + + override fun imagePickerController( + picker: UIImagePickerController, + didFinishPickingMediaWithInfo: Map, + ) { + picker.dismissViewControllerAnimated(true, completion = null) + activePickerDelegate = null + + val image = didFinishPickingMediaWithInfo[UIImagePickerControllerOriginalImage] as? UIImage + if (image != null) { + val data = UIImageJPEGRepresentation(image, 0.85) ?: return + val bytes = data.toByteArray() ?: return + val cgImage = image.CGImage + val width = cgImage?.let { CGImageGetWidth(it).toInt() } + val height = cgImage?.let { CGImageGetHeight(it).toInt() } + val timestamp = NSDate().timeIntervalSince1970.toLong() + onResult( + listOf( + IosImageData( + bytes = bytes, + filename = "photo_$timestamp.jpg", + mimeType = "image/jpeg", + width = width, + height = height, + ), + ), + ) + } + } + + override fun imagePickerControllerDidCancel(picker: UIImagePickerController) { + picker.dismissViewControllerAnimated(true, completion = null) + activePickerDelegate = null + } +} + +// ── Utilities ────────────────────────────────────────────────── + +@OptIn(ExperimentalForeignApi::class) +private fun NSData.toByteArray(): ByteArray? { + val length = this.length.toInt() + if (length == 0) return null + val bytes = ByteArray(length) + bytes.usePinned { pinned -> + memcpy(pinned.addressOf(0), this@toByteArray.bytes, this@toByteArray.length) + } + return bytes +} + +private fun guessMimeType(filename: String): String { + val ext = filename.substringAfterLast('.', "").lowercase() + return when (ext) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "webp" -> "image/webp" + "heic", "heif" -> "image/heic" + "pdf" -> "application/pdf" + "txt" -> "text/plain" + "csv" -> "text/csv" + "json" -> "application/json" + "xml" -> "application/xml" + "zip" -> "application/zip" + "doc" -> "application/msword" + "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + "xls" -> "application/vnd.ms-excel" + "xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + "ppt" -> "application/vnd.ms-powerpoint" + "pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation" + "mp3" -> "audio/mpeg" + "wav" -> "audio/wav" + "mp4" -> "video/mp4" + "mov" -> "video/quicktime" + else -> "application/octet-stream" + } +} + diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/IosImageData.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/IosImageData.kt new file mode 100644 index 0000000..8443835 --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/IosImageData.kt @@ -0,0 +1,13 @@ +package com.librechat.android.feature.chat.util + +/** + * Wrapper for image data from iOS sources (clipboard, photo picker, etc.). + * Passed as the platform reference through [PlatformFileHandler.onFilesSelected]. + */ +class IosImageData( + val bytes: ByteArray, + val filename: String, + val mimeType: String, + val width: Int? = null, + val height: Int? = null, +) diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/PlatformClipboard.ios.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/PlatformClipboard.ios.kt new file mode 100644 index 0000000..c659d54 --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/PlatformClipboard.ios.kt @@ -0,0 +1,7 @@ +package com.librechat.android.feature.chat.util + +import platform.UIKit.UIPasteboard + +actual fun copyToClipboard(text: String, label: String) { + UIPasteboard.generalPasteboard.string = text +} diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/TimestampFormatter.ios.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/TimestampFormatter.ios.kt new file mode 100644 index 0000000..a3879be --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/util/TimestampFormatter.ios.kt @@ -0,0 +1,58 @@ +package com.librechat.android.feature.chat.util + +import platform.Foundation.NSDateFormatter +import platform.Foundation.NSDate +import platform.Foundation.NSLocale +import platform.Foundation.NSTimeZone +import platform.Foundation.timeIntervalSince1970 +import platform.Foundation.timeZoneWithName +import platform.Foundation.localeWithLocaleIdentifier + +actual fun formatRelativeTimestamp(isoTimestamp: String): String { + val date = parseTimestamp(isoTimestamp) ?: return "" + val nowSeconds = NSDate().timeIntervalSince1970 + val diff = (nowSeconds - date.timeIntervalSince1970).toLong() + if (diff < 0) return "now" + + val seconds = diff + val minutes = diff / 60 + val hours = diff / 3600 + val days = diff / 86400 + + return when { + seconds < 60 -> "now" + minutes < 60 -> "${minutes}m ago" + hours < 24 -> "${hours}h ago" + days < 7 -> "${days}d ago" + days < 30 -> "${days / 7}w ago" + else -> formatAbsoluteTimestamp(isoTimestamp) + } +} + +actual fun formatAbsoluteTimestamp(isoTimestamp: String): String { + val date = parseTimestamp(isoTimestamp) ?: return "" + val formatter = NSDateFormatter().apply { + dateFormat = "MMM d, yyyy h:mm a" + locale = NSLocale.localeWithLocaleIdentifier("en_US") + } + return formatter.stringFromDate(date) +} + +private fun parseTimestamp(timestamp: String): NSDate? { + val formats = listOf( + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", + "yyyy-MM-dd'T'HH:mm:ss'Z'", + "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "yyyy-MM-dd'T'HH:mm:ssXXX", + ) + for (pattern in formats) { + val formatter = NSDateFormatter().apply { + dateFormat = pattern + locale = NSLocale.localeWithLocaleIdentifier("en_US_POSIX") + timeZone = NSTimeZone.timeZoneWithName("UTC")!! + } + val date = formatter.dateFromString(timestamp) + if (date != null) return date + } + return null +} diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosDelegateFactory.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosDelegateFactory.kt new file mode 100644 index 0000000..7502c9c --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosDelegateFactory.kt @@ -0,0 +1,40 @@ +package com.librechat.android.feature.chat.viewmodel.delegate + +import com.librechat.android.core.data.datastore.SettingsDataStore +import com.librechat.android.core.data.repository.FileRepository +import com.librechat.android.core.data.repository.SpeechRepository +import com.librechat.android.feature.chat.viewmodel.ChatStateHandle + +class IosDelegateFactory( + private val fileRepository: FileRepository, + private val speechRepository: SpeechRepository, + private val settingsDataStore: SettingsDataStore, +) : PlatformDelegateFactory { + + override fun createFileHandler(stateHandle: ChatStateHandle): PlatformFileHandler { + return IosFileHandler(stateHandle, fileRepository) + } + + override fun createVoiceInput( + stateHandle: ChatStateHandle, + onTranscriptionComplete: () -> Unit, + ): PlatformVoiceInput { + return IosVoiceInput(stateHandle, onTranscriptionComplete) + } + + override fun createTts( + stateHandle: ChatStateHandle, + getMessageText: (String) -> String, + ): PlatformTts { + return IosTts( + stateHandle = stateHandle, + speechRepository = speechRepository, + settingsDataStore = settingsDataStore, + getMessageText = getMessageText, + ) + } + + override fun createShareConsumer(): PlatformShareConsumer { + return IosShareConsumer() + } +} diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosFileHandler.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosFileHandler.kt new file mode 100644 index 0000000..c354cad --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosFileHandler.kt @@ -0,0 +1,242 @@ +package com.librechat.android.feature.chat.viewmodel.delegate + +import co.touchlab.kermit.Logger +import com.librechat.android.core.common.EndpointConstants +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.FileRepository +import com.librechat.android.core.model.FileReference +import com.librechat.android.feature.chat.components.AttachedFile +import com.librechat.android.feature.chat.util.IosFileData +import com.librechat.android.feature.chat.util.IosImageData +import com.librechat.android.feature.chat.viewmodel.ChatStateHandle +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.flow.update +import kotlinx.coroutines.launch +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * iOS implementation of [PlatformFileHandler]. + * Handles file uploads from clipboard paste and (future) photo picker. + */ +class IosFileHandler( + private val stateHandle: ChatStateHandle, + private val fileRepository: FileRepository, +) : PlatformFileHandler { + private val _attachedFiles = MutableStateFlow>(emptyList()) + override val attachedFiles: StateFlow> = _attachedFiles.asStateFlow() + override var pendingUploadSendJob: Job? = null + + override fun onFilesSelected(platformRefs: List) { + platformRefs.forEach { ref -> + when (ref) { + is IosImageData -> uploadImage(ref) + is IosFileData -> uploadFile(ref) + } + } + } + + @OptIn(ExperimentalUuidApi::class) + private fun uploadImage(imageData: IosImageData) { + val uniqueId = Uuid.random().toString() + + val pendingFile = AttachedFile( + uri = uniqueId, + name = imageData.filename, + isImage = true, + uploadProgress = 0f, + type = imageData.mimeType, + ) + _attachedFiles.update { it + pendingFile } + + stateHandle.scope.launch { + try { + _attachedFiles.update { list -> + list.map { f -> if (f.uri == uniqueId) f.copy(uploadProgress = 0.5f) else f } + } + + val state = stateHandle.state + val isAgent = state.selectedEndpoint == EndpointConstants.AGENTS + val fileId = Uuid.random().toString() + + val result = fileRepository.uploadFile( + bytes = imageData.bytes, + filename = imageData.filename, + type = imageData.mimeType, + fileId = fileId, + endpoint = state.selectedEndpoint, + model = if (!isAgent) state.selectedModel else null, + agentId = if (isAgent) state.selectedModel else null, + messageFile = true, + width = imageData.width, + height = imageData.height, + ) + + when (result) { + is Result.Success -> { + val fileObject = result.data + Logger.d { "IosFileHandler: upload success -- fileId=${fileObject.fileId}" } + _attachedFiles.update { list -> + list.map { f -> + if (f.uri == uniqueId) { + f.copy( + uploadProgress = 1f, + fileId = fileObject.fileId, + filepath = fileObject.filepath, + type = fileObject.type, + width = fileObject.width, + height = fileObject.height, + ) + } else f + } + } + } + is Result.Error -> { + Logger.e(result.exception) { "IosFileHandler: upload failed -- ${result.message}" } + _attachedFiles.update { list -> + list.map { f -> + if (f.uri == uniqueId) f.copy(uploadFailed = true, uploadProgress = null) else f + } + } + stateHandle.update { + copy(error = "Failed to upload ${imageData.filename}: ${result.message ?: "Unknown error"}") + } + } + is Result.Loading -> { /* unexpected */ } + } + } catch (e: Exception) { + Logger.e(e) { "IosFileHandler: unexpected exception for ${imageData.filename}" } + _attachedFiles.update { list -> + list.map { f -> + if (f.uri == uniqueId) f.copy(uploadFailed = true, uploadProgress = null) else f + } + } + stateHandle.update { copy(error = "Failed to upload ${imageData.filename}: ${e.message}") } + } + } + } + + @OptIn(ExperimentalUuidApi::class) + private fun uploadFile(fileData: IosFileData) { + val uniqueId = Uuid.random().toString() + + val pendingFile = AttachedFile( + uri = uniqueId, + name = fileData.filename, + isImage = false, + uploadProgress = 0f, + type = fileData.mimeType, + ) + _attachedFiles.update { it + pendingFile } + + stateHandle.scope.launch { + try { + _attachedFiles.update { list -> + list.map { f -> if (f.uri == uniqueId) f.copy(uploadProgress = 0.5f) else f } + } + + val state = stateHandle.state + val isAgent = state.selectedEndpoint == EndpointConstants.AGENTS + val fileId = Uuid.random().toString() + + val result = fileRepository.uploadFile( + bytes = fileData.bytes, + filename = fileData.filename, + type = fileData.mimeType, + fileId = fileId, + endpoint = state.selectedEndpoint, + model = if (!isAgent) state.selectedModel else null, + agentId = if (isAgent) state.selectedModel else null, + messageFile = true, + ) + + when (result) { + is Result.Success -> { + val fileObject = result.data + Logger.d { "IosFileHandler: file upload success -- fileId=${fileObject.fileId}" } + _attachedFiles.update { list -> + list.map { f -> + if (f.uri == uniqueId) { + f.copy( + uploadProgress = 1f, + fileId = fileObject.fileId, + filepath = fileObject.filepath, + type = fileObject.type, + ) + } else f + } + } + } + is Result.Error -> { + Logger.e(result.exception) { "IosFileHandler: file upload failed -- ${result.message}" } + _attachedFiles.update { list -> + list.map { f -> + if (f.uri == uniqueId) f.copy(uploadFailed = true, uploadProgress = null) else f + } + } + stateHandle.update { + copy(error = "Failed to upload ${fileData.filename}: ${result.message ?: "Unknown error"}") + } + } + is Result.Loading -> { /* unexpected */ } + } + } catch (e: Exception) { + Logger.e(e) { "IosFileHandler: unexpected exception for ${fileData.filename}" } + _attachedFiles.update { list -> + list.map { f -> + if (f.uri == uniqueId) f.copy(uploadFailed = true, uploadProgress = null) else f + } + } + stateHandle.update { copy(error = "Failed to upload ${fileData.filename}: ${e.message}") } + } + } + } + + override fun removeFile(file: AttachedFile) { + _attachedFiles.update { list -> list.filter { it.uri != file.uri } } + } + + override fun retryUpload(file: AttachedFile) { + // Can't retry clipboard paste — user must paste again + removeFile(file) + } + + override fun buildFileReferences(): List { + return _attachedFiles.value + .filter { it.fileId != null } + .map { file -> + FileReference( + fileId = file.fileId, + filename = file.name, + filepath = file.filepath, + type = file.type, + width = file.width, + height = file.height, + ) + } + } + + override suspend fun waitForUploadsAndSend(text: String, doSend: (String) -> Unit) { + val timeoutMs = 30_000L + val pollIntervalMs = 200L + var elapsed = 0L + while (elapsed < timeoutMs) { + val pending = _attachedFiles.value.any { it.fileId == null && !it.uploadFailed } + if (!pending) break + delay(pollIntervalMs) + elapsed += pollIntervalMs + } + doSend(text) + } + + override fun hasPendingUploads(): Boolean = + _attachedFiles.value.any { it.fileId == null && !it.uploadFailed } + + override fun clearAttachedFiles() { + _attachedFiles.update { emptyList() } + } +} diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosShareConsumer.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosShareConsumer.kt new file mode 100644 index 0000000..8040ba3 --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosShareConsumer.kt @@ -0,0 +1,13 @@ +package com.librechat.android.feature.chat.viewmodel.delegate + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +/** + * iOS no-op implementation of [PlatformShareConsumer]. + * iOS doesn't use Android share intents. + */ +class IosShareConsumer : PlatformShareConsumer { + override val shareAvailable: SharedFlow = MutableSharedFlow() + override fun consume(): ShareData? = null +} diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosTts.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosTts.kt new file mode 100644 index 0000000..1edd0c8 --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosTts.kt @@ -0,0 +1,144 @@ +package com.librechat.android.feature.chat.viewmodel.delegate + +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.SettingsDataStore +import com.librechat.android.core.data.repository.SpeechRepository +import com.librechat.android.feature.chat.viewmodel.ChatStateHandle +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import platform.AVFAudio.AVSpeechBoundary +import platform.AVFAudio.AVSpeechSynthesisVoice +import platform.AVFAudio.AVSpeechSynthesizer +import platform.AVFAudio.AVSpeechSynthesizerDelegateProtocol +import platform.AVFAudio.AVSpeechUtterance +import platform.Foundation.NSDate +import platform.Foundation.timeIntervalSince1970 +import platform.darwin.NSObject + +private const val DEFAULT_SPEECH_RATE = 0.5f +private const val MIN_SPEECH_RATE = 0.0f +private const val MAX_SPEECH_RATE = 1.0f + +class IosTts( + private val stateHandle: ChatStateHandle, + private val speechRepository: SpeechRepository, + private val settingsDataStore: SettingsDataStore, + private val getMessageText: (String) -> String, +) : PlatformTts { + + private val synthesizer = AVSpeechSynthesizer() + + private val delegate = object : NSObject(), AVSpeechSynthesizerDelegateProtocol { + @kotlinx.cinterop.ObjCSignatureOverride + override fun speechSynthesizer( + synthesizer: AVSpeechSynthesizer, + didFinishSpeechUtterance: AVSpeechUtterance, + ) { + stateHandle.update { copy(currentlyReadingMessageId = null) } + } + + @kotlinx.cinterop.ObjCSignatureOverride + override fun speechSynthesizer( + synthesizer: AVSpeechSynthesizer, + didCancelSpeechUtterance: AVSpeechUtterance, + ) { + stateHandle.update { copy(currentlyReadingMessageId = null) } + } + } + + init { + synthesizer.delegate = delegate + } + + override fun readAloud(messageId: String) { + if (stateHandle.state.currentlyReadingMessageId == messageId) { + stopReading() + return + } + + stopReading() + + val text = getMessageText(messageId) + if (text.isBlank()) return + + stateHandle.update { copy(currentlyReadingMessageId = messageId) } + + stateHandle.scope.launch { + val source = settingsDataStore.ttsSource.first() + if (source == "server") { + readAloudViaServer(text) + } else { + val rate = settingsDataStore.ttsSpeechRate.first() + val pitch = settingsDataStore.ttsPitch.first() + readAloudViaDevice(text, rate, pitch) + } + } + } + + private fun readAloudViaDevice(text: String, speechRate: Float, pitch: Float) { + val utterance = AVSpeechUtterance.speechUtteranceWithString(text) + // AVSpeechUtterance rate: 0.0-1.0, default ~0.5 + // Android TTS rate: 0.1-4.0, default 1.0. Scale proportionally. + utterance.rate = (speechRate * DEFAULT_SPEECH_RATE).coerceIn(MIN_SPEECH_RATE, MAX_SPEECH_RATE) + // AVSpeechUtterance pitch: 0.5-2.0, default 1.0 (same as Android) + utterance.pitchMultiplier = pitch.coerceIn(0.5f, 2.0f) + utterance.voice = AVSpeechSynthesisVoice.voiceWithLanguage(null) + synthesizer.speakUtterance(utterance) + } + + private suspend fun readAloudViaServer(text: String) { + when (val result = speechRepository.synthesizeSpeech(text)) { + is Result.Success -> { + // Server TTS returns audio bytes; fall back to device TTS + // since AVAudioPlayer needs additional platform plumbing + val rate = settingsDataStore.ttsSpeechRate.first() + val pitch = settingsDataStore.ttsPitch.first() + readAloudViaDevice(text, rate, pitch) + } + is Result.Error -> { + stateHandle.update { + copy( + currentlyReadingMessageId = null, + error = result.message ?: "Server TTS request failed", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + + override fun stopReading() { + if (synthesizer.isSpeaking()) { + synthesizer.stopSpeakingAtBoundary(AVSpeechBoundary.AVSpeechBoundaryImmediate) + } + stateHandle.update { copy(currentlyReadingMessageId = null) } + } + + override fun maybeAutoReadResponse(responseText: String) { + if (stateHandle.state.inputText.isNotBlank()) return + + stateHandle.scope.launch { + val autoRead = settingsDataStore.autoReadEnabled.first() + if (!autoRead) return@launch + + val syntheticId = "auto_read_${NSDate().timeIntervalSince1970().toLong()}" + stateHandle.update { copy(currentlyReadingMessageId = syntheticId) } + + val source = settingsDataStore.ttsSource.first() + if (source == "server") { + readAloudViaServer(responseText) + } else { + val rate = settingsDataStore.ttsSpeechRate.first() + val pitch = settingsDataStore.ttsPitch.first() + readAloudViaDevice(responseText, rate, pitch) + } + } + } + + override fun release() { + if (synthesizer.isSpeaking()) { + synthesizer.stopSpeakingAtBoundary(AVSpeechBoundary.AVSpeechBoundaryImmediate) + } + synthesizer.delegate = null + } +} diff --git a/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosVoiceInput.kt b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosVoiceInput.kt new file mode 100644 index 0000000..5fef250 --- /dev/null +++ b/feature/chat/src/iosMain/kotlin/com/librechat/android/feature/chat/viewmodel/delegate/IosVoiceInput.kt @@ -0,0 +1,212 @@ +package com.librechat.android.feature.chat.viewmodel.delegate + +import com.librechat.android.feature.chat.viewmodel.ChatStateHandle +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.launch +import platform.AVFAudio.AVAudioEngine +import platform.AVFAudio.AVAudioSession +import platform.AVFAudio.AVAudioSessionCategoryPlayAndRecord +import platform.AVFAudio.AVAudioSessionModeDefault +import platform.AVFAudio.AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation +import platform.AVFAudio.setActive +import platform.Foundation.NSLocale +import platform.Foundation.currentLocale +import platform.Foundation.languageCode +import platform.Speech.SFSpeechAudioBufferRecognitionRequest +import platform.Speech.SFSpeechRecognitionTask +import platform.Speech.SFSpeechRecognizer +import platform.Speech.SFSpeechRecognizerAuthorizationStatus +import co.touchlab.kermit.Logger + +/** + * iOS implementation of [PlatformVoiceInput] using AVAudioEngine + SFSpeechRecognizer + * for real-time on-device speech recognition. + */ +class IosVoiceInput( + private val stateHandle: ChatStateHandle, + private val onTranscriptionComplete: () -> Unit, +) : PlatformVoiceInput { + + private val log = Logger.withTag("IosVoiceInput") + + private var audioEngine: AVAudioEngine? = null + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? = null + private var recognitionTask: SFSpeechRecognitionTask? = null + private var speechRecognizer: SFSpeechRecognizer? = null + + @OptIn(ExperimentalForeignApi::class) + override fun startRecording() { + log.d { "startRecording() called, isRecording=${stateHandle.state.isRecording}" } + if (stateHandle.state.isRecording) return + + // Check authorization + val authStatus = SFSpeechRecognizer.authorizationStatus() + log.d { "SFSpeechRecognizer authStatus=$authStatus" } + when (authStatus) { + SFSpeechRecognizerAuthorizationStatus.SFSpeechRecognizerAuthorizationStatusNotDetermined -> { + log.d { "Requesting speech recognition authorization..." } + SFSpeechRecognizer.requestAuthorization { status -> + log.d { "Authorization callback: status=$status" } + if (status == SFSpeechRecognizerAuthorizationStatus.SFSpeechRecognizerAuthorizationStatusAuthorized) { + stateHandle.scope.launch { beginRecording() } + } else { + log.w { "Speech recognition permission denied" } + stateHandle.update { copy(error = "Speech recognition permission denied") } + } + } + return + } + SFSpeechRecognizerAuthorizationStatus.SFSpeechRecognizerAuthorizationStatusAuthorized -> { + log.d { "Already authorized, beginning recording" } + beginRecording() + } + else -> { + log.w { "Speech recognition not available, authStatus=$authStatus" } + stateHandle.update { copy(error = "Speech recognition not available. Check Settings > Privacy > Speech Recognition.") } + return + } + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun beginRecording() { + try { + val locale = NSLocale.currentLocale + log.d { "beginRecording() locale=${locale.languageCode}" } + val recognizer = SFSpeechRecognizer(locale = locale) + val available = recognizer.isAvailable() + log.d { "SFSpeechRecognizer available=$available" } + if (!available) { + val msg = "Speech recognition not available on this device (locale: ${locale.languageCode}). This feature requires a real device." + log.w { msg } + stateHandle.update { copy(error = msg) } + return + } + speechRecognizer = recognizer + + // Configure audio session + log.d { "Configuring audio session..." } + val audioSession = AVAudioSession.sharedInstance() + audioSession.setCategory( + AVAudioSessionCategoryPlayAndRecord, + mode = AVAudioSessionModeDefault, + options = 0u, + error = null, + ) + audioSession.setActive(true, error = null) + log.d { "Audio session configured" } + + val engine = AVAudioEngine() + val request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + + recognitionRequest = request + audioEngine = engine + + log.d { "Creating recognition task..." } + val task = recognizer.recognitionTaskWithRequest(request) { result, error -> + if (result != null) { + val text = result.bestTranscription.formattedString + log.d { "Recognition result: '$text', isFinal=${result.isFinal()}" } + stateHandle.update { copy(inputText = text) } + + if (result.isFinal()) { + log.d { "Final result received, stopping" } + cleanupAudio() + stateHandle.update { copy(isRecording = false) } + } + } + if (error != null) { + log.e { "Recognition error: ${error.localizedDescription}, isRecording=${stateHandle.state.isRecording}" } + if (stateHandle.state.isRecording) { + cleanupAudio() + stateHandle.update { copy(isRecording = false, error = "Speech recognition error: ${error.localizedDescription}") } + } + } + } + recognitionTask = task + + // Install audio tap + val inputNode = engine.inputNode + val recordingFormat = inputNode.outputFormatForBus(0u) + log.d { "Installing audio tap, format=$recordingFormat" } + inputNode.installTapOnBus( + bus = 0u, + bufferSize = 1024u, + format = recordingFormat, + ) { buffer, _ -> + if (buffer != null) { + request.appendAudioPCMBuffer(buffer) + } + } + + engine.prepare() + engine.startAndReturnError(null) + log.d { "Audio engine started, setting isRecording=true" } + + stateHandle.update { copy(isRecording = true) } + } catch (e: Exception) { + log.e(e) { "Failed to start recording: ${e.message}" } + cleanupAudio() + stateHandle.update { copy(error = "Could not start recording: ${e.message}") } + } + } + + override fun stopRecording() { + if (!stateHandle.state.isRecording) return + + // End the recognition request so we get the final result + recognitionRequest?.endAudio() + cleanupAudio() + stateHandle.update { copy(isRecording = false) } + } + + override fun cancelRecording() { + recognitionTask?.cancel() + cleanupAudio() + stateHandle.update { copy(isRecording = false, inputText = "") } + } + + override fun onDeviceSpeechResult(transcribedText: String) { + if (transcribedText.isBlank()) return + val currentInput = stateHandle.state.inputText + val separator = if (currentInput.isNotBlank() && !currentInput.endsWith(" ")) " " else "" + stateHandle.update { + copy(inputText = currentInput + separator + transcribedText) + } + } + + override fun loadSpeechConfig() { + // iOS uses on-device speech recognition only; no server STT config needed + } + + override fun release() { + recognitionTask?.cancel() + cleanupAudio() + } + + @OptIn(ExperimentalForeignApi::class) + private fun cleanupAudio() { + audioEngine?.let { engine -> + if (engine.isRunning()) { + engine.stop() + engine.inputNode.removeTapOnBus(0u) + } + } + audioEngine = null + recognitionRequest = null + recognitionTask = null + speechRecognizer = null + + // Deactivate audio session + try { + AVAudioSession.sharedInstance().setActive( + active = false, + withOptions = AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation, + error = null, + ) + } catch (_: Exception) { + // Best effort + } + } +} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatInput.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatInput.kt deleted file mode 100644 index 2619244..0000000 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/ChatInput.kt +++ /dev/null @@ -1,618 +0,0 @@ -package com.librechat.android.feature.chat.components - -import android.Manifest -import android.content.pm.PackageManager -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ContentPaste -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.Stop -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.core.content.FileProvider -import com.librechat.android.feature.chat.McpServerDisplayData -import com.librechat.android.feature.chat.PromptMentionDisplayData -import com.librechat.android.feature.chat.R -import java.io.File - -data class AttachedFile( - val uri: Uri, - val name: String, - val isImage: Boolean = false, - val uploadProgress: Float? = null, - /** Server-assigned file ID after successful upload. Null while uploading. */ - val fileId: String? = null, - /** Server file path returned from upload. */ - val filepath: String? = null, - /** MIME type of the file. */ - val type: String? = null, - /** Image width in pixels (if applicable). */ - val width: Int? = null, - /** Image height in pixels (if applicable). */ - val height: Int? = null, - /** Whether the upload has failed. */ - val uploadFailed: Boolean = false, -) - -@Composable -fun ChatInput( - inputText: String, - isStreaming: Boolean, - onInputChanged: (String) -> Unit, - onSend: () -> Unit, - onStop: () -> Unit, - modifier: Modifier = Modifier, - attachedFiles: List = emptyList(), - onFilesSelected: (List) -> Unit = {}, - onRemoveFile: (AttachedFile) -> Unit = {}, - promptSuggestions: List = emptyList(), - onPromptSelected: (PromptMentionDisplayData) -> Unit = {}, - onSlashCommandSelected: (PromptMentionDisplayData) -> Unit = {}, - isRecording: Boolean = false, - isTranscribing: Boolean = false, - onStartRecording: () -> Unit = {}, - onStopRecording: () -> Unit = {}, - onImagePasted: ((Uri) -> Unit)? = null, - enabledTools: Set = emptySet(), - onToggleTool: (String) -> Unit = {}, - mcpServers: List = emptyList(), - selectedMcpServerNames: Set = emptySet(), - onToggleMcpServer: (String) -> Unit = {}, - onOpenModelParameters: () -> Unit = {}, - onOpenModelSelector: () -> Unit = {}, - selectedModelDisplay: String? = null, - isCodeInterpreterAvailable: Boolean = true, -) { - val cdOpenToolsMenu = stringResource(R.string.cd_open_tools_menu) - val cdPasteImage = stringResource(R.string.cd_paste_image) - val cdMessageInput = stringResource(R.string.cd_message_input) - val cdStopVoiceRec = stringResource(R.string.cd_stop_voice_recording) - val cdStartVoiceRec = stringResource(R.string.cd_start_voice_recording) - val cdStopGen = stringResource(R.string.cd_stop_generation) - val cdSendMsg = stringResource(R.string.cd_send_message) - val context = LocalContext.current - - val filePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenMultipleDocuments(), - ) { uris -> - if (uris.isNotEmpty()) { - onFilesSelected(uris) - } - } - - // Photo picker (gallery) launcher using modern PickMultipleVisualMedia - val photoPickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickMultipleVisualMedia(), - ) { uris -> - if (uris.isNotEmpty()) { - onFilesSelected(uris) - } - } - - // Camera launcher: stores the photo in a temp file via FileProvider - var cameraPhotoUri by remember { mutableStateOf(null) } - - val cameraLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.TakePicture(), - ) { success -> - val uri = cameraPhotoUri - if (success && uri != null) { - onFilesSelected(listOf(uri)) - } - cameraPhotoUri = null - } - - // Camera permission launcher - val cameraPermissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { isGranted -> - if (isGranted) { - val photoFile = createCameraPhotoFile(context) - val uri = FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - photoFile, - ) - cameraPhotoUri = uri - cameraLauncher.launch(uri) - } - } - - val onTakePhoto: () -> Unit = { - val hasCameraPermission = ContextCompat.checkSelfPermission( - context, - Manifest.permission.CAMERA, - ) == PackageManager.PERMISSION_GRANTED - if (hasCameraPermission) { - val photoFile = createCameraPhotoFile(context) - val uri = FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - photoFile, - ) - cameraPhotoUri = uri - cameraLauncher.launch(uri) - } else { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) - } - } - - val onPickPhotos: () -> Unit = { - photoPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), - ) - } - - val focusRequester = remember { FocusRequester() } - var showToolsSheet by remember { mutableStateOf(false) } - - // Use TextFieldValue internally so we can control cursor position. - // When inputText changes externally (e.g. STT result, prompt insertion), - // place the cursor at the end of the new text. - var textFieldValue by remember { - mutableStateOf(TextFieldValue(inputText, selection = TextRange(inputText.length))) - } - if (textFieldValue.text != inputText) { - textFieldValue = TextFieldValue(inputText, selection = TextRange(inputText.length)) - } - - val hasActiveTools by remember(enabledTools, selectedMcpServerNames) { - derivedStateOf { - enabledTools.isNotEmpty() || selectedMcpServerNames.isNotEmpty() - } - } - - val surfaceColor = MaterialTheme.colorScheme.surface - Box( - modifier = modifier - .fillMaxWidth() - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - surfaceColor.copy(alpha = 0.7f), - surfaceColor.copy(alpha = 0.95f), - ), - ), - ), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - // Floating attachment and tool indicator chips - AttachmentChipsRow( - attachedFiles = attachedFiles, - enabledTools = enabledTools, - onRemoveFile = onRemoveFile, - mcpServers = mcpServers, - selectedMcpServerNames = selectedMcpServerNames, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - // "+" button to open tools bottom sheet - Box { - FilledTonalIconButton( - onClick = { showToolsSheet = true }, - modifier = Modifier - .size(48.dp) - .semantics { - contentDescription = cdOpenToolsMenu - role = Role.Button - }, - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - ) - } - // Active tools badge - if (hasActiveTools) { - Box( - modifier = Modifier - .size(10.dp) - .align(Alignment.TopEnd) - .offset(x = (-2).dp, y = 2.dp) - .background( - color = MaterialTheme.colorScheme.primary, - shape = CircleShape, - ), - ) - } - } - - // Paste image button (shown only when clipboard has image content) - if (onImagePasted != null) { - IconButton( - onClick = { - // Clipboard image pasting is handled by the screen - onImagePasted(Uri.EMPTY) - }, - modifier = Modifier - .size(48.dp) - .semantics { - contentDescription = cdPasteImage - role = Role.Button - }, - ) { - Icon( - imageVector = Icons.Default.ContentPaste, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - // Detect @mention query from input text - val mentionQuery by remember(inputText) { - derivedStateOf { - val atIndex = inputText.lastIndexOf('@') - if (atIndex >= 0) { - val afterAt = inputText.substring(atIndex + 1) - // Only show suggestions if there's no space after @ (still typing the mention) - if (!afterAt.contains(' ')) afterAt else null - } else { - null - } - } - } - - val filteredPrompts by remember(mentionQuery, promptSuggestions) { - derivedStateOf { - val query = mentionQuery - if (query != null && promptSuggestions.isNotEmpty()) { - promptSuggestions.filter { group -> - group.name.contains(query, ignoreCase = true) || - group.command?.contains(query, ignoreCase = true) == true - }.take(5) - } else { - emptyList() - } - } - } - - // Detect slash command query: "/" at position 0 - val slashQuery by remember(inputText) { - derivedStateOf { - if (inputText.startsWith("/")) { - val afterSlash = inputText.substring(1) - // Only show suggestions if there's no space (still typing the command) - if (!afterSlash.contains(' ')) afterSlash else null - } else { - null - } - } - } - - val filteredSlashCommands by remember(slashQuery, promptSuggestions) { - derivedStateOf { - val query = slashQuery - if (query != null && promptSuggestions.isNotEmpty()) { - promptSuggestions.filter { group -> - val cmd = group.command - cmd != null && cmd.contains(query, ignoreCase = true) - }.take(5) - } else { - emptyList() - } - } - } - - Spacer(modifier = Modifier.width(8.dp)) - - Box(modifier = Modifier.weight(1f)) { - OutlinedTextField( - value = textFieldValue, - onValueChange = { newValue -> - textFieldValue = newValue - onInputChanged(newValue.text) - }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp, max = 160.dp) - .focusRequester(focusRequester) - .semantics { - contentDescription = cdMessageInput - }, - placeholder = { - Text( - text = if (isRecording) { - stringResource(R.string.recording) - } else if (!selectedModelDisplay.isNullOrBlank()) { - stringResource(R.string.hint_message_model, selectedModelDisplay) - } else { - stringResource(R.string.hint_message) - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - textStyle = MaterialTheme.typography.bodyLarge, - shape = RoundedCornerShape(24.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - focusedBorderColor = MaterialTheme.colorScheme.outline, - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, - ), - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Default, - capitalization = KeyboardCapitalization.Sentences, - ), - keyboardActions = KeyboardActions.Default, - maxLines = 6, - trailingIcon = { - IconButton( - onClick = { - if (isRecording) { - onStopRecording() - } else { - onStartRecording() - } - }, - modifier = Modifier - .size(40.dp) - .semantics { - contentDescription = if (isRecording) { - cdStopVoiceRec - } else { - cdStartVoiceRec - } - role = Role.Button - }, - enabled = !isTranscribing, - ) { - if (isTranscribing) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, - ) - } else if (isRecording) { - val infiniteTransition = rememberInfiniteTransition( - label = "recording_pulse", - ) - val pulseAlpha by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 0.3f, - animationSpec = infiniteRepeatable( - animation = tween(600), - repeatMode = RepeatMode.Reverse, - ), - label = "pulse_alpha", - ) - Box( - modifier = Modifier - .size(20.dp) - .alpha(pulseAlpha) - .background( - color = MaterialTheme.colorScheme.error, - shape = CircleShape, - ), - ) - } else { - Icon( - imageVector = Icons.Default.Mic, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, - ) - - // @mention dropdown - DropdownMenu( - expanded = filteredPrompts.isNotEmpty() && filteredSlashCommands.isEmpty(), - onDismissRequest = { /* Dismissed by typing or selecting */ }, - ) { - filteredPrompts.forEach { group -> - DropdownMenuItem( - text = { - Column { - Text( - text = group.name, - style = MaterialTheme.typography.bodyMedium, - ) - val oneliner = group.oneliner - if (!oneliner.isNullOrBlank()) { - Text( - text = oneliner, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - }, - onClick = { onPromptSelected(group) }, - ) - } - } - - // Slash command dropdown - SlashCommandMenu( - filteredCommands = filteredSlashCommands, - onCommandSelected = onSlashCommandSelected, - ) - } - - Spacer(modifier = Modifier.width(8.dp)) - - // Send / Stop button - AnimatedContent( - targetState = isStreaming, - transitionSpec = { - (fadeIn() + scaleIn()).togetherWith(fadeOut() + scaleOut()) - }, - label = "send_stop_toggle", - ) { streaming -> - if (streaming) { - IconButton( - onClick = onStop, - modifier = Modifier - .size(56.dp) - .semantics { - contentDescription = cdStopGen - role = Role.Button - }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Icon( - imageVector = Icons.Default.Stop, - contentDescription = null, - modifier = Modifier - .size(28.dp) - .background( - color = MaterialTheme.colorScheme.error, - shape = CircleShape, - ), - ) - } - } else { - val canSend = inputText.isNotBlank() || attachedFiles.isNotEmpty() - IconButton( - onClick = onSend, - modifier = Modifier - .size(56.dp) - .semantics { - contentDescription = cdSendMsg - role = Role.Button - }, - enabled = canSend, - colors = IconButtonDefaults.iconButtonColors( - containerColor = if (canSend) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - contentColor = if (canSend) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer, - disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = null, - ) - } - } - } - } - } - } - - // Tools bottom sheet - if (showToolsSheet) { - ChatToolsBottomSheet( - enabledTools = enabledTools, - onToggleTool = onToggleTool, - mcpServers = mcpServers, - selectedMcpServerNames = selectedMcpServerNames, - onToggleMcpServer = onToggleMcpServer, - onAttachFiles = { filePickerLauncher.launch(arrayOf("*/*")) }, - onTakePhoto = onTakePhoto, - onPickPhotos = onPickPhotos, - onOpenModelParameters = onOpenModelParameters, - onOpenModelSelector = onOpenModelSelector, - selectedModelDisplay = selectedModelDisplay, - onDismiss = { showToolsSheet = false }, - isCodeInterpreterAvailable = isCodeInterpreterAvailable, - ) - } -} - -/** - * Creates a temporary file for the camera to write a photo into. - * Stored in the app's cache directory under `camera_photos/` which is - * registered in the FileProvider paths XML. - */ -private fun createCameraPhotoFile(context: android.content.Context): File { - val cameraDir = File(context.cacheDir, "camera_photos") - if (!cameraDir.exists()) { - cameraDir.mkdirs() - } - return File.createTempFile("photo_", ".jpg", cameraDir) -} diff --git a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageTimestamp.kt b/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageTimestamp.kt deleted file mode 100644 index 6201a79..0000000 --- a/feature/chat/src/main/kotlin/com/librechat/android/feature/chat/components/MessageTimestamp.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.librechat.android.feature.chat.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import java.util.concurrent.TimeUnit - -/** Displays a message timestamp with relative time ("2m ago") that toggles to absolute on tap. Parses multiple ISO 8601 variants; returns empty on invalid input. */ -@Composable -fun MessageTimestamp( - isoTimestamp: String, - modifier: Modifier = Modifier, -) { - var showAbsolute by remember { mutableStateOf(false) } - - val timeText = remember(isoTimestamp, showAbsolute) { - if (showAbsolute) { - formatAbsoluteTimestamp(isoTimestamp) - } else { - formatRelativeTimestamp(isoTimestamp) - } - } - - if (timeText.isNotEmpty()) { - Text( - text = timeText, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - showAbsolute = !showAbsolute - }, - ) - } -} - -internal fun formatRelativeTimestamp(isoTimestamp: String): String { - val date = parseTimestamp(isoTimestamp) ?: return "" - val now = System.currentTimeMillis() - val diff = now - date.time - if (diff < 0) return "now" - - val seconds = TimeUnit.MILLISECONDS.toSeconds(diff) - val minutes = TimeUnit.MILLISECONDS.toMinutes(diff) - val hours = TimeUnit.MILLISECONDS.toHours(diff) - val days = TimeUnit.MILLISECONDS.toDays(diff) - - return when { - seconds < 60 -> "now" - minutes < 60 -> "${minutes}m ago" - hours < 24 -> "${hours}h ago" - days < 7 -> "${days}d ago" - days < 30 -> "${days / 7}w ago" - else -> formatAbsoluteTimestamp(isoTimestamp) - } -} - -internal fun formatAbsoluteTimestamp(isoTimestamp: String): String { - val date = parseTimestamp(isoTimestamp) ?: return "" - val format = SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()) - return format.format(date) -} - -private fun parseTimestamp(timestamp: String): Date? { - val formats = listOf( - "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", - "yyyy-MM-dd'T'HH:mm:ss'Z'", - "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", - "yyyy-MM-dd'T'HH:mm:ssXXX", - ) - for (pattern in formats) { - try { - val sdf = SimpleDateFormat(pattern, Locale.US) - sdf.timeZone = TimeZone.getTimeZone("UTC") - return sdf.parse(timestamp) - } catch (_: Exception) { - continue - } - } - return null -} diff --git a/feature/conversations/build.gradle.kts b/feature/conversations/build.gradle.kts index 8eb3539..ed3c68d 100644 --- a/feature/conversations/build.gradle.kts +++ b/feature/conversations/build.gradle.kts @@ -1,14 +1,24 @@ plugins { - id("librechat.android.feature") + id("librechat.kmp.feature") } android { namespace = "com.librechat.android.feature.conversations" } -dependencies { - implementation(libs.timber) - implementation(libs.paging.runtime) - implementation(libs.paging.compose) - implementation(libs.kotlinx.serialization.json) +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.paging.common) + implementation(libs.kermit) + implementation(libs.kotlinx.serialization.json) + } + androidMain.dependencies { + implementation(libs.paging.runtime) + implementation(libs.paging.compose) + } + named("androidUnitTest").dependencies { + implementation(libs.koin.test) + } + } } diff --git a/feature/conversations/src/androidMain/kotlin/com/librechat/android/feature/conversations/platform/PlatformActions.android.kt b/feature/conversations/src/androidMain/kotlin/com/librechat/android/feature/conversations/platform/PlatformActions.android.kt new file mode 100644 index 0000000..507dbc2 --- /dev/null +++ b/feature/conversations/src/androidMain/kotlin/com/librechat/android/feature/conversations/platform/PlatformActions.android.kt @@ -0,0 +1,66 @@ +package com.librechat.android.feature.conversations.platform + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext + +private lateinit var appContext: Context + +private fun getAppContext(context: Context): Context { + if (!::appContext.isInitialized) { + appContext = context.applicationContext + } + return appContext +} + +actual fun copyToClipboard(text: String, label: String) { + val ctx = appContext + val clipboard = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(label, text)) +} + +actual fun showToast(message: String) { + Toast.makeText(appContext, message, Toast.LENGTH_SHORT).show() +} + +@Composable +actual fun FileSaver( + triggerFileName: String?, + content: String?, + onComplete: (success: Boolean, message: String?) -> Unit, + onReset: () -> Unit, +) { + val context = LocalContext.current + // Initialize app context for clipboard/toast + if (!::appContext.isInitialized) { + appContext = context.applicationContext + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("*/*"), + ) { uri -> + if (uri != null && content != null) { + try { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(content.toByteArray()) + } + onComplete(true, null) + } catch (e: Exception) { + onComplete(false, e.message) + } + } + onReset() + } + + LaunchedEffect(triggerFileName) { + if (triggerFileName != null) { + launcher.launch(triggerFileName) + } + } +} diff --git a/feature/conversations/src/test/kotlin/com/librechat/android/feature/conversations/di/ConversationsModuleVerificationTest.kt b/feature/conversations/src/androidUnitTest/kotlin/com/librechat/android/feature/conversations/di/ConversationsModuleVerificationTest.kt similarity index 100% rename from feature/conversations/src/test/kotlin/com/librechat/android/feature/conversations/di/ConversationsModuleVerificationTest.kt rename to feature/conversations/src/androidUnitTest/kotlin/com/librechat/android/feature/conversations/di/ConversationsModuleVerificationTest.kt diff --git a/feature/conversations/src/test/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModelTest.kt b/feature/conversations/src/androidUnitTest/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModelTest.kt similarity index 100% rename from feature/conversations/src/test/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModelTest.kt rename to feature/conversations/src/androidUnitTest/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModelTest.kt diff --git a/feature/conversations/src/main/res/values/strings.xml b/feature/conversations/src/commonMain/composeResources/values/strings.xml similarity index 100% rename from feature/conversations/src/main/res/values/strings.xml rename to feature/conversations/src/commonMain/composeResources/values/strings.xml diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/ArchivedConversationDisplayData.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/ArchivedConversationDisplayData.kt similarity index 100% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/ArchivedConversationDisplayData.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/ArchivedConversationDisplayData.kt diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationActions.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/ConversationActions.kt similarity index 84% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationActions.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/ConversationActions.kt index 208c1ec..7220773 100644 --- a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationActions.kt +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/ConversationActions.kt @@ -34,10 +34,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp import com.librechat.android.core.model.Conversation -import com.librechat.android.feature.conversations.R +import librechat_android.feature.conversations.generated.resources.Res +import librechat_android.feature.conversations.generated.resources.* @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -70,7 +71,7 @@ fun ConversationActions( modifier = Modifier.padding(bottom = 32.dp), ) { Text( - text = conversation.title ?: stringResource(R.string.new_chat), + text = conversation.title ?: stringResource(Res.string.new_chat), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), @@ -79,7 +80,7 @@ fun ConversationActions( ActionRow( icon = Icons.Default.Edit, - label = stringResource(R.string.rename), + label = stringResource(Res.string.rename), onClick = { onDismiss() showRenameDialog = true @@ -88,7 +89,7 @@ fun ConversationActions( ActionRow( icon = if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, - label = if (isBookmarked) stringResource(R.string.remove_bookmark) else stringResource(R.string.bookmark), + label = if (isBookmarked) stringResource(Res.string.remove_bookmark) else stringResource(Res.string.bookmark), iconTint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, onClick = { onDismiss() @@ -98,7 +99,7 @@ fun ConversationActions( ActionRow( icon = Icons.Default.BookmarkBorder, - label = stringResource(R.string.tags), + label = stringResource(Res.string.tags), onClick = { onDismiss() onTags() @@ -108,7 +109,7 @@ fun ConversationActions( if (showShareAction) { ActionRow( icon = Icons.Default.Share, - label = stringResource(R.string.share), + label = stringResource(Res.string.share), onClick = { onDismiss() onShare() @@ -118,7 +119,7 @@ fun ConversationActions( ActionRow( icon = Icons.Default.ContentCopy, - label = stringResource(R.string.duplicate), + label = stringResource(Res.string.duplicate), onClick = { onDismiss() onDuplicate() @@ -127,7 +128,7 @@ fun ConversationActions( ActionRow( icon = Icons.Default.FileDownload, - label = stringResource(R.string.export), + label = stringResource(Res.string.export), onClick = { onDismiss() onExport() @@ -136,7 +137,7 @@ fun ConversationActions( ActionRow( icon = Icons.Default.Archive, - label = stringResource(R.string.archive), + label = stringResource(Res.string.archive), onClick = { onDismiss() onArchive() @@ -145,7 +146,7 @@ fun ConversationActions( ActionRow( icon = Icons.Default.Delete, - label = stringResource(R.string.delete), + label = stringResource(Res.string.delete), iconTint = MaterialTheme.colorScheme.error, labelColor = MaterialTheme.colorScheme.error, onClick = { @@ -169,7 +170,7 @@ fun ConversationActions( if (showDeleteConfirmation) { DeleteConfirmationDialog( - conversationTitle = conversation.title ?: stringResource(R.string.this_conversation), + conversationTitle = conversation.title ?: stringResource(Res.string.this_conversation), onDismiss = { showDeleteConfirmation = false }, onConfirm = { showDeleteConfirmation = false @@ -218,11 +219,11 @@ private fun RenameDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.rename_conversation)) }, + title = { Text(stringResource(Res.string.rename_conversation)) }, text = { Column { Text( - text = stringResource(R.string.rename_dialog_message), + text = stringResource(Res.string.rename_dialog_message), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -232,7 +233,7 @@ private fun RenameDialog( onValueChange = { title = it }, singleLine = true, modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.title_label)) }, + label = { Text(stringResource(Res.string.title_label)) }, ) } }, @@ -241,12 +242,12 @@ private fun RenameDialog( onClick = { onConfirm(title) }, enabled = title.isNotBlank(), ) { - Text(stringResource(R.string.rename)) + Text(stringResource(Res.string.rename)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) + Text(stringResource(Res.string.cancel)) } }, ) @@ -260,24 +261,24 @@ private fun DeleteConfirmationDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.delete_conversation)) }, + title = { Text(stringResource(Res.string.delete_conversation)) }, text = { Text( - text = stringResource(R.string.delete_confirmation_message, conversationTitle), + text = stringResource(Res.string.delete_confirmation_message, conversationTitle), style = MaterialTheme.typography.bodyMedium, ) }, confirmButton = { TextButton(onClick = onConfirm) { Text( - text = stringResource(R.string.delete), + text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error, ) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) + Text(stringResource(Res.string.cancel)) } }, ) diff --git a/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/ConversationDisplayData.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/ConversationDisplayData.kt new file mode 100644 index 0000000..b2f1642 --- /dev/null +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/ConversationDisplayData.kt @@ -0,0 +1,24 @@ +package com.librechat.android.feature.conversations.components + +import androidx.compose.runtime.Immutable +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.model.EModelEndpoint + +@Immutable +data class ConversationDisplayData( + val conversationId: String, + val title: String, + val endpoint: EModelEndpoint?, + val model: String?, + val updatedAt: String?, + val isBookmarked: Boolean, +) + +fun Conversation.toDisplayData(bookmarkedIds: Set) = ConversationDisplayData( + conversationId = conversationId ?: "", + title = title ?: "New Chat", + endpoint = endpoint, + model = model, + updatedAt = updatedAt, + isBookmarked = conversationId in bookmarkedIds, +) diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationItem.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/ConversationItem.kt similarity index 75% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationItem.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/ConversationItem.kt index 326d198..c7d8132 100644 --- a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationItem.kt +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/ConversationItem.kt @@ -17,49 +17,24 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource + +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.librechat.android.core.common.extensions.toInstantOrNull -import com.librechat.android.core.model.Conversation import com.librechat.android.core.model.EModelEndpoint +import com.librechat.android.core.ui.components.endpointIconPainter import com.librechat.android.core.ui.components.isMonochromeIcon -import com.librechat.android.core.ui.components.toIconRes -import com.librechat.android.feature.conversations.R -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoUnit - -/** - * Lightweight snapshot of the fields ConversationItem actually renders. - * Avoids passing the full 28-field Conversation data class through composition, - * letting Compose skip recomposition when only irrelevant fields change. - */ -@Immutable -data class ConversationDisplayData( - val conversationId: String, - val title: String, - val endpoint: EModelEndpoint?, - val model: String?, - val updatedAt: String?, - val isBookmarked: Boolean, -) - -fun Conversation.toDisplayData(bookmarkedIds: Set) = ConversationDisplayData( - conversationId = conversationId ?: "", - title = title ?: "New Chat", - endpoint = endpoint, - model = model, - updatedAt = updatedAt, - isBookmarked = conversationId in bookmarkedIds, -) +import librechat_android.feature.conversations.generated.resources.Res +import librechat_android.feature.conversations.generated.resources.* +import kotlin.time.Clock +import kotlin.time.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime @Composable fun ConversationItem( @@ -76,9 +51,7 @@ fun ConversationItem( data.endpoint?.toDisplayLabel() ?: "Chat" } - val endpointIconRes = remember(data.endpoint) { - data.endpoint?.toIconRes() - } + val iconPainter = data.endpoint?.let { endpointIconPainter(it) } Row( modifier = modifier @@ -87,10 +60,10 @@ fun ConversationItem( .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (endpointIconRes != null) { + if (iconPainter != null) { val isMonochrome = data.endpoint?.isMonochromeIcon() == true Icon( - painter = painterResource(id = endpointIconRes), + painter = iconPainter, contentDescription = endpointLabel, modifier = Modifier.size(24.dp), tint = if (isMonochrome) { @@ -165,7 +138,7 @@ fun ConversationItem( if (data.isBookmarked) { Icon( imageVector = Icons.Default.Bookmark, - contentDescription = stringResource(R.string.cd_bookmarked), + contentDescription = stringResource(Res.string.cd_bookmarked), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary, ) @@ -174,7 +147,7 @@ fun ConversationItem( IconButton(onClick = onActionsClick) { Icon( imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.cd_conversation_actions), + contentDescription = stringResource(Res.string.cd_conversation_actions), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -182,18 +155,27 @@ fun ConversationItem( } private fun Instant.toRelativeTimeString(): String { - val now = Instant.now() - val minutes = ChronoUnit.MINUTES.between(this, now) - val hours = ChronoUnit.HOURS.between(this, now) - val days = ChronoUnit.DAYS.between(this, now) + val now = Clock.System.now() + val duration = now - this + val minutes = duration.inWholeMinutes + val hours = duration.inWholeHours + val days = duration.inWholeDays return when { minutes < 1 -> "Just now" minutes < 60 -> "${minutes}m ago" hours < 24 -> "${hours}h ago" days < 7 -> "${days}d ago" - else -> atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("MMM d")) + else -> { + val date = toLocalDateTime(TimeZone.currentSystemDefault()).date + val monthAbbr = when (date.monthNumber) { + 1 -> "Jan"; 2 -> "Feb"; 3 -> "Mar"; 4 -> "Apr" + 5 -> "May"; 6 -> "Jun"; 7 -> "Jul"; 8 -> "Aug" + 9 -> "Sep"; 10 -> "Oct"; 11 -> "Nov"; 12 -> "Dec" + else -> "" + } + "$monthAbbr ${date.dayOfMonth}" + } } } diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationSearchBar.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/ConversationSearchBar.kt similarity index 85% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationSearchBar.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/ConversationSearchBar.kt index 1062f11..d359a11 100644 --- a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/ConversationSearchBar.kt +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/ConversationSearchBar.kt @@ -16,9 +16,10 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.conversations.R +import librechat_android.feature.conversations.generated.resources.Res +import librechat_android.feature.conversations.generated.resources.* @Composable fun ConversationSearchBar( @@ -32,7 +33,7 @@ fun ConversationSearchBar( leadingIcon = { Icon( imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.cd_search), + contentDescription = stringResource(Res.string.cd_search), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, @@ -41,7 +42,7 @@ fun ConversationSearchBar( IconButton(onClick = { onQueryChanged("") }) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_clear_search), + contentDescription = stringResource(Res.string.cd_clear_search), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -49,7 +50,7 @@ fun ConversationSearchBar( }, placeholder = { Text( - text = stringResource(R.string.search_conversations), + text = stringResource(Res.string.search_conversations), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/DateGroupHeader.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/DateGroupHeader.kt similarity index 100% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/DateGroupHeader.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/DateGroupHeader.kt diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagFilterBar.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/TagFilterBar.kt similarity index 88% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagFilterBar.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/TagFilterBar.kt index a1e3476..452ac39 100644 --- a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagFilterBar.kt +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/TagFilterBar.kt @@ -26,10 +26,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp import com.librechat.android.core.model.ConversationTag -import com.librechat.android.feature.conversations.R +import librechat_android.feature.conversations.generated.resources.Res +import librechat_android.feature.conversations.generated.resources.* @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -52,7 +53,7 @@ fun TagFilterBar( IconButton(onClick = { expanded = !expanded }) { Icon( imageVector = Icons.Default.BookmarkBorder, - contentDescription = if (expanded) stringResource(R.string.cd_hide_tags) else stringResource(R.string.cd_show_tags), + contentDescription = if (expanded) stringResource(Res.string.cd_hide_tags) else stringResource(Res.string.cd_show_tags), tint = if (selectedTags.isNotEmpty()) { MaterialTheme.colorScheme.primary } else { @@ -63,7 +64,7 @@ fun TagFilterBar( if (selectedTags.isNotEmpty() && !expanded) { Text( - text = stringResource(R.string.tags_active_count, selectedTags.size, if (selectedTags.size > 1) "s" else ""), + text = stringResource(Res.string.tags_active_count, selectedTags.size, if (selectedTags.size > 1) "s" else ""), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) @@ -86,11 +87,11 @@ fun TagFilterBar( FilterChip( selected = false, onClick = onClearFilter, - label = { Text(stringResource(R.string.clear)) }, + label = { Text(stringResource(Res.string.clear)) }, leadingIcon = { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_clear_filter), + contentDescription = stringResource(Res.string.cd_clear_filter), modifier = Modifier.size(16.dp), ) }, diff --git a/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/TagPicker.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/TagPicker.kt new file mode 100644 index 0000000..56156a0 --- /dev/null +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/components/TagPicker.kt @@ -0,0 +1,40 @@ +package com.librechat.android.feature.conversations.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.librechat.android.core.model.ConversationTag +import com.librechat.android.core.ui.components.FilterChipBottomSheet +import librechat_android.feature.conversations.generated.resources.Res +import librechat_android.feature.conversations.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@Composable +fun TagPicker( + availableTags: List, + currentTags: List, + onTagsChanged: (List) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val allTagStrings = remember(availableTags, currentTags) { + buildList { + availableTags.mapNotNull { it.tag }.forEach(::add) + currentTags.forEach { tag -> if (tag !in this) add(tag) } + } + } + + FilterChipBottomSheet( + items = allTagStrings, + selectedItems = currentTags.toSet(), + onSelectionChanged = { onTagsChanged(it.toList()) }, + label = { it }, + onDismiss = onDismiss, + title = stringResource(Res.string.tags), + emptyMessage = stringResource(Res.string.no_tags_yet), + onAdd = { newTag -> newTag }, + addPlaceholder = stringResource(Res.string.add_new_tag), + addContentDescription = stringResource(Res.string.cd_add_tag), + modifier = modifier, + ) +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/di/ConversationsModule.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/di/ConversationsModule.kt similarity index 100% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/di/ConversationsModule.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/di/ConversationsModule.kt diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationExporter.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/export/ConversationExporter.kt similarity index 87% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationExporter.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/export/ConversationExporter.kt index 5039bf6..5fa7e45 100644 --- a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationExporter.kt +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/export/ConversationExporter.kt @@ -5,10 +5,11 @@ import com.librechat.android.core.data.repository.ConversationRepository import com.librechat.android.core.data.repository.MessageRepository import com.librechat.android.core.model.ConversationExport import com.librechat.android.core.model.Message +import kotlin.time.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.json.Json -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale + class ConversationExporter( private val conversationRepository: ConversationRepository, private val messageRepository: MessageRepository, @@ -32,7 +33,7 @@ class ConversationExporter( val export = ConversationExport( conversation = conversation, messages = messages, - exportedAt = System.currentTimeMillis(), + exportedAt = Clock.System.now().toEpochMilliseconds(), version = 1, ) return Result.Success(json.encodeToString(ConversationExport.serializer(), export)) @@ -50,8 +51,8 @@ class ConversationExporter( is Result.Loading -> return Result.Error(message = "Unexpected loading state") } - val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) - val exportDate = dateFormat.format(Date()) + val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + val exportDate = "${now.year}-${now.monthNumber.toString().padStart(2, '0')}-${now.dayOfMonth.toString().padStart(2, '0')} ${now.hour.toString().padStart(2, '0')}:${now.minute.toString().padStart(2, '0')}" val sb = StringBuilder() sb.appendLine("# ${conversation.title ?: "Untitled Conversation"}") diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationImporter.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/export/ConversationImporter.kt similarity index 100% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ConversationImporter.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/export/ConversationImporter.kt diff --git a/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/export/ExportFormat.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/export/ExportFormat.kt new file mode 100644 index 0000000..75cf56c --- /dev/null +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/export/ExportFormat.kt @@ -0,0 +1,6 @@ +package com.librechat.android.feature.conversations.export + +enum class ExportFormat { + JSON, + MARKDOWN, +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ExportFormatPicker.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/export/ExportFormatPicker.kt similarity index 85% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ExportFormatPicker.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/export/ExportFormatPicker.kt index cbb0c63..4fa5c2c 100644 --- a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/export/ExportFormatPicker.kt +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/export/ExportFormatPicker.kt @@ -20,14 +20,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.conversations.R - -enum class ExportFormat { - JSON, - MARKDOWN, -} +import librechat_android.feature.conversations.generated.resources.Res +import librechat_android.feature.conversations.generated.resources.* @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -47,7 +43,7 @@ fun ExportFormatPicker( modifier = Modifier.padding(bottom = 32.dp), ) { Text( - text = stringResource(R.string.export_format), + text = stringResource(Res.string.export_format), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), @@ -55,8 +51,8 @@ fun ExportFormatPicker( FormatRow( icon = Icons.Default.Code, - label = stringResource(R.string.format_json), - description = stringResource(R.string.format_json_description), + label = stringResource(Res.string.format_json), + description = stringResource(Res.string.format_json_description), onClick = { onDismiss() onFormatSelected(ExportFormat.JSON) @@ -65,8 +61,8 @@ fun ExportFormatPicker( FormatRow( icon = Icons.Default.Description, - label = stringResource(R.string.format_markdown), - description = stringResource(R.string.format_markdown_description), + label = stringResource(Res.string.format_markdown), + description = stringResource(Res.string.format_markdown_description), onClick = { onDismiss() onFormatSelected(ExportFormat.MARKDOWN) diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/navigation/ConversationsNavigation.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/navigation/ConversationsNavigation.kt similarity index 100% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/navigation/ConversationsNavigation.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/navigation/ConversationsNavigation.kt diff --git a/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/platform/PlatformActions.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/platform/PlatformActions.kt new file mode 100644 index 0000000..11b81ab --- /dev/null +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/platform/PlatformActions.kt @@ -0,0 +1,27 @@ +package com.librechat.android.feature.conversations.platform + +import androidx.compose.runtime.Composable + +/** + * Copy text to the system clipboard. + */ +expect fun copyToClipboard(text: String, label: String = "") + +/** + * Show a short toast/notification to the user. + */ +expect fun showToast(message: String) + +/** + * Composable that provides a file-save callback. + * When [triggerFileName] changes to a non-null value, the platform + * prompts the user to save a file with that name and writes [content] to it. + * Calls [onComplete] with success/failure message. + */ +@Composable +expect fun FileSaver( + triggerFileName: String?, + content: String?, + onComplete: (success: Boolean, message: String?) -> Unit, + onReset: () -> Unit, +) diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ArchivedConversationsScreen.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/screen/ArchivedConversationsScreen.kt similarity index 89% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ArchivedConversationsScreen.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/screen/ArchivedConversationsScreen.kt index 936f066..51cdee0 100644 --- a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ArchivedConversationsScreen.kt +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/screen/ArchivedConversationsScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -43,7 +43,8 @@ import com.librechat.android.core.ui.components.EmptyState import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.LoadingIndicator import com.librechat.android.feature.conversations.ArchivedConversationDisplayData -import com.librechat.android.feature.conversations.R +import librechat_android.feature.conversations.generated.resources.Res +import librechat_android.feature.conversations.generated.resources.* import com.librechat.android.feature.conversations.viewmodel.ArchivedConversationsEvent import com.librechat.android.feature.conversations.viewmodel.ArchivedConversationsViewModel import org.koin.compose.viewmodel.koinViewModel @@ -74,12 +75,12 @@ fun ArchivedConversationsScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text(stringResource(R.string.archived_conversations)) }, + title = { Text(stringResource(Res.string.archived_conversations)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), + contentDescription = stringResource(Res.string.back), ) } }, @@ -99,7 +100,7 @@ fun ArchivedConversationsScreen( } !uiState.isLoading && uiState.conversations.isEmpty() && uiState.error != null -> { ErrorBanner( - message = uiState.error ?: stringResource(R.string.could_not_load_archived), + message = uiState.error ?: stringResource(Res.string.could_not_load_archived), onRetry = { viewModel.dismissError() viewModel.loadArchivedConversations() @@ -108,8 +109,8 @@ fun ArchivedConversationsScreen( } !uiState.isLoading && uiState.conversations.isEmpty() -> { EmptyState( - title = stringResource(R.string.no_archived_conversations), - description = stringResource(R.string.archived_will_appear_here), + title = stringResource(Res.string.no_archived_conversations), + description = stringResource(Res.string.archived_will_appear_here), icon = Icons.Default.Archive, ) } @@ -143,12 +144,12 @@ fun ArchivedConversationsScreen( if (deleteConfirmation != null) { AlertDialog( onDismissRequest = { deleteConfirmation = null }, - title = { Text(stringResource(R.string.delete_conversation)) }, + title = { Text(stringResource(Res.string.delete_conversation)) }, text = { Text( text = stringResource( - R.string.delete_confirmation_message, - deleteConfirmation?.title ?: stringResource(R.string.this_conversation), + Res.string.delete_confirmation_message, + deleteConfirmation?.title ?: stringResource(Res.string.this_conversation), ), style = MaterialTheme.typography.bodyMedium, ) @@ -161,14 +162,14 @@ fun ArchivedConversationsScreen( deleteConfirmation = null }) { Text( - text = stringResource(R.string.delete), + text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error, ) } }, dismissButton = { TextButton(onClick = { deleteConfirmation = null }) { - Text(stringResource(R.string.cancel)) + Text(stringResource(Res.string.cancel)) } }, ) @@ -210,7 +211,7 @@ private fun ArchivedConversationItem( IconButton(onClick = onUnarchive) { Icon( imageVector = Icons.Default.Unarchive, - contentDescription = stringResource(R.string.cd_unarchive), + contentDescription = stringResource(Res.string.cd_unarchive), tint = MaterialTheme.colorScheme.primary, ) } @@ -218,7 +219,7 @@ private fun ArchivedConversationItem( IconButton(onClick = onDelete) { Icon( imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.cd_delete), + contentDescription = stringResource(Res.string.cd_delete), tint = MaterialTheme.colorScheme.error, ) } diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ConversationListScreen.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/screen/ConversationListScreen.kt similarity index 87% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ConversationListScreen.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/screen/ConversationListScreen.kt index 2742537..0179b71 100644 --- a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/screen/ConversationListScreen.kt +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/screen/ConversationListScreen.kt @@ -1,11 +1,5 @@ package com.librechat.android.feature.conversations.screen -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -41,15 +35,15 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.librechat.android.core.model.Conversation import com.librechat.android.core.ui.components.EmptyState import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.LoadingIndicator -import com.librechat.android.feature.conversations.R +import librechat_android.feature.conversations.generated.resources.Res +import librechat_android.feature.conversations.generated.resources.* import com.librechat.android.feature.conversations.components.ConversationActions import com.librechat.android.feature.conversations.components.ConversationItem import com.librechat.android.feature.conversations.components.ConversationSearchBar @@ -58,6 +52,9 @@ import com.librechat.android.feature.conversations.components.TagFilterBar import com.librechat.android.feature.conversations.components.TagPicker import com.librechat.android.feature.conversations.export.ExportFormat import com.librechat.android.feature.conversations.export.ExportFormatPicker +import com.librechat.android.feature.conversations.platform.FileSaver +import com.librechat.android.feature.conversations.platform.copyToClipboard +import com.librechat.android.feature.conversations.platform.showToast import com.librechat.android.feature.conversations.viewmodel.ConversationListEvent import com.librechat.android.feature.conversations.viewmodel.ConversationListViewModel import org.koin.compose.viewmodel.koinViewModel @@ -72,45 +69,49 @@ fun ConversationListScreen( viewModel: ConversationListViewModel = koinViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current var selectedConversation by remember { mutableStateOf(null) } var showTagPicker by remember { mutableStateOf(false) } var tagPickerConversation by remember { mutableStateOf(null) } var showExportFormatPicker by remember { mutableStateOf(false) } var exportConversation by remember { mutableStateOf(null) } + + // File saver state + var pendingExportFileName by remember { mutableStateOf(null) } var pendingExportContent by remember { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } - val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val copyOfPrefix = stringResource(R.string.copy_of) - val newChatLabel = stringResource(R.string.new_chat) + val copyOfPrefix = stringResource(Res.string.copy_of) + val newChatLabel = stringResource(Res.string.new_chat) + val conversationExportedMsg = stringResource(Res.string.conversation_exported) + val linkCopiedMsg = stringResource(Res.string.link_copied) + val importedTitleTemplate = stringResource(Res.string.imported_title) - // SAF launcher for saving exported file - val exportFileLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("*/*"), - ) { uri -> - if (uri != null && pendingExportContent != null) { - try { - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(pendingExportContent!!.toByteArray()) - } - Toast.makeText(context, context.getString(R.string.conversation_exported), Toast.LENGTH_SHORT).show() - } catch (e: Exception) { - Toast.makeText(context, context.getString(R.string.export_failed, e.message ?: ""), Toast.LENGTH_SHORT).show() + // Platform file saver composable + FileSaver( + triggerFileName = pendingExportFileName, + content = pendingExportContent, + onComplete = { success, errorMessage -> + if (success) { + showToast(conversationExportedMsg) + } else if (errorMessage != null) { + showToast(errorMessage) } - } - pendingExportContent = null - } + }, + onReset = { + pendingExportFileName = null + pendingExportContent = null + }, + ) // Collect one-shot events LaunchedEffect(Unit) { viewModel.events.collect { event -> when (event) { is ConversationListEvent.ShareLinkCopied -> { - clipboardManager.setPrimaryClip(ClipData.newPlainText("Share Link", event.url)) - snackbarHostState.showSnackbar(context.getString(R.string.link_copied)) + copyToClipboard(event.url, "Share Link") + snackbarHostState.showSnackbar(linkCopiedMsg) } is ConversationListEvent.NavigateToConversation -> { onConversationClick(event.conversationId) @@ -125,10 +126,10 @@ fun ConversationListScreen( ExportFormat.MARKDOWN -> "md" } val safeTitle = event.title.replace(Regex("[^a-zA-Z0-9._-]"), "_") - exportFileLauncher.launch("$safeTitle.$ext") + pendingExportFileName = "$safeTitle.$ext" } is ConversationListEvent.ImportSuccess -> { - snackbarHostState.showSnackbar(context.getString(R.string.imported_title, event.title)) + snackbarHostState.showSnackbar(importedTitleTemplate.replace("%1\$s", event.title)) } } } @@ -167,7 +168,7 @@ fun ConversationListScreen( ) { Icon( imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.cd_new_chat), + contentDescription = stringResource(Res.string.cd_new_chat), ) } }, @@ -193,7 +194,7 @@ fun ConversationListScreen( IconButton(onClick = onNavigateToArchived) { Icon( imageVector = Icons.Default.Archive, - contentDescription = stringResource(R.string.cd_archived_conversations), + contentDescription = stringResource(Res.string.cd_archived_conversations), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -221,7 +222,7 @@ fun ConversationListScreen( // Error state when no conversations loaded !uiState.isLoading && uiState.conversationCount == 0 && uiState.error != null -> { ErrorBanner( - message = uiState.error ?: stringResource(R.string.could_not_load_conversations), + message = uiState.error ?: stringResource(Res.string.could_not_load_conversations), onRetry = { viewModel.dismissError() viewModel.loadConversations() @@ -233,14 +234,14 @@ fun ConversationListScreen( !uiState.isLoading && uiState.conversationCount == 0 -> { EmptyState( title = if (uiState.searchQuery.isNotEmpty()) { - stringResource(R.string.no_results_found) + stringResource(Res.string.no_results_found) } else { - stringResource(R.string.no_conversations_yet) + stringResource(Res.string.no_conversations_yet) }, description = if (uiState.searchQuery.isNotEmpty()) { - stringResource(R.string.try_different_search) + stringResource(Res.string.try_different_search) } else { - stringResource(R.string.start_new_chat) + stringResource(Res.string.start_new_chat) }, icon = Icons.Default.Forum, ) @@ -257,7 +258,7 @@ fun ConversationListScreen( if (uiState.error != null) { item(key = "error") { ErrorBanner( - message = uiState.error ?: stringResource(R.string.unknown_error), + message = uiState.error ?: stringResource(Res.string.unknown_error), onRetry = { viewModel.dismissError() viewModel.loadConversations() @@ -364,7 +365,7 @@ fun ConversationListScreen( onDuplicate = { val convo = selectedConversation convo?.conversationId?.let { id -> - viewModel.duplicateConversation(id, copyOfPrefix.format(convo.title ?: newChatLabel)) + viewModel.duplicateConversation(id, copyOfPrefix.replace("%1\$s", convo.title ?: newChatLabel)) } selectedConversation = null }, diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ArchivedConversationsViewModel.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/viewmodel/ArchivedConversationsViewModel.kt similarity index 96% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ArchivedConversationsViewModel.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/viewmodel/ArchivedConversationsViewModel.kt index bb4889d..5a036bb 100644 --- a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ArchivedConversationsViewModel.kt +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/viewmodel/ArchivedConversationsViewModel.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import timber.log.Timber +import co.touchlab.kermit.Logger @Immutable data class ArchivedConversationsUiState( @@ -114,7 +114,7 @@ class ArchivedConversationsViewModel( try { conversationRepository.archive(id, false) } catch (e: Exception) { - Timber.e(e, "Failed to unarchive conversation") + Logger.e(e) { "Failed to unarchive conversation" } _events.emit(ArchivedConversationsEvent.ShowError("Failed to unarchive conversation")) } } @@ -125,7 +125,7 @@ class ArchivedConversationsViewModel( try { conversationRepository.delete(id) } catch (e: Exception) { - Timber.e(e, "Failed to delete conversation") + Logger.e(e) { "Failed to delete conversation" } _events.emit(ArchivedConversationsEvent.ShowError("Failed to delete conversation")) } } diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModel.kt b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModel.kt similarity index 98% rename from feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModel.kt rename to feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModel.kt index ebd51ab..ece841a 100644 --- a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModel.kt +++ b/feature/conversations/src/commonMain/kotlin/com/librechat/android/feature/conversations/viewmodel/ConversationListViewModel.kt @@ -27,7 +27,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import timber.log.Timber +import co.touchlab.kermit.Logger @Immutable data class ConversationListUiState( @@ -211,7 +211,7 @@ class ConversationListViewModel( } is Result.Error -> { // Tags are non-critical, silently fail - Timber.d(result.exception, "Failed to load tags: ${result.message}") + Logger.d(result.exception) { "Failed to load tags: ${result.message}" } } is Result.Loading -> { /* no-op */ } } @@ -285,7 +285,7 @@ class ConversationListViewModel( try { conversationRepository.updateTitle(id, newTitle) } catch (e: Exception) { - Timber.e(e, "Failed to rename conversation") + Logger.e(e) { "Failed to rename conversation" } _events.emit(ConversationListEvent.ShowError("Failed to rename conversation")) } } @@ -296,7 +296,7 @@ class ConversationListViewModel( try { conversationRepository.archive(id, true) } catch (e: Exception) { - Timber.e(e, "Failed to archive conversation") + Logger.e(e) { "Failed to archive conversation" } _events.emit(ConversationListEvent.ShowError("Failed to archive conversation")) } } @@ -307,7 +307,7 @@ class ConversationListViewModel( try { conversationRepository.delete(id) } catch (e: Exception) { - Timber.e(e, "Failed to delete conversation") + Logger.e(e) { "Failed to delete conversation" } _events.emit(ConversationListEvent.ShowError("Failed to delete conversation")) } } diff --git a/feature/conversations/src/iosMain/kotlin/com/librechat/android/feature/conversations/platform/PlatformActions.ios.kt b/feature/conversations/src/iosMain/kotlin/com/librechat/android/feature/conversations/platform/PlatformActions.ios.kt new file mode 100644 index 0000000..f133365 --- /dev/null +++ b/feature/conversations/src/iosMain/kotlin/com/librechat/android/feature/conversations/platform/PlatformActions.ios.kt @@ -0,0 +1,88 @@ +package com.librechat.android.feature.conversations.platform + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import co.touchlab.kermit.Logger +import platform.UIKit.UIPasteboard + +actual fun copyToClipboard(text: String, label: String) { + UIPasteboard.generalPasteboard.string = text +} + +actual fun showToast(message: String) { + // iOS doesn't have native Toast — show a brief alert-style overlay. + Logger.i("Toast") { message } + // Use UIAlertController as a lightweight toast replacement + val scene = platform.UIKit.UIApplication.sharedApplication.connectedScenes + .firstOrNull() as? platform.UIKit.UIWindowScene + val rootVc = scene?.windows?.firstOrNull { + (it as? platform.UIKit.UIWindow)?.isKeyWindow() == true + }?.let { (it as platform.UIKit.UIWindow).rootViewController } ?: return + val alert = platform.UIKit.UIAlertController.alertControllerWithTitle( + title = null, + message = message, + preferredStyle = platform.UIKit.UIAlertControllerStyleAlert, + ) + rootVc.presentViewController(alert, animated = true, completion = null) + // Auto-dismiss after 1.5 seconds + platform.Foundation.NSTimer.scheduledTimerWithTimeInterval( + interval = 1.5, + repeats = false, + ) { + alert.dismissViewControllerAnimated(true, completion = null) + } +} + +@Composable +actual fun FileSaver( + triggerFileName: String?, + content: String?, + onComplete: (success: Boolean, message: String?) -> Unit, + onReset: () -> Unit, +) { + var showDialog by remember { mutableStateOf(false) } + + if (triggerFileName != null && content != null && !showDialog) { + showDialog = true + } + + if (showDialog) { + AlertDialog( + onDismissRequest = { + showDialog = false + onComplete(false, null) + onReset() + }, + title = { + Text( + text = "Export Not Available", + style = MaterialTheme.typography.titleMedium, + ) + }, + text = { + Text( + text = "File export is not yet available on iOS. This feature will be added in a future update.", + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton( + onClick = { + showDialog = false + onComplete(false, "File export not yet available on iOS") + onReset() + }, + ) { + Text("OK") + } + }, + ) + } +} diff --git a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagPicker.kt b/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagPicker.kt deleted file mode 100644 index aa1ee6d..0000000 --- a/feature/conversations/src/main/kotlin/com/librechat/android/feature/conversations/components/TagPicker.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.librechat.android.feature.conversations.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.librechat.android.core.model.ConversationTag -import com.librechat.android.feature.conversations.R - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun TagPicker( - availableTags: List, - currentTags: List, - onTagsChanged: (List) -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, -) { - val sheetState = rememberModalBottomSheetState() - var selectedTags by remember { mutableStateOf(currentTags.toSet()) } - var newTagText by remember { mutableStateOf("") } - - ModalBottomSheet( - onDismissRequest = { - onTagsChanged(selectedTags.toList()) - onDismiss() - }, - modifier = modifier, - sheetState = sheetState, - ) { - Column( - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(bottom = 32.dp), - ) { - Text( - text = stringResource(R.string.tags), - style = MaterialTheme.typography.titleMedium, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - if (availableTags.isEmpty() && selectedTags.isEmpty()) { - Text( - text = stringResource(R.string.no_tags_yet), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Show all unique tags (available + currently selected) - val allTags = buildSet { - availableTags.mapNotNull { it.tag }.forEach(::add) - addAll(selectedTags) - } - - allTags.forEach { tag -> - FilterChip( - selected = tag in selectedTags, - onClick = { - selectedTags = if (tag in selectedTags) { - selectedTags - tag - } else { - selectedTags + tag - } - }, - label = { Text(tag) }, - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = newTagText, - onValueChange = { newTagText = it }, - placeholder = { Text(stringResource(R.string.add_new_tag)) }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - Spacer(modifier = Modifier.width(8.dp)) - IconButton( - onClick = { - val trimmed = newTagText.trim() - if (trimmed.isNotEmpty()) { - selectedTags = selectedTags + trimmed - newTagText = "" - } - }, - enabled = newTagText.isNotBlank(), - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.cd_add_tag), - ) - } - } - } - } -} diff --git a/feature/files/build.gradle.kts b/feature/files/build.gradle.kts index 073105d..769951b 100644 --- a/feature/files/build.gradle.kts +++ b/feature/files/build.gradle.kts @@ -1,13 +1,20 @@ plugins { - id("librechat.android.feature") + id("librechat.kmp.feature") } android { namespace = "com.librechat.android.feature.files" } -dependencies { - implementation(project(":core:network")) - implementation(libs.coil.compose) - implementation(libs.timber) +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":core:network")) + implementation(libs.kermit) + implementation(libs.coil3.compose) + } + named("androidUnitTest").dependencies { + implementation(libs.koin.test) + } + } } diff --git a/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/di/FilesPlatformModule.kt b/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/di/FilesPlatformModule.kt new file mode 100644 index 0000000..f09d950 --- /dev/null +++ b/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/di/FilesPlatformModule.kt @@ -0,0 +1,11 @@ +package com.librechat.android.feature.files.di + +import com.librechat.android.feature.files.platform.AndroidFileReader +import com.librechat.android.feature.files.platform.FileReader +import org.koin.android.ext.koin.androidApplication +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val filesPlatformModule: Module = module { + single { AndroidFileReader(androidApplication()) } +} diff --git a/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/platform/AndroidFileReader.kt b/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/platform/AndroidFileReader.kt new file mode 100644 index 0000000..780088f --- /dev/null +++ b/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/platform/AndroidFileReader.kt @@ -0,0 +1,37 @@ +package com.librechat.android.feature.files.platform + +import android.app.Application +import android.net.Uri +import android.provider.OpenableColumns + +class AndroidFileReader( + private val context: Application, +) : FileReader { + + override fun readBytes(fileRef: Any): ByteArray? { + val uri = fileRef as? Uri ?: return null + return context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + } + + override fun getFileName(fileRef: Any): String? { + val uri = fileRef as? Uri ?: return null + val cursor = context.contentResolver.query(uri, null, null, null, null) ?: return null + return cursor.use { + if (it.moveToFirst()) { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) it.getString(nameIndex) else null + } else { + null + } + } + } + + override fun getMimeType(fileRef: Any): String? { + val uri = fileRef as? Uri ?: return null + return context.contentResolver.getType(uri) + ?: uri.lastPathSegment + ?.substringAfterLast('.', "") + ?.takeIf { it.isNotEmpty() } + ?.let { CommonMimeTypes.fromExtension(it) } + } +} diff --git a/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/platform/AndroidFileSizeFormatter.kt b/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/platform/AndroidFileSizeFormatter.kt new file mode 100644 index 0000000..e626aa6 --- /dev/null +++ b/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/platform/AndroidFileSizeFormatter.kt @@ -0,0 +1,12 @@ +package com.librechat.android.feature.files.platform + +actual fun formatFileSize(bytes: Long): String { + // Simple human-readable formatter that doesn't need Context + if (bytes < 1024) return "$bytes B" + val kb = bytes / 1024.0 + if (kb < 1024) return "%.1f KB".format(kb) + val mb = kb / 1024.0 + if (mb < 1024) return "%.1f MB".format(mb) + val gb = mb / 1024.0 + return "%.1f GB".format(gb) +} diff --git a/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/platform/FilePicker.android.kt b/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/platform/FilePicker.android.kt new file mode 100644 index 0000000..5ccabad --- /dev/null +++ b/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/platform/FilePicker.android.kt @@ -0,0 +1,30 @@ +package com.librechat.android.feature.files.platform + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +actual fun rememberFilePickerLauncher( + onFilePicked: (fileRef: Any) -> Unit, +): FilePickerLauncher { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri -> + if (uri != null) { + onFilePicked(uri) + } + } + return remember(launcher) { + FilePickerLauncher(launchAction = { mimeType -> launcher.launch(mimeType) }) + } +} + +actual class FilePickerLauncher( + private val launchAction: (String) -> Unit, +) { + actual fun launch(mimeType: String) { + launchAction(mimeType) + } +} diff --git a/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/platform/PdfPreview.android.kt b/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/platform/PdfPreview.android.kt new file mode 100644 index 0000000..e320eaf --- /dev/null +++ b/feature/files/src/androidMain/kotlin/com/librechat/android/feature/files/platform/PdfPreview.android.kt @@ -0,0 +1,291 @@ +package com.librechat.android.feature.files.platform + +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PictureAsPdf +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import co.touchlab.kermit.Logger +import com.librechat.android.feature.files.FilePreviewDisplayData +import com.librechat.android.feature.files.screen.InfoRow +import org.jetbrains.compose.resources.stringResource +import librechat_android.feature.files.generated.resources.Res +import librechat_android.feature.files.generated.resources.* +import java.io.File + +private sealed interface PdfLoadState { + data object Loading : PdfLoadState + data class Success(val pages: List) : PdfLoadState + data class Error(val message: String) : PdfLoadState +} + +@Composable +actual fun PdfPreview( + file: FilePreviewDisplayData, + onDownloadFile: (suspend (fileId: String, userId: String?) -> ByteArray?)?, + modifier: Modifier, +) { + val context = LocalContext.current + var loadState by remember { mutableStateOf(PdfLoadState.Loading) } + var tempFile by remember { mutableStateOf(null) } + + DisposableEffect(file.fileId) { + onDispose { + tempFile?.delete() + val currentState = loadState + if (currentState is PdfLoadState.Success) { + currentState.pages.forEach { bitmap -> + if (!bitmap.isRecycled) { + bitmap.recycle() + } + } + } + } + } + + LaunchedEffect(file.fileId) { + loadState = PdfLoadState.Loading + try { + val bytes = onDownloadFile?.invoke(file.fileId, file.userId) + if (bytes == null) { + loadState = PdfLoadState.Error("Failed to download PDF") + return@LaunchedEffect + } + + val pdfTempFile = File(context.cacheDir, "pdf_preview_${file.fileId}.pdf") + pdfTempFile.writeBytes(bytes) + tempFile = pdfTempFile + + val fd = ParcelFileDescriptor.open( + pdfTempFile, + ParcelFileDescriptor.MODE_READ_ONLY, + ) + val renderer = PdfRenderer(fd) + val pageCount = renderer.pageCount + Logger.d { "PdfPreview: opened PDF with $pageCount pages" } + + val maxPages = minOf(pageCount, 50) + val bitmaps = mutableListOf() + val displayMetrics = context.resources.displayMetrics + val targetWidth = displayMetrics.widthPixels + + for (i in 0 until maxPages) { + val page = renderer.openPage(i) + val scale = targetWidth.toFloat() / page.width + val bitmapWidth = targetWidth + val bitmapHeight = (page.height * scale).toInt() + + val bitmap = Bitmap.createBitmap( + bitmapWidth, + bitmapHeight, + Bitmap.Config.ARGB_8888, + ) + bitmap.eraseColor(android.graphics.Color.WHITE) + page.render( + bitmap, + null, + null, + PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY, + ) + page.close() + bitmaps.add(bitmap) + } + + renderer.close() + fd.close() + + loadState = PdfLoadState.Success(bitmaps) + if (pageCount > maxPages) { + Logger.d { "PdfPreview: showing first $maxPages of $pageCount pages" } + } + } catch (e: Exception) { + Logger.e(e) { "PdfPreview: failed to render PDF" } + loadState = PdfLoadState.Error( + e.message ?: "Failed to render PDF", + ) + } + } + + when (val state = loadState) { + is PdfLoadState.Loading -> { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator() + Text( + text = stringResource(Res.string.loading_pdf), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + is PdfLoadState.Error -> { + PdfErrorFallback( + file = file, + errorMessage = state.message, + modifier = modifier, + ) + } + is PdfLoadState.Success -> { + PdfPagesList( + pages = state.pages, + filename = file.filename, + modifier = modifier, + ) + } + } +} + +@Composable +private fun PdfPagesList( + pages: List, + filename: String, + modifier: Modifier = Modifier, +) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + val transformableState = rememberTransformableState { zoomChange, panChange, _ -> + scale = (scale * zoomChange).coerceIn(0.5f, 5f) + offset = Offset( + x = offset.x + panChange.x, + y = offset.y + panChange.y, + ) + } + + LazyColumn( + modifier = modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offset.x + translationY = offset.y + } + .transformable(state = transformableState), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + itemsIndexed( + items = pages, + key = { index, _ -> "page_$index" }, + ) { index, bitmap -> + Column { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource(Res.string.page_cd, index + 1, filename), + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth, + ) + Text( + text = stringResource(Res.string.page_of, index + 1, pages.size), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } + } + } +} + +@Composable +private fun PdfErrorFallback( + file: FilePreviewDisplayData, + errorMessage: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = Icons.Default.PictureAsPdf, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error, + ) + + Text( + text = file.filename, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = stringResource(Res.string.could_not_render_pdf), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + InfoRow(stringResource(Res.string.info_type), file.type) + InfoRow(stringResource(Res.string.info_size), file.formattedSize) + file.createdAt?.let { InfoRow(stringResource(Res.string.info_created), it) } + file.source?.let { InfoRow(stringResource(Res.string.info_source), it) } + } + } + } + } +} diff --git a/feature/files/src/test/kotlin/com/librechat/android/feature/files/di/FilesModuleVerificationTest.kt b/feature/files/src/androidUnitTest/kotlin/com/librechat/android/feature/files/di/FilesModuleVerificationTest.kt similarity index 88% rename from feature/files/src/test/kotlin/com/librechat/android/feature/files/di/FilesModuleVerificationTest.kt rename to feature/files/src/androidUnitTest/kotlin/com/librechat/android/feature/files/di/FilesModuleVerificationTest.kt index f05202c..f694401 100644 --- a/feature/files/src/test/kotlin/com/librechat/android/feature/files/di/FilesModuleVerificationTest.kt +++ b/feature/files/src/androidUnitTest/kotlin/com/librechat/android/feature/files/di/FilesModuleVerificationTest.kt @@ -13,6 +13,7 @@ class FilesModuleVerificationTest { androidx.lifecycle.SavedStateHandle::class, com.librechat.android.core.data.repository.FileRepository::class, com.librechat.android.core.data.datastore.ServerDataStore::class, + com.librechat.android.feature.files.platform.FileReader::class, ), ) } diff --git a/feature/files/src/test/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModelTest.kt b/feature/files/src/androidUnitTest/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModelTest.kt similarity index 96% rename from feature/files/src/test/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModelTest.kt rename to feature/files/src/androidUnitTest/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModelTest.kt index d9d12b3..de66132 100644 --- a/feature/files/src/test/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModelTest.kt +++ b/feature/files/src/androidUnitTest/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModelTest.kt @@ -1,18 +1,15 @@ package com.librechat.android.feature.files.viewmodel -import android.app.Application -import android.text.format.Formatter import com.google.common.truth.Truth.assertThat import com.librechat.android.core.common.result.Result import com.librechat.android.core.data.datastore.ServerDataStore import com.librechat.android.core.data.repository.FileRepository import com.librechat.android.core.model.FileObject +import com.librechat.android.feature.files.platform.FileReader import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkStatic import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -30,7 +27,7 @@ class FilesViewModelTest { private val testDispatcher = StandardTestDispatcher() private val fileRepository = mockk(relaxed = true) - private val context = mockk(relaxed = true) + private val fileReader = mockk(relaxed = true) private val serverDataStore = mockk(relaxed = true) private lateinit var viewModel: FilesViewModel @@ -65,8 +62,6 @@ class FilesViewModelTest { @Before fun setup() { Dispatchers.setMain(testDispatcher) - mockkStatic(Formatter::class) - every { Formatter.formatShortFileSize(any(), any()) } returns "1 KB" coEvery { fileRepository.getFiles() } returns Result.Success(testFiles) every { serverDataStore.getBaseUrl() } returns "https://chat.example.com" } @@ -74,12 +69,11 @@ class FilesViewModelTest { @After fun tearDown() { Dispatchers.resetMain() - unmockkStatic(Formatter::class) } private fun createViewModel() = FilesViewModel( fileRepository = fileRepository, - context = context, + fileReader = fileReader, serverDataStore = serverDataStore, ) diff --git a/feature/files/src/main/res/values/strings.xml b/feature/files/src/commonMain/composeResources/values/strings.xml similarity index 91% rename from feature/files/src/main/res/values/strings.xml rename to feature/files/src/commonMain/composeResources/values/strings.xml index ac59488..8976a19 100644 --- a/feature/files/src/main/res/values/strings.xml +++ b/feature/files/src/commonMain/composeResources/values/strings.xml @@ -38,19 +38,19 @@ Delete %1$d file%2$s? This action cannot be undone. Delete file? - Delete \"%1$s\"? This action cannot be undone. + Delete "%1$s"? This action cannot be undone. this file Image preview unavailable Preview of %1$s - Loading PDF\u2026 + Loading PDF… Page %1$d of %2$d Page %1$d of %2$s Could not render PDF preview Failed to download PDF Failed to render PDF - Loading file content\u2026 + Loading file content… Could not load file content Failed to download file Failed to load file content @@ -63,5 +63,5 @@ Source - Uploading\u2026 + Uploading… diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/FileDisplayData.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/FileDisplayData.kt similarity index 100% rename from feature/files/src/main/kotlin/com/librechat/android/feature/files/FileDisplayData.kt rename to feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/FileDisplayData.kt diff --git a/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/components/FileTypeFilterBar.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/components/FileTypeFilterBar.kt new file mode 100644 index 0000000..2a67246 --- /dev/null +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/components/FileTypeFilterBar.kt @@ -0,0 +1,22 @@ +package com.librechat.android.feature.files.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.librechat.android.core.ui.components.FilterChipBar +import com.librechat.android.feature.files.viewmodel.FileTypeFilter + +/** Horizontal FilterChip row that filters files by MIME type prefix (All, Images, Documents, Audio, Video). */ +@Composable +fun FileTypeFilterBar( + selectedFilter: FileTypeFilter, + onFilterChange: (FileTypeFilter) -> Unit, + modifier: Modifier = Modifier, +) { + FilterChipBar( + items = FileTypeFilter.entries, + isSelected = { it == selectedFilter }, + onSelect = onFilterChange, + label = { it.label }, + modifier = modifier, + ) +} diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/components/UploadProgressCard.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/components/UploadProgressCard.kt similarity index 92% rename from feature/files/src/main/kotlin/com/librechat/android/feature/files/components/UploadProgressCard.kt rename to feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/components/UploadProgressCard.kt index ff1656c..1aa5d32 100644 --- a/feature/files/src/main/kotlin/com/librechat/android/feature/files/components/UploadProgressCard.kt +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/components/UploadProgressCard.kt @@ -25,10 +25,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.librechat.android.feature.files.R +import org.jetbrains.compose.resources.stringResource +import librechat_android.feature.files.generated.resources.Res +import librechat_android.feature.files.generated.resources.* /** Animated card showing upload filename, progress bar, and cancel button with slide/fade visibility transitions. */ @Composable @@ -88,7 +89,7 @@ fun UploadProgressCard( text = if (progress != null) { "${(progress * 100).toInt()}%" } else { - stringResource(R.string.uploading) + stringResource(Res.string.uploading) }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -97,7 +98,7 @@ fun UploadProgressCard( IconButton(onClick = onCancel) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_cancel_upload), + contentDescription = stringResource(Res.string.cd_cancel_upload), ) } } diff --git a/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/di/FilesModule.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/di/FilesModule.kt new file mode 100644 index 0000000..f63a4bf --- /dev/null +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/di/FilesModule.kt @@ -0,0 +1,13 @@ +package com.librechat.android.feature.files.di + +import com.librechat.android.feature.files.viewmodel.FilesViewModel +import org.koin.core.module.Module +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +expect val filesPlatformModule: Module + +val filesModule = module { + includes(filesPlatformModule) + viewModelOf(::FilesViewModel) +} diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/navigation/FilesNavigation.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/navigation/FilesNavigation.kt similarity index 78% rename from feature/files/src/main/kotlin/com/librechat/android/feature/files/navigation/FilesNavigation.kt rename to feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/navigation/FilesNavigation.kt index d346391..9073791 100644 --- a/feature/files/src/main/kotlin/com/librechat/android/feature/files/navigation/FilesNavigation.kt +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/navigation/FilesNavigation.kt @@ -7,10 +7,12 @@ import com.librechat.android.feature.files.screen.FilesScreen const val FILES_ROUTE = "files" -fun NavGraphBuilder.filesGraph() { +fun NavGraphBuilder.filesGraph( + onBack: (() -> Unit)? = null, +) { composable(FILES_ROUTE) { ScreenTransitionWrapper(transition) { - FilesScreen() + FilesScreen(onBack = onBack) } } } diff --git a/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/CommonMimeTypes.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/CommonMimeTypes.kt new file mode 100644 index 0000000..17c1923 --- /dev/null +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/CommonMimeTypes.kt @@ -0,0 +1,85 @@ +package com.librechat.android.feature.files.platform + +/** + * Shared extension-to-MIME-type mapping used by both platforms. + * iOS uses this as the primary resolver; Android uses it as a fallback + * when ContentResolver.getType() returns null. + */ +object CommonMimeTypes { + + private val EXTENSION_TO_MIME = mapOf( + // Images + "jpg" to "image/jpeg", + "jpeg" to "image/jpeg", + "png" to "image/png", + "gif" to "image/gif", + "webp" to "image/webp", + "svg" to "image/svg+xml", + "bmp" to "image/bmp", + "ico" to "image/x-icon", + "heic" to "image/heic", + "heif" to "image/heif", + "tiff" to "image/tiff", + "tif" to "image/tiff", + // Documents + "pdf" to "application/pdf", + "doc" to "application/msword", + "docx" to "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" to "application/vnd.ms-excel", + "xlsx" to "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" to "application/vnd.ms-powerpoint", + "pptx" to "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "txt" to "text/plain", + "csv" to "text/csv", + "md" to "text/markdown", + "rtf" to "application/rtf", + // Code / data + "json" to "application/json", + "xml" to "application/xml", + "yaml" to "application/x-yaml", + "yml" to "application/x-yaml", + "html" to "text/html", + "htm" to "text/html", + "css" to "text/css", + "js" to "application/javascript", + "ts" to "application/typescript", + "py" to "text/x-python", + "kt" to "text/x-kotlin", + "java" to "text/x-java-source", + "swift" to "text/x-swift", + "c" to "text/x-c", + "cpp" to "text/x-c++src", + "h" to "text/x-c", + "rs" to "text/x-rust", + "go" to "text/x-go", + "rb" to "text/x-ruby", + "sh" to "application/x-sh", + "sql" to "application/sql", + // Audio + "mp3" to "audio/mpeg", + "wav" to "audio/wav", + "aac" to "audio/aac", + "ogg" to "audio/ogg", + "m4a" to "audio/mp4", + "flac" to "audio/flac", + // Video + "mp4" to "video/mp4", + "mov" to "video/quicktime", + "avi" to "video/x-msvideo", + "mkv" to "video/x-matroska", + "webm" to "video/webm", + // Archives + "zip" to "application/zip", + "tar" to "application/x-tar", + "gz" to "application/gzip", + "7z" to "application/x-7z-compressed", + "rar" to "application/vnd.rar", + ) + + /** + * Returns the MIME type for the given file extension, or null if unknown. + * Extension matching is case-insensitive. + */ + fun fromExtension(extension: String): String? = + EXTENSION_TO_MIME[extension.lowercase()] +} diff --git a/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/FilePicker.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/FilePicker.kt new file mode 100644 index 0000000..ec93f02 --- /dev/null +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/FilePicker.kt @@ -0,0 +1,18 @@ +package com.librechat.android.feature.files.platform + +import androidx.compose.runtime.Composable + +/** + * Platform file picker composable. + * When [launch] is called, shows the platform's native file picker. + * On file selection, calls [onFilePicked] with a platform-specific file reference + * (Android Uri, iOS NSURL). + */ +@Composable +expect fun rememberFilePickerLauncher( + onFilePicked: (fileRef: Any) -> Unit, +): FilePickerLauncher + +expect class FilePickerLauncher { + fun launch(mimeType: String) +} diff --git a/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/FileReader.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/FileReader.kt new file mode 100644 index 0000000..cc45cff --- /dev/null +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/FileReader.kt @@ -0,0 +1,25 @@ +package com.librechat.android.feature.files.platform + +/** + * Platform-abstracted file reader. + * Android: ContentResolver + Uri + * iOS: NSFileManager / PHAsset + */ +interface FileReader { + /** + * Reads file bytes from a platform-specific file reference. + * @param fileRef Opaque platform reference (Android Uri, iOS URL) + * @return file bytes, or null if unreadable + */ + fun readBytes(fileRef: Any): ByteArray? + + /** + * Resolves the display filename from a platform file reference. + */ + fun getFileName(fileRef: Any): String? + + /** + * Resolves the MIME type from a platform file reference. + */ + fun getMimeType(fileRef: Any): String? +} diff --git a/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/FileSizeFormatter.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/FileSizeFormatter.kt new file mode 100644 index 0000000..d84fa02 --- /dev/null +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/FileSizeFormatter.kt @@ -0,0 +1,6 @@ +package com.librechat.android.feature.files.platform + +/** + * Formats a file size in bytes to a human-readable string (e.g. "1.2 MB"). + */ +expect fun formatFileSize(bytes: Long): String diff --git a/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/PdfPreview.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/PdfPreview.kt new file mode 100644 index 0000000..c93a809 --- /dev/null +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/platform/PdfPreview.kt @@ -0,0 +1,17 @@ +package com.librechat.android.feature.files.platform + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.librechat.android.feature.files.FilePreviewDisplayData + +/** + * Platform-specific PDF preview composable. + * Android: Uses PdfRenderer to render pages as bitmaps. + * iOS: Stub placeholder for now. + */ +@Composable +expect fun PdfPreview( + file: FilePreviewDisplayData, + onDownloadFile: (suspend (fileId: String, userId: String?) -> ByteArray?)?, + modifier: Modifier = Modifier, +) diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilePreviewDialog.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/screen/FilePreviewDialog.kt similarity index 55% rename from feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilePreviewDialog.kt rename to feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/screen/FilePreviewDialog.kt index 9159edf..419828c 100644 --- a/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilePreviewDialog.kt +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/screen/FilePreviewDialog.kt @@ -1,9 +1,5 @@ package com.librechat.android.feature.files.screen -import android.graphics.Bitmap -import android.graphics.pdf.PdfRenderer -import android.os.ParcelFileDescriptor -import androidx.compose.foundation.Image import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.transformable import androidx.compose.foundation.horizontalScroll @@ -18,8 +14,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -41,7 +35,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -51,24 +44,22 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import coil.compose.AsyncImage -import coil.compose.AsyncImagePainter +import coil3.compose.AsyncImage +import co.touchlab.kermit.Logger import com.librechat.android.feature.files.FilePreviewDisplayData -import com.librechat.android.feature.files.R -import timber.log.Timber -import java.io.File +import com.librechat.android.feature.files.platform.PdfPreview +import org.jetbrains.compose.resources.stringResource +import librechat_android.feature.files.generated.resources.Res +import librechat_android.feature.files.generated.resources.* @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -97,7 +88,7 @@ fun FilePreviewDialog( IconButton(onClick = onDismiss) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_close_preview), + contentDescription = stringResource(Res.string.cd_close_preview), ) } }, @@ -168,26 +159,10 @@ private fun ImagePreview( contentAlignment = Alignment.Center, ) { if (url != null) { - Timber.d("ImagePreview loading URL: %s", url) + Logger.d { "ImagePreview loading URL: $url" } AsyncImage( model = url, - contentDescription = stringResource(R.string.preview_of, filename), - onState = { state -> - when (state) { - is AsyncImagePainter.State.Loading -> - Timber.d("ImagePreview: loading %s", url) - is AsyncImagePainter.State.Success -> - Timber.d("ImagePreview: success %s", url) - is AsyncImagePainter.State.Error -> - Timber.e( - state.result.throwable, - "ImagePreview: error loading %s", - url, - ) - is AsyncImagePainter.State.Empty -> - Timber.d("ImagePreview: empty state for %s", url) - } - }, + contentDescription = stringResource(Res.string.preview_of, filename), modifier = Modifier .fillMaxSize() .graphicsLayer { @@ -211,7 +186,7 @@ private fun ImagePreview( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.image_preview_unavailable), + text = stringResource(Res.string.image_preview_unavailable), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -220,261 +195,6 @@ private fun ImagePreview( } } -// -- PDF Preview -- - -private sealed interface PdfLoadState { - data object Loading : PdfLoadState - data class Success(val pages: List) : PdfLoadState - data class Error(val message: String) : PdfLoadState -} - -@Composable -private fun PdfPreview( - file: FilePreviewDisplayData, - onDownloadFile: (suspend (fileId: String, userId: String?) -> ByteArray?)?, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - var loadState by remember { mutableStateOf(PdfLoadState.Loading) } - var tempFile by remember { mutableStateOf(null) } - - // Clean up temp file when composable leaves composition - DisposableEffect(file.fileId) { - onDispose { - tempFile?.delete() - // Recycle bitmaps - val currentState = loadState - if (currentState is PdfLoadState.Success) { - currentState.pages.forEach { bitmap -> - if (!bitmap.isRecycled) { - bitmap.recycle() - } - } - } - } - } - - // Download and render PDF - LaunchedEffect(file.fileId) { - loadState = PdfLoadState.Loading - try { - val bytes = onDownloadFile?.invoke(file.fileId, file.userId) - if (bytes == null) { - loadState = PdfLoadState.Error(context.getString(R.string.failed_to_download_pdf)) - return@LaunchedEffect - } - - // Write to temp file - val pdfTempFile = File(context.cacheDir, "pdf_preview_${file.fileId}.pdf") - pdfTempFile.writeBytes(bytes) - tempFile = pdfTempFile - - // Render pages with PdfRenderer - val fd = ParcelFileDescriptor.open( - pdfTempFile, - ParcelFileDescriptor.MODE_READ_ONLY, - ) - val renderer = PdfRenderer(fd) - val pageCount = renderer.pageCount - Timber.d("PdfPreview: opened PDF with %d pages", pageCount) - - // Render up to 50 pages to avoid memory issues on very large PDFs - val maxPages = minOf(pageCount, 50) - val bitmaps = mutableListOf() - - // Calculate a reasonable width based on screen density - val displayMetrics = context.resources.displayMetrics - val targetWidth = displayMetrics.widthPixels - - for (i in 0 until maxPages) { - val page = renderer.openPage(i) - // Scale to fill screen width while maintaining aspect ratio - val scale = targetWidth.toFloat() / page.width - val bitmapWidth = targetWidth - val bitmapHeight = (page.height * scale).toInt() - - val bitmap = Bitmap.createBitmap( - bitmapWidth, - bitmapHeight, - Bitmap.Config.ARGB_8888, - ) - // Render with white background - bitmap.eraseColor(android.graphics.Color.WHITE) - page.render( - bitmap, - null, - null, - PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY, - ) - page.close() - bitmaps.add(bitmap) - } - - renderer.close() - fd.close() - - loadState = PdfLoadState.Success(bitmaps) - if (pageCount > maxPages) { - Timber.d("PdfPreview: showing first %d of %d pages", maxPages, pageCount) - } - } catch (e: Exception) { - Timber.e(e, "PdfPreview: failed to render PDF") - loadState = PdfLoadState.Error( - e.message ?: context.getString(R.string.failed_to_render_pdf), - ) - } - } - - when (val state = loadState) { - is PdfLoadState.Loading -> { - Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - CircularProgressIndicator() - Text( - text = stringResource(R.string.loading_pdf), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - is PdfLoadState.Error -> { - PdfErrorFallback( - file = file, - errorMessage = state.message, - modifier = modifier, - ) - } - is PdfLoadState.Success -> { - PdfPagesList( - pages = state.pages, - filename = file.filename, - modifier = modifier, - ) - } - } -} - -@Composable -private fun PdfPagesList( - pages: List, - filename: String, - modifier: Modifier = Modifier, -) { - var scale by remember { mutableFloatStateOf(1f) } - var offset by remember { mutableStateOf(Offset.Zero) } - - val transformableState = rememberTransformableState { zoomChange, panChange, _ -> - scale = (scale * zoomChange).coerceIn(0.5f, 5f) - offset = Offset( - x = offset.x + panChange.x, - y = offset.y + panChange.y, - ) - } - - LazyColumn( - modifier = modifier - .graphicsLayer { - scaleX = scale - scaleY = scale - translationX = offset.x - translationY = offset.y - } - .transformable(state = transformableState), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - itemsIndexed( - items = pages, - key = { index, _ -> "page_$index" }, - ) { index, bitmap -> - Column { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = stringResource(R.string.page_cd, index + 1, filename), - modifier = Modifier.fillMaxWidth(), - contentScale = ContentScale.FillWidth, - ) - // Page number indicator - Text( - text = stringResource(R.string.page_of, index + 1, pages.size), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) - } - } - } -} - -@Composable -private fun PdfErrorFallback( - file: FilePreviewDisplayData, - errorMessage: String, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Icon( - imageVector = Icons.Default.PictureAsPdf, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.error, - ) - - Text( - text = file.filename, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - Text( - text = stringResource(R.string.could_not_render_pdf), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Text( - text = errorMessage, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - ) - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - InfoRow(stringResource(R.string.info_type), file.type) - InfoRow(stringResource(R.string.info_size), file.formattedSize) - file.createdAt?.let { InfoRow(stringResource(R.string.info_created), it) } - file.source?.let { InfoRow(stringResource(R.string.info_source), it) } - } - } - } - } -} - // -- Text Content Preview -- private sealed interface TextLoadState { @@ -489,7 +209,6 @@ private fun TextContentPreview( onDownloadFile: (suspend (fileId: String, userId: String?) -> ByteArray?)?, modifier: Modifier = Modifier, ) { - val context = LocalContext.current var loadState by remember { mutableStateOf(TextLoadState.Loading) } LaunchedEffect(file.fileId) { @@ -497,21 +216,20 @@ private fun TextContentPreview( try { val bytes = onDownloadFile?.invoke(file.fileId, file.userId) if (bytes == null) { - loadState = TextLoadState.Error(context.getString(R.string.failed_to_download_file)) + loadState = TextLoadState.Error("Failed to download file") return@LaunchedEffect } val content = bytes.decodeToString() - // Limit to ~500KB of text to avoid UI performance issues val truncated = if (content.length > 500_000) { - content.take(500_000) + context.getString(R.string.content_truncated) + content.take(500_000) + "\n\n--- Content truncated (file too large) ---" } else { content } loadState = TextLoadState.Success(truncated) } catch (e: Exception) { - Timber.e(e, "TextContentPreview: failed to load text content") + Logger.e(e) { "TextContentPreview: failed to load text content" } loadState = TextLoadState.Error( - e.message ?: context.getString(R.string.failed_to_load_content), + e.message ?: "Failed to load file content", ) } } @@ -528,7 +246,7 @@ private fun TextContentPreview( ) { CircularProgressIndicator() Text( - text = stringResource(R.string.loading_file_content), + text = stringResource(Res.string.loading_file_content), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -551,7 +269,6 @@ private fun TextContentPreview( .verticalScroll(verticalScrollState) .padding(16.dp), ) { - // File info header Text( text = file.filename, style = MaterialTheme.typography.titleMedium, @@ -565,7 +282,6 @@ private fun TextContentPreview( HorizontalDivider() Spacer(modifier = Modifier.height(12.dp)) - // Scrollable text content with monospace font Box( modifier = Modifier .fillMaxWidth() @@ -619,7 +335,7 @@ private fun TextErrorFallback( ) Text( - text = stringResource(R.string.could_not_load_file_content), + text = stringResource(Res.string.could_not_load_file_content), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -634,10 +350,10 @@ private fun TextErrorFallback( Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { - InfoRow(stringResource(R.string.info_type), file.type) - InfoRow(stringResource(R.string.info_size), file.formattedSize) - file.createdAt?.let { InfoRow(stringResource(R.string.info_created), it) } - file.source?.let { InfoRow(stringResource(R.string.info_source), it) } + InfoRow(stringResource(Res.string.info_type), file.type) + InfoRow(stringResource(Res.string.info_size), file.formattedSize) + file.createdAt?.let { InfoRow(stringResource(Res.string.info_created), it) } + file.source?.let { InfoRow(stringResource(Res.string.info_source), it) } } } } @@ -682,10 +398,10 @@ private fun FileInfoCard( Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { - InfoRow(stringResource(R.string.info_type), file.type) - InfoRow(stringResource(R.string.info_size), file.formattedSize) - file.createdAt?.let { InfoRow(stringResource(R.string.info_created), it) } - file.source?.let { InfoRow(stringResource(R.string.info_source), it) } + InfoRow(stringResource(Res.string.info_type), file.type) + InfoRow(stringResource(Res.string.info_size), file.formattedSize) + file.createdAt?.let { InfoRow(stringResource(Res.string.info_created), it) } + file.source?.let { InfoRow(stringResource(Res.string.info_source), it) } } } } @@ -693,7 +409,7 @@ private fun FileInfoCard( } @Composable -private fun InfoRow( +internal fun InfoRow( label: String, value: String, ) { diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FileSortMenu.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/screen/FileSortMenu.kt similarity index 90% rename from feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FileSortMenu.kt rename to feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/screen/FileSortMenu.kt index e6b1d76..39d6b29 100644 --- a/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FileSortMenu.kt +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/screen/FileSortMenu.kt @@ -15,9 +15,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.files.R +import org.jetbrains.compose.resources.stringResource +import librechat_android.feature.files.generated.resources.Res +import librechat_android.feature.files.generated.resources.* import com.librechat.android.feature.files.viewmodel.FileSortField import com.librechat.android.feature.files.viewmodel.FileSortOrder @@ -35,7 +36,6 @@ fun FileSortMenu( onDismissRequest = onDismiss, modifier = modifier, ) { - // Sort field options FileSortField.entries.forEach { field -> val isSelected = field == currentSortField DropdownMenuItem( @@ -59,7 +59,6 @@ fun FileSortMenu( }, onClick = { if (isSelected) { - // Toggle sort order val newOrder = if (currentSortOrder == FileSortOrder.ASCENDING) { FileSortOrder.DESCENDING } else { @@ -67,7 +66,6 @@ fun FileSortMenu( } onSortSelected(field, newOrder) } else { - // Select field with default descending onSortSelected(field, FileSortOrder.DESCENDING) } onDismiss() @@ -81,9 +79,9 @@ fun FileSortMenu( Icons.Default.ArrowDownward }, contentDescription = if (currentSortOrder == FileSortOrder.ASCENDING) { - stringResource(R.string.cd_ascending) + stringResource(Res.string.cd_ascending) } else { - stringResource(R.string.cd_descending) + stringResource(Res.string.cd_descending) }, ) } diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilesScreen.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/screen/FilesScreen.kt similarity index 89% rename from feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilesScreen.kt rename to feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/screen/FilesScreen.kt index 6150311..3f85863 100644 --- a/feature/files/src/main/kotlin/com/librechat/android/feature/files/screen/FilesScreen.kt +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/screen/FilesScreen.kt @@ -1,7 +1,5 @@ package com.librechat.android.feature.files.screen -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -23,6 +21,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.automirrored.filled.ViewList @@ -70,26 +69,29 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.compose.AsyncImage +import coil3.compose.AsyncImage import com.librechat.android.core.ui.components.EmptyState import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.feature.files.FileDisplayData -import com.librechat.android.feature.files.R import com.librechat.android.feature.files.components.UploadProgressCard +import com.librechat.android.feature.files.platform.rememberFilePickerLauncher import com.librechat.android.feature.files.viewmodel.FileTypeFilter import com.librechat.android.feature.files.viewmodel.FileViewMode import com.librechat.android.feature.files.viewmodel.FilesViewModel +import org.jetbrains.compose.resources.stringResource +import librechat_android.feature.files.generated.resources.Res +import librechat_android.feature.files.generated.resources.* import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun FilesScreen( modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, viewModel: FilesViewModel = koinViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -98,13 +100,9 @@ fun FilesScreen( var singleDeleteFileId by remember { mutableStateOf(null) } var showSortMenu by remember { mutableStateOf(false) } - val filePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - ) { uri -> - if (uri != null) { - viewModel.uploadFile(uri) - } - } + val filePickerLauncher = rememberFilePickerLauncher( + onFilePicked = { fileRef -> viewModel.uploadFile(fileRef) }, + ) LaunchedEffect(uiState.error) { val error = uiState.error ?: return@LaunchedEffect @@ -122,9 +120,9 @@ fun FilesScreen( val count = uiState.selectedFileIds.size Text( if (count == 0) { - stringResource(R.string.select_files) + stringResource(Res.string.select_files) } else { - stringResource(R.string.selected_count, count) + stringResource(Res.string.selected_count, count) }, ) }, @@ -132,7 +130,7 @@ fun FilesScreen( IconButton(onClick = { viewModel.exitSelectionMode() }) { Icon( imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_exit_edit_mode), + contentDescription = stringResource(Res.string.cd_exit_edit_mode), ) } }, @@ -140,7 +138,7 @@ fun FilesScreen( IconButton(onClick = { viewModel.selectAll() }) { Icon( imageVector = Icons.Default.SelectAll, - contentDescription = stringResource(R.string.cd_select_all), + contentDescription = stringResource(Res.string.cd_select_all), ) } IconButton( @@ -149,7 +147,7 @@ fun FilesScreen( ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.cd_delete_selected), + contentDescription = stringResource(Res.string.cd_delete_selected), tint = if (uiState.selectedFileIds.isNotEmpty()) { MaterialTheme.colorScheme.error } else { @@ -158,19 +156,29 @@ fun FilesScreen( ) } TextButton(onClick = { viewModel.exitSelectionMode() }) { - Text(stringResource(R.string.done)) + Text(stringResource(Res.string.done)) } }, ) } else { TopAppBar( - title = { Text(stringResource(R.string.files)) }, + title = { Text(stringResource(Res.string.files)) }, + navigationIcon = { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + } + }, actions = { if (uiState.hasFiles) { IconButton(onClick = { viewModel.enterEditMode() }) { Icon( imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.cd_edit_files), + contentDescription = stringResource(Res.string.cd_edit_files), ) } } @@ -181,8 +189,8 @@ fun FilesScreen( FileViewMode.GRID -> Icons.AutoMirrored.Filled.ViewList }, contentDescription = when (uiState.viewMode) { - FileViewMode.LIST -> stringResource(R.string.cd_switch_to_grid) - FileViewMode.GRID -> stringResource(R.string.cd_switch_to_list) + FileViewMode.LIST -> stringResource(Res.string.cd_switch_to_grid) + FileViewMode.GRID -> stringResource(Res.string.cd_switch_to_list) }, ) } @@ -190,7 +198,7 @@ fun FilesScreen( IconButton(onClick = { showSortMenu = true }) { Icon( imageVector = Icons.AutoMirrored.Filled.Sort, - contentDescription = stringResource(R.string.cd_sort_files), + contentDescription = stringResource(Res.string.cd_sort_files), ) } FileSortMenu( @@ -214,7 +222,7 @@ fun FilesScreen( ) { Icon( imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.cd_upload_file), + contentDescription = stringResource(Res.string.cd_upload_file), ) } } @@ -255,7 +263,7 @@ fun FilesScreen( } uiState.error != null && !uiState.hasFiles -> { ErrorBanner( - message = uiState.error ?: stringResource(R.string.could_not_load_files), + message = uiState.error ?: stringResource(Res.string.could_not_load_files), onRetry = { viewModel.dismissError() viewModel.loadFiles() @@ -265,14 +273,14 @@ fun FilesScreen( uiState.displayFiles.isEmpty() && !uiState.isRefreshing -> { EmptyState( title = if (uiState.selectedFilter == FileTypeFilter.ALL) { - stringResource(R.string.no_files) + stringResource(Res.string.no_files) } else { - stringResource(R.string.no_filter_files, uiState.selectedFilter.label.lowercase()) + stringResource(Res.string.no_filter_files, uiState.selectedFilter.label.lowercase()) }, description = if (uiState.selectedFilter == FileTypeFilter.ALL) { - stringResource(R.string.upload_to_get_started) + stringResource(Res.string.upload_to_get_started) } else { - stringResource(R.string.no_filter_files_found, uiState.selectedFilter.label.lowercase()) + stringResource(Res.string.no_filter_files_found, uiState.selectedFilter.label.lowercase()) }, icon = Icons.Default.Folder, ) @@ -357,10 +365,10 @@ fun FilesScreen( val count = uiState.selectedFileIds.size AlertDialog( onDismissRequest = { showDeleteConfirmation = false }, - title = { Text(stringResource(R.string.delete_count_files, count, if (count > 1) "s" else "")) }, + title = { Text(stringResource(Res.string.delete_count_files, count, if (count > 1) "s" else "")) }, text = { Text( - text = stringResource(R.string.action_cannot_be_undone), + text = stringResource(Res.string.action_cannot_be_undone), style = MaterialTheme.typography.bodyMedium, ) }, @@ -370,14 +378,14 @@ fun FilesScreen( showDeleteConfirmation = false }) { Text( - text = stringResource(R.string.delete), + text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error, ) } }, dismissButton = { TextButton(onClick = { showDeleteConfirmation = false }) { - Text(stringResource(R.string.cancel)) + Text(stringResource(Res.string.cancel)) } }, ) @@ -387,13 +395,13 @@ fun FilesScreen( val pendingDeleteId = singleDeleteFileId if (pendingDeleteId != null) { val fileName = uiState.displayFiles - .find { it.fileId == pendingDeleteId }?.filename ?: stringResource(R.string.this_file) + .find { it.fileId == pendingDeleteId }?.filename ?: stringResource(Res.string.this_file) AlertDialog( onDismissRequest = { singleDeleteFileId = null }, - title = { Text(stringResource(R.string.delete_file_question)) }, + title = { Text(stringResource(Res.string.delete_file_question)) }, text = { Text( - text = stringResource(R.string.delete_file_message, fileName), + text = stringResource(Res.string.delete_file_message, fileName), style = MaterialTheme.typography.bodyMedium, ) }, @@ -403,14 +411,14 @@ fun FilesScreen( singleDeleteFileId = null }) { Text( - text = stringResource(R.string.delete), + text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error, ) } }, dismissButton = { TextButton(onClick = { singleDeleteFileId = null }) { - Text(stringResource(R.string.cancel)) + Text(stringResource(Res.string.cancel)) } }, ) @@ -484,7 +492,7 @@ private fun FileItem( IconButton(onClick = onDelete) { Icon( imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.cd_delete_file, file.filename), + contentDescription = stringResource(Res.string.cd_delete_file, file.filename), tint = MaterialTheme.colorScheme.error, ) } @@ -533,7 +541,7 @@ private fun FileGridItem( if (file.type.startsWith("image/") && file.previewUrl != null) { AsyncImage( model = file.previewUrl, - contentDescription = stringResource(R.string.cd_thumbnail, file.filename), + contentDescription = stringResource(Res.string.cd_thumbnail, file.filename), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, ) @@ -591,9 +599,6 @@ private fun fileTypeIcon(type: String): ImageVector = when { else -> Icons.Default.Description } -/** - * Returns a more specific icon for grid view where the icon is the primary visual element. - */ private fun fileTypeIconLarge(type: String): ImageVector = when { type.startsWith("image/") -> Icons.Default.Image type.startsWith("video/") -> Icons.Default.VideoFile diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModel.kt b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModel.kt similarity index 82% rename from feature/files/src/main/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModel.kt rename to feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModel.kt index a12c751..609390b 100644 --- a/feature/files/src/main/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModel.kt +++ b/feature/files/src/commonMain/kotlin/com/librechat/android/feature/files/viewmodel/FilesViewModel.kt @@ -1,9 +1,5 @@ package com.librechat.android.feature.files.viewmodel -import android.app.Application -import android.net.Uri -import android.provider.OpenableColumns -import android.text.format.Formatter import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -14,14 +10,17 @@ import com.librechat.android.core.model.FileObject import com.librechat.android.core.model.request.DeleteFileEntry import com.librechat.android.feature.files.FileDisplayData import com.librechat.android.feature.files.FilePreviewDisplayData +import com.librechat.android.feature.files.platform.FileReader +import com.librechat.android.feature.files.platform.formatFileSize import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import timber.log.Timber -import java.util.UUID +import co.touchlab.kermit.Logger +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid enum class FileTypeFilter(val label: String) { ALL("All"), @@ -69,7 +68,7 @@ data class FilesUiState( class FilesViewModel( private val fileRepository: FileRepository, - private val context: Application, + private val fileReader: FileReader, private val serverDataStore: ServerDataStore, ) : ViewModel() { @@ -80,7 +79,7 @@ class FilesViewModel( private val _transientState = MutableStateFlow(TransientState()) - /** Cache display data by fileId to avoid re-running Formatter.formatShortFileSize on every emission. */ + /** Cache display data by fileId to avoid re-running formatFileSize on every emission. */ private val displayDataCache = mutableMapOf() val uiState: StateFlow = combine( @@ -169,9 +168,14 @@ class FilesViewModel( private var uploadJob: kotlinx.coroutines.Job? = null - fun uploadFile(uri: Uri) { + /** + * Upload a file from a platform-specific file reference. + * On Android this is a Uri; on iOS it will be an NSURL. + */ + @OptIn(ExperimentalUuidApi::class) + fun uploadFile(fileRef: Any) { uploadJob = viewModelScope.launch { - val filename = getFileName(uri) ?: "upload" + val filename = fileReader.getFileName(fileRef) ?: "upload" updateTransient { copy( isUploading = true, @@ -180,10 +184,10 @@ class FilesViewModel( error = null, ) } - val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream" - val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + val mimeType = fileReader.getMimeType(fileRef) ?: "application/octet-stream" + val bytes = fileReader.readBytes(fileRef) if (bytes == null) { - Timber.e("uploadFile: could not read bytes from URI: %s", uri) + Logger.e { "uploadFile: could not read bytes from file reference" } updateTransient { copy( isUploading = false, @@ -195,8 +199,8 @@ class FilesViewModel( return@launch } - val fileId = UUID.randomUUID().toString() - Timber.d("uploadFile: filename=%s, mimeType=%s, size=%d, fileId=%s", filename, mimeType, bytes.size, fileId) + val fileId = Uuid.random().toString() + Logger.d { "uploadFile: filename=$filename, mimeType=$mimeType, size=${bytes.size}, fileId=$fileId" } when (val result = fileRepository.uploadFile( bytes = bytes, @@ -206,7 +210,7 @@ class FilesViewModel( endpoint = "agents", )) { is Result.Success -> { - Timber.d("uploadFile: success -- serverFileId=%s", result.data.fileId) + Logger.d { "uploadFile: success -- serverFileId=${result.data.fileId}" } _files.value = listOf(result.data) + _files.value updateTransient { copy( @@ -217,7 +221,7 @@ class FilesViewModel( } } is Result.Error -> { - Timber.e(result.exception, "uploadFile: server error -- %s", result.message) + Logger.e(result.exception) { "uploadFile: server error -- ${result.message}" } updateTransient { copy( isUploading = false, @@ -295,24 +299,18 @@ class FilesViewModel( updateTransient { copy(previewFile = null) } } - /** - * Downloads the raw bytes of a file from the server. - * - * Uses [FileRepository.downloadFile] which calls `GET /api/files/download/:userId/:fileId`. - * Returns null if the user ID is missing or the download fails. - */ suspend fun downloadFileBytes(fileId: String, userId: String?): ByteArray? { if (userId.isNullOrBlank()) { - Timber.w("downloadFileBytes: userId is null/blank for fileId=%s", fileId) + Logger.w { "downloadFileBytes: userId is null/blank for fileId=$fileId" } return null } return when (val result = fileRepository.downloadFile(userId, fileId)) { is Result.Success -> { - Timber.d("downloadFileBytes: success, %d bytes for fileId=%s", result.data.size, fileId) + Logger.d { "downloadFileBytes: success, ${result.data.size} bytes for fileId=$fileId" } result.data } is Result.Error -> { - Timber.e(result.exception, "downloadFileBytes: error for fileId=%s: %s", fileId, result.message) + Logger.e(result.exception) { "downloadFileBytes: error for fileId=$fileId: ${result.message}" } null } is Result.Loading -> null @@ -429,40 +427,21 @@ class FilesViewModel( return if (order == FileSortOrder.DESCENDING) sorted.reversed() else sorted } - /** - * Builds the image preview URL for a file. - * - * The web frontend uses the `filepath` field directly (e.g. `/images/userId/file.webp`) - * prepended with the server base URL. For images stored locally, this path is served - * statically by the server. For non-local storage (S3, Firebase), `filepath` may already - * be an absolute URL. - * - * As a fallback when `filepath` is blank, we use the download endpoint - * (`/api/files/download/:userId/:fileId`) which streams the file with auth. - */ private fun FileObject.buildImagePreviewUrl(): String? { if (!type.startsWith("image/")) return null return buildFileUrl() } - /** - * Builds the download/preview URL for any file type. - * - * Uses the same logic as [buildImagePreviewUrl] but works for all MIME types. - */ private fun FileObject.buildFileUrl(): String { val baseUrl = serverDataStore.getBaseUrl().trimEnd('/') val url = if (filepath.startsWith("http://") || filepath.startsWith("https://")) { - // Already an absolute URL (e.g. S3/CDN) filepath } else if (filepath.isNotBlank()) { - // Relative path from server (e.g. /images/userId/file.webp) "$baseUrl$filepath" } else { - // Fallback: use the download endpoint "$baseUrl/api/files/download/${user ?: ""}/$fileId" } - Timber.d("File URL for %s (%s): %s", filename, fileId, url) + Logger.d { "File URL for $filename ($fileId): $url" } return url } @@ -471,7 +450,7 @@ class FilesViewModel( fileId = fileId, filename = filename, type = type, - formattedSize = Formatter.formatShortFileSize(context, bytes), + formattedSize = formatFileSize(bytes), createdAt = createdAt, previewUrl = buildImagePreviewUrl(), ) @@ -482,7 +461,7 @@ class FilesViewModel( fileId = fileId, filename = filename, type = type, - formattedSize = Formatter.formatShortFileSize(context, bytes), + formattedSize = formatFileSize(bytes), createdAt = createdAt, source = source, previewUrl = buildImagePreviewUrl(), @@ -494,18 +473,6 @@ class FilesViewModel( private inline fun updateTransient(update: TransientState.() -> TransientState) { _transientState.value = _transientState.value.update() } - - private fun getFileName(uri: Uri): String? { - val cursor = context.contentResolver.query(uri, null, null, null, null) ?: return null - return cursor.use { - if (it.moveToFirst()) { - val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex >= 0) it.getString(nameIndex) else null - } else { - null - } - } - } } private data class TransientState( diff --git a/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/di/FilesPlatformModule.kt b/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/di/FilesPlatformModule.kt new file mode 100644 index 0000000..33ba595 --- /dev/null +++ b/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/di/FilesPlatformModule.kt @@ -0,0 +1,10 @@ +package com.librechat.android.feature.files.di + +import com.librechat.android.feature.files.platform.FileReader +import com.librechat.android.feature.files.platform.IosFileReader +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val filesPlatformModule: Module = module { + single { IosFileReader() } +} diff --git a/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/platform/FilePicker.ios.kt b/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/platform/FilePicker.ios.kt new file mode 100644 index 0000000..da1de04 --- /dev/null +++ b/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/platform/FilePicker.ios.kt @@ -0,0 +1,19 @@ +package com.librechat.android.feature.files.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +actual fun rememberFilePickerLauncher( + onFilePicked: (fileRef: Any) -> Unit, +): FilePickerLauncher { + // iOS file picker — stub for now. + // A proper implementation would use UIDocumentPickerViewController. + return remember { FilePickerLauncher() } +} + +actual class FilePickerLauncher { + actual fun launch(mimeType: String) { + // No-op on iOS for now + } +} diff --git a/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/platform/IosFileReader.kt b/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/platform/IosFileReader.kt new file mode 100644 index 0000000..601c545 --- /dev/null +++ b/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/platform/IosFileReader.kt @@ -0,0 +1,42 @@ +package com.librechat.android.feature.files.platform + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSURL +import platform.Foundation.dataWithContentsOfURL +import platform.Foundation.lastPathComponent +import platform.Foundation.pathExtension +import platform.posix.memcpy + +class IosFileReader : FileReader { + + override fun readBytes(fileRef: Any): ByteArray? { + val url = fileRef as? NSURL ?: return null + val data = NSData.dataWithContentsOfURL(url) ?: return null + return data.toByteArray() + } + + override fun getFileName(fileRef: Any): String? { + val url = fileRef as? NSURL ?: return null + return url.lastPathComponent + } + + override fun getMimeType(fileRef: Any): String? { + val url = fileRef as? NSURL ?: return null + val ext = url.pathExtension ?: return null + return CommonMimeTypes.fromExtension(ext) + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun NSData.toByteArray(): ByteArray { + val size = length.toInt() + if (size == 0) return ByteArray(0) + val result = ByteArray(size) + result.usePinned { pinned -> + memcpy(pinned.addressOf(0), bytes, length) + } + return result +} diff --git a/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/platform/IosFileSizeFormatter.kt b/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/platform/IosFileSizeFormatter.kt new file mode 100644 index 0000000..3be1b9a --- /dev/null +++ b/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/platform/IosFileSizeFormatter.kt @@ -0,0 +1,11 @@ +package com.librechat.android.feature.files.platform + +import platform.Foundation.NSByteCountFormatter +import platform.Foundation.NSByteCountFormatterCountStyleFile + +actual fun formatFileSize(bytes: Long): String { + return NSByteCountFormatter.stringFromByteCount( + byteCount = bytes, + countStyle = NSByteCountFormatterCountStyleFile, + ) +} diff --git a/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/platform/PdfPreview.ios.kt b/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/platform/PdfPreview.ios.kt new file mode 100644 index 0000000..d0eb9fc --- /dev/null +++ b/feature/files/src/iosMain/kotlin/com/librechat/android/feature/files/platform/PdfPreview.ios.kt @@ -0,0 +1,66 @@ +package com.librechat.android.feature.files.platform + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PictureAsPdf +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.librechat.android.feature.files.FilePreviewDisplayData + +@Composable +actual fun PdfPreview( + file: FilePreviewDisplayData, + onDownloadFile: (suspend (fileId: String, userId: String?) -> ByteArray?)?, + modifier: Modifier, +) { + // iOS PDF preview — placeholder for now. + // A proper implementation would use PDFKit via UIKitView. + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = Icons.Default.PictureAsPdf, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Text( + text = file.filename, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = "PDF preview not yet available on iOS", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/feature/files/src/main/kotlin/com/librechat/android/feature/files/di/FilesModule.kt b/feature/files/src/main/kotlin/com/librechat/android/feature/files/di/FilesModule.kt deleted file mode 100644 index 368ea44..0000000 --- a/feature/files/src/main/kotlin/com/librechat/android/feature/files/di/FilesModule.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.librechat.android.feature.files.di - -import com.librechat.android.feature.files.viewmodel.FilesViewModel -import org.koin.android.ext.koin.androidApplication -import org.koin.core.module.dsl.viewModel -import org.koin.dsl.module - -val filesModule = module { - viewModel { - FilesViewModel( - fileRepository = get(), - context = androidApplication(), - serverDataStore = get(), - ) - } -} diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index e5a33c7..8212e8a 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -1,13 +1,24 @@ plugins { - id("librechat.android.feature") + id("librechat.kmp.feature") } android { namespace = "com.librechat.android.feature.settings" } -dependencies { - implementation(project(":core:network")) - implementation(libs.coil.compose) - implementation(libs.timber) +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":core:network")) + implementation(libs.coil3.compose) + implementation(libs.coil3.network.ktor) + implementation(libs.kermit) + } + androidMain.dependencies { + implementation(libs.koin.android) + } + getByName("androidUnitTest").dependencies { + implementation(libs.koin.test) + } + } } diff --git a/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/di/SettingsPlatformModule.android.kt b/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/di/SettingsPlatformModule.android.kt new file mode 100644 index 0000000..0fbc989 --- /dev/null +++ b/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/di/SettingsPlatformModule.android.kt @@ -0,0 +1,27 @@ +package com.librechat.android.feature.settings.di + +import com.librechat.android.feature.settings.util.AndroidCacheCleaner +import com.librechat.android.feature.settings.util.AndroidContentReader +import com.librechat.android.feature.settings.util.ContentReader +import com.librechat.android.feature.settings.util.PlatformCacheCleaner +import com.librechat.android.feature.settings.viewmodel.delegate.SpeechSettingsDelegate +import com.librechat.android.feature.settings.viewmodel.delegate.SpeechSettingsFactory +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.Module +import org.koin.dsl.bind +import org.koin.dsl.module + +actual val settingsPlatformModule: Module = module { + single { AndroidContentReader(androidContext()) } bind ContentReader::class + single { AndroidCacheCleaner(androidContext()) } bind PlatformCacheCleaner::class + single { + SpeechSettingsFactory { stateHandle -> + SpeechSettingsDelegate( + stateHandle = stateHandle, + context = androidContext(), + speechRepository = get(), + settingsDataStore = get(), + ) + } + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt b/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt similarity index 85% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt rename to feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt index fd93ec2..caf8369 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt +++ b/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt @@ -30,20 +30,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import com.librechat.android.feature.settings.R +import coil3.compose.AsyncImage +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable -internal fun AvatarUploadDialog( +internal actual fun AvatarUploadDialog( currentAvatarUrl: String?, isUploading: Boolean, - onPickImage: (Uri) -> Unit, + onPickImage: (Any) -> Unit, onDismiss: () -> Unit, - modifier: Modifier = Modifier, + modifier: Modifier, ) { var selectedUri by remember { mutableStateOf(null) } @@ -58,15 +59,14 @@ internal fun AvatarUploadDialog( AlertDialog( onDismissRequest = onDismiss, modifier = modifier, - title = { Text(stringResource(R.string.dialog_title_update_avatar)) }, + title = { Text(stringResource(Res.string.dialog_title_update_avatar)) }, text = { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), ) { - // Circular avatar preview - val avatarPreviewCd = stringResource(R.string.cd_avatar_preview) + val avatarPreviewCd = stringResource(Res.string.cd_avatar_preview) Surface( modifier = Modifier .size(120.dp) @@ -81,14 +81,14 @@ internal fun AvatarUploadDialog( if (displayUri != null) { AsyncImage( model = displayUri, - contentDescription = stringResource(R.string.cd_selected_avatar), + contentDescription = stringResource(Res.string.cd_selected_avatar), modifier = Modifier.size(120.dp), contentScale = ContentScale.Crop, ) } else if (currentAvatarUrl != null) { AsyncImage( model = currentAvatarUrl, - contentDescription = stringResource(R.string.cd_current_avatar), + contentDescription = stringResource(Res.string.cd_current_avatar), modifier = Modifier.size(120.dp), contentScale = ContentScale.Crop, ) @@ -102,19 +102,18 @@ internal fun AvatarUploadDialog( } } - // Pick image button IconButton( onClick = { imagePickerLauncher.launch("image/*") }, enabled = !isUploading, ) { Icon( imageVector = Icons.Default.CameraAlt, - contentDescription = stringResource(R.string.cd_choose_image), + contentDescription = stringResource(Res.string.cd_choose_image), ) } Text( - text = stringResource(R.string.avatar_choose_hint), + text = stringResource(Res.string.avatar_choose_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -135,12 +134,12 @@ internal fun AvatarUploadDialog( }, enabled = selectedUri != null && !isUploading, ) { - Text(stringResource(if (isUploading) R.string.action_uploading else R.string.action_upload)) + Text(stringResource(if (isUploading) Res.string.action_uploading else Res.string.action_upload)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) diff --git a/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/util/AndroidCacheCleaner.kt b/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/util/AndroidCacheCleaner.kt new file mode 100644 index 0000000..a534b13 --- /dev/null +++ b/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/util/AndroidCacheCleaner.kt @@ -0,0 +1,9 @@ +package com.librechat.android.feature.settings.util + +import android.content.Context + +class AndroidCacheCleaner(private val context: Context) : PlatformCacheCleaner { + override fun clearCache() { + context.cacheDir.deleteRecursively() + } +} diff --git a/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/util/AndroidContentReader.kt b/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/util/AndroidContentReader.kt new file mode 100644 index 0000000..df86d34 --- /dev/null +++ b/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/util/AndroidContentReader.kt @@ -0,0 +1,11 @@ +package com.librechat.android.feature.settings.util + +import android.content.Context +import android.net.Uri + +class AndroidContentReader(private val context: Context) : ContentReader { + override fun readBytes(uri: Any): ByteArray? { + val androidUri = uri as? Uri ?: return null + return context.contentResolver.openInputStream(androidUri)?.use { it.readBytes() } + } +} diff --git a/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/util/PlatformClipboard.android.kt b/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/util/PlatformClipboard.android.kt new file mode 100644 index 0000000..670cbe7 --- /dev/null +++ b/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/util/PlatformClipboard.android.kt @@ -0,0 +1,14 @@ +package com.librechat.android.feature.settings.util + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.mp.KoinPlatformTools + +actual fun copyToClipboard(text: String, label: String) { + val context = KoinPlatformTools.defaultContext().get().get() + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(label, text)) +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsDelegate.kt b/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsDelegate.kt similarity index 89% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsDelegate.kt rename to feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsDelegate.kt index 24a1050..58d8f39 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsDelegate.kt +++ b/feature/settings/src/androidMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsDelegate.kt @@ -10,7 +10,7 @@ import com.librechat.android.core.model.speech.TtsVoice import com.librechat.android.feature.settings.screen.DeviceVoiceInfo import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle import kotlinx.coroutines.launch -import timber.log.Timber +import co.touchlab.kermit.Logger /** * Handles TTS voice selection, test playback, device voice loading, and MediaPlayer lifecycle. @@ -20,26 +20,26 @@ class SpeechSettingsDelegate( private val context: Context, private val speechRepository: SpeechRepository, private val settingsDataStore: SettingsDataStore, -) { +) : SpeechSettingsContract { private var currentMediaPlayer: MediaPlayer? = null private var deviceTtsEngine: TextToSpeech? = null - fun loadVoices() { + override fun loadVoices() { stateHandle.scope.launch { when (val result = speechRepository.getVoices()) { is Result.Success -> { stateHandle.update { copy(availableVoices = result.data) } } is Result.Error -> { - Timber.d(result.exception, "Failed to load voices: ${result.message}") + Logger.d(result.exception) { "Failed to load voices: ${result.message}" } } is Result.Loading -> { /* no-op */ } } } } - fun loadDeviceVoices() { + override fun loadDeviceVoices() { deviceTtsEngine = TextToSpeech(context) { status -> if (status == TextToSpeech.SUCCESS) { val voices = deviceTtsEngine?.voices?.map { voice -> @@ -53,25 +53,25 @@ class SpeechSettingsDelegate( } } - fun setAutoSendAfterStt(enabled: Boolean) { + override fun setAutoSendAfterStt(enabled: Boolean) { stateHandle.scope.launch { settingsDataStore.setAutoSendAfterStt(enabled) } } - fun setAutoReadEnabled(enabled: Boolean) { + override fun setAutoReadEnabled(enabled: Boolean) { stateHandle.scope.launch { settingsDataStore.setAutoReadEnabled(enabled) } } - fun selectVoice(voice: TtsVoice) { + override fun selectVoice(voice: TtsVoice) { stateHandle.scope.launch { settingsDataStore.setSelectedVoiceId(voice.id) } } - fun testVoice() { + override fun testVoice() { val voice = stateHandle.state.selectedVoice stateHandle.scope.launch { val result = speechRepository.synthesizeSpeech( @@ -90,7 +90,7 @@ class SpeechSettingsDelegate( } } - fun previewDeviceTts(text: String, rate: Float, pitch: Float, voiceName: String?) { + override fun previewDeviceTts(text: String, rate: Float, pitch: Float, voiceName: String?) { stopTtsPreview() val tts = deviceTtsEngine ?: return tts.setSpeechRate(rate) @@ -116,7 +116,7 @@ class SpeechSettingsDelegate( tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "tts_preview") } - fun previewServerTts(text: String, voice: String?, model: String?) { + override fun previewServerTts(text: String, voice: String?, model: String?) { stopTtsPreview() stateHandle.update { copy(isTtsPreviewPlaying = true) } stateHandle.scope.launch { @@ -142,7 +142,7 @@ class SpeechSettingsDelegate( } } - fun stopTtsPreview() { + override fun stopTtsPreview() { deviceTtsEngine?.stop() currentMediaPlayer?.release() currentMediaPlayer = null @@ -151,15 +151,15 @@ class SpeechSettingsDelegate( // STT detail dialogs - fun showSttDetailDialog() { + override fun showSttDetailDialog() { stateHandle.update { copy(showSttDetailDialog = true) } } - fun dismissSttDetailDialog() { + override fun dismissSttDetailDialog() { stateHandle.update { copy(showSttDetailDialog = false) } } - fun saveSttSettings(engine: String, language: String) { + override fun saveSttSettings(engine: String, language: String) { stateHandle.update { copy(sttEngine = engine, sttLanguage = language, showSttDetailDialog = false) } @@ -169,15 +169,15 @@ class SpeechSettingsDelegate( } } - fun showTtsDetailDialog() { + override fun showTtsDetailDialog() { stateHandle.update { copy(showTtsDetailDialog = true) } } - fun dismissTtsDetailDialog() { + override fun dismissTtsDetailDialog() { stateHandle.update { copy(showTtsDetailDialog = false) } } - fun saveTtsSettings( + override fun saveTtsSettings( engine: String, voice: String, rate: Float, @@ -234,7 +234,7 @@ class SpeechSettingsDelegate( } } mediaPlayer.setOnErrorListener { mp, what, extra -> - Timber.e("MediaPlayer error: what=$what, extra=$extra") + Logger.e { "MediaPlayer error: what=$what, extra=$extra" } mp.release() currentMediaPlayer = null tempFile.delete() @@ -255,7 +255,7 @@ class SpeechSettingsDelegate( currentMediaPlayer = mediaPlayer } catch (e: Exception) { - Timber.e(e, "Failed to play audio") + Logger.e(e) { "Failed to play audio" } stateHandle.update { copy( error = "Audio playback failed: ${e.localizedMessage}", @@ -268,7 +268,7 @@ class SpeechSettingsDelegate( /** * Releases TTS engine and media player resources. Call from ViewModel.onCleared(). */ - fun release() { + override fun release() { currentMediaPlayer?.release() currentMediaPlayer = null deviceTtsEngine?.shutdown() diff --git a/feature/settings/src/test/kotlin/com/librechat/android/feature/settings/di/SettingsModuleVerificationTest.kt b/feature/settings/src/androidUnitTest/kotlin/com/librechat/android/feature/settings/di/SettingsModuleVerificationTest.kt similarity index 85% rename from feature/settings/src/test/kotlin/com/librechat/android/feature/settings/di/SettingsModuleVerificationTest.kt rename to feature/settings/src/androidUnitTest/kotlin/com/librechat/android/feature/settings/di/SettingsModuleVerificationTest.kt index 28a4dca..8eeaf9e 100644 --- a/feature/settings/src/test/kotlin/com/librechat/android/feature/settings/di/SettingsModuleVerificationTest.kt +++ b/feature/settings/src/androidUnitTest/kotlin/com/librechat/android/feature/settings/di/SettingsModuleVerificationTest.kt @@ -25,6 +25,9 @@ class SettingsModuleVerificationTest { com.librechat.android.core.data.datastore.ThemeDataStore::class, com.librechat.android.core.data.datastore.ServerDataStore::class, com.librechat.android.core.data.datastore.SettingsDataStore::class, + com.librechat.android.feature.settings.util.ContentReader::class, + com.librechat.android.feature.settings.util.PlatformCacheCleaner::class, + com.librechat.android.feature.settings.viewmodel.delegate.SpeechSettingsFactory::class, ), ) } diff --git a/feature/settings/src/test/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModelTest.kt b/feature/settings/src/androidUnitTest/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModelTest.kt similarity index 95% rename from feature/settings/src/test/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModelTest.kt rename to feature/settings/src/androidUnitTest/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModelTest.kt index 2ce77dd..25fa0e6 100644 --- a/feature/settings/src/test/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModelTest.kt +++ b/feature/settings/src/androidUnitTest/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModelTest.kt @@ -1,7 +1,10 @@ package com.librechat.android.feature.settings.viewmodel -import android.app.Application import com.google.common.truth.Truth.assertThat +import com.librechat.android.feature.settings.util.ContentReader +import com.librechat.android.feature.settings.util.PlatformCacheCleaner +import com.librechat.android.feature.settings.viewmodel.delegate.SpeechSettingsContract +import com.librechat.android.feature.settings.viewmodel.delegate.SpeechSettingsFactory import com.librechat.android.core.common.ChatLayoutConstants import com.librechat.android.core.common.result.Result import com.librechat.android.core.data.datastore.ChatFontSize @@ -41,7 +44,9 @@ class SettingsViewModelTest { private val testDispatcher = StandardTestDispatcher() - private val context = mockk(relaxed = true) + private val contentReader = mockk(relaxed = true) + private val cacheCleaner = mockk(relaxed = true) + private val speechSettingsContract = mockk(relaxed = true) private val userRepository = mockk(relaxed = true) private val authRepository = mockk(relaxed = true) private val conversationRepository = mockk(relaxed = true) @@ -111,7 +116,8 @@ class SettingsViewModelTest { } private fun createViewModel() = SettingsViewModel( - context = context, + contentReader = contentReader, + cacheCleaner = cacheCleaner, userRepository = userRepository, authRepository = authRepository, conversationRepository = conversationRepository, @@ -120,7 +126,7 @@ class SettingsViewModelTest { settingsDataStore = settingsDataStore, mcpRepository = mcpRepository, memoryRepository = memoryRepository, - speechRepository = speechRepository, + speechSettingsFactory = SpeechSettingsFactory { speechSettingsContract }, balanceRepository = balanceRepository, shareRepository = shareRepository, keyRepository = keyRepository, diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/commonMain/composeResources/values/strings.xml similarity index 100% rename from feature/settings/src/main/res/values/strings.xml rename to feature/settings/src/commonMain/composeResources/values/strings.xml diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/PresetManagerDisplayData.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/PresetManagerDisplayData.kt similarity index 100% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/PresetManagerDisplayData.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/PresetManagerDisplayData.kt diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/SettingsDisplayData.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/SettingsDisplayData.kt similarity index 100% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/SettingsDisplayData.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/SettingsDisplayData.kt diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/di/SettingsModule.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/di/SettingsModule.kt similarity index 83% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/di/SettingsModule.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/di/SettingsModule.kt index 78aa883..2d8e538 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/di/SettingsModule.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/di/SettingsModule.kt @@ -5,15 +5,20 @@ import com.librechat.android.feature.settings.viewmodel.McpViewModel import com.librechat.android.feature.settings.viewmodel.MemoriesViewModel import com.librechat.android.feature.settings.viewmodel.PresetManagerViewModel import com.librechat.android.feature.settings.viewmodel.SettingsViewModel -import org.koin.android.ext.koin.androidApplication +import org.koin.core.module.Module import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module +expect val settingsPlatformModule: Module + val settingsModule = module { + includes(settingsPlatformModule) + viewModel { SettingsViewModel( - context = androidApplication(), + contentReader = get(), + cacheCleaner = get(), userRepository = get(), authRepository = get(), conversationRepository = get(), @@ -22,7 +27,7 @@ val settingsModule = module { settingsDataStore = get(), mcpRepository = get(), memoryRepository = get(), - speechRepository = get(), + speechSettingsFactory = get(), balanceRepository = get(), shareRepository = get(), keyRepository = get(), diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/McpNavigation.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/navigation/McpNavigation.kt similarity index 63% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/McpNavigation.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/navigation/McpNavigation.kt index 6055316..2dc7fb4 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/McpNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/navigation/McpNavigation.kt @@ -2,7 +2,6 @@ package com.librechat.android.feature.settings.navigation import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import com.librechat.android.core.ui.components.ScreenTransitionWrapper import com.librechat.android.feature.settings.screen.McpServersScreen const val MCP_SERVERS_ROUTE = "settings/mcp" @@ -11,10 +10,8 @@ fun NavGraphBuilder.mcpServersScreen( onNavigateBack: () -> Unit, ) { composable(MCP_SERVERS_ROUTE) { - ScreenTransitionWrapper(transition) { - McpServersScreen( - onNavigateBack = onNavigateBack, - ) - } + McpServersScreen( + onNavigateBack = onNavigateBack, + ) } } diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/MemoriesNavigation.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/navigation/MemoriesNavigation.kt similarity index 63% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/MemoriesNavigation.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/navigation/MemoriesNavigation.kt index 06ad732..1a3df0e 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/MemoriesNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/navigation/MemoriesNavigation.kt @@ -2,7 +2,6 @@ package com.librechat.android.feature.settings.navigation import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import com.librechat.android.core.ui.components.ScreenTransitionWrapper import com.librechat.android.feature.settings.screen.MemoriesScreen const val MEMORIES_ROUTE = "settings/memories" @@ -11,10 +10,8 @@ fun NavGraphBuilder.memoriesScreen( onNavigateBack: () -> Unit, ) { composable(MEMORIES_ROUTE) { - ScreenTransitionWrapper(transition) { - MemoriesScreen( - onNavigateBack = onNavigateBack, - ) - } + MemoriesScreen( + onNavigateBack = onNavigateBack, + ) } } diff --git a/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/navigation/SettingsNavigation.kt new file mode 100644 index 0000000..851291c --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/navigation/SettingsNavigation.kt @@ -0,0 +1,103 @@ +package com.librechat.android.feature.settings.navigation + +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.librechat.android.feature.settings.screen.AccountSettingsScreen +import com.librechat.android.feature.settings.screen.ApiKeysScreen +import com.librechat.android.feature.settings.screen.ChatSettingsScreen +import com.librechat.android.feature.settings.screen.DataSettingsScreen +import com.librechat.android.feature.settings.screen.GeneralSettingsScreen +import com.librechat.android.feature.settings.screen.PresetManagerScreen +import com.librechat.android.feature.settings.screen.SharedLinksScreen +import com.librechat.android.feature.settings.screen.TabbedSettingsScreen +import com.librechat.android.feature.settings.viewmodel.SettingsViewModel +import org.koin.compose.viewmodel.koinViewModel + +const val SETTINGS_TABBED_ROUTE = "settings" +const val SETTINGS_GENERAL_ROUTE = "settings/general" +const val SETTINGS_CHAT_ROUTE = "settings/chat" +const val SETTINGS_ACCOUNT_ROUTE = "settings/account" +const val SETTINGS_DATA_ROUTE = "settings/data" +const val SHARED_LINKS_ROUTE = "settings/shared-links" +const val PRESET_MANAGER_ROUTE = "settings/presets" +const val API_KEYS_ROUTE = "settings/api_keys" + +fun NavGraphBuilder.settingsGraph( + onLogout: () -> Unit, + onNavigateBack: () -> Unit, + onNavigateToArchived: () -> Unit = {}, + onNavigateToSharedLinks: () -> Unit = {}, + onNavigateBackFromSharedLinks: () -> Unit = {}, + onNavigateToPresets: () -> Unit = {}, + onNavigateBackFromPresets: () -> Unit = {}, + onNavigateToApiKeys: () -> Unit = {}, + onNavigateBackFromApiKeys: () -> Unit = {}, +) { + composable(SETTINGS_TABBED_ROUTE) { + TabbedSettingsScreen( + onNavigateBack = onNavigateBack, + onLogout = onLogout, + onNavigateToArchived = onNavigateToArchived, + onNavigateToSharedLinks = onNavigateToSharedLinks, + onNavigateToPresets = onNavigateToPresets, + onNavigateToApiKeys = onNavigateToApiKeys, + ) + } + + composable(SETTINGS_GENERAL_ROUTE) { + GeneralSettingsScreen( + onNavigateBack = onNavigateBack, + ) + } + composable(SETTINGS_CHAT_ROUTE) { + ChatSettingsScreen( + onNavigateBack = onNavigateBack, + onNavigateToPresets = onNavigateToPresets, + ) + } + composable(SETTINGS_ACCOUNT_ROUTE) { + AccountSettingsScreen( + onLogout = onLogout, + onNavigateBack = onNavigateBack, + onNavigateToApiKeys = onNavigateToApiKeys, + ) + } + composable(SETTINGS_DATA_ROUTE) { + DataSettingsScreen( + onNavigateBack = onNavigateBack, + onNavigateToArchived = onNavigateToArchived, + onNavigateToSharedLinks = onNavigateToSharedLinks, + ) + } + composable(SHARED_LINKS_ROUTE) { + val viewModel: SettingsViewModel = koinViewModel() + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loadSharedLinks() + } + + SharedLinksScreen( + links = uiState.value.sharedLinks, + isLoading = uiState.value.isSharedLinksLoading, + hasNextPage = uiState.value.sharedLinksHasNextPage, + serverUrl = uiState.value.serverUrl, + onLoadMore = viewModel::loadMoreSharedLinks, + onToggleVisibility = viewModel::toggleSharedLinkVisibility, + onDelete = viewModel::deleteSharedLink, + onNavigateBack = onNavigateBackFromSharedLinks, + ) + } + composable(PRESET_MANAGER_ROUTE) { + PresetManagerScreen( + onNavigateBack = onNavigateBackFromPresets, + ) + } + composable(API_KEYS_ROUTE) { + ApiKeysScreen( + onNavigateBack = onNavigateBackFromApiKeys, + ) + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt similarity index 82% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt index ee87f3f..0b33afa 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/AccountSettingsScreen.kt @@ -46,17 +46,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.compose.AsyncImage +import coil3.compose.AsyncImage import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.LoadingIndicator import com.librechat.android.core.ui.components.OtpVerificationDialog -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* import com.librechat.android.feature.settings.screen.sections.BackupCodesDialog import com.librechat.android.feature.settings.screen.sections.TwoFactorCodeDialog import com.librechat.android.feature.settings.screen.sections.TwoFactorSetupDialog @@ -79,12 +80,12 @@ fun AccountSettingsScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_account)) }, + title = { Text(stringResource(Res.string.title_account)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -143,7 +144,7 @@ fun AccountSettingsContent( LoadingIndicator() } else if (uiState.error != null && uiState.user == null) { ErrorBanner( - message = uiState.error ?: stringResource(R.string.error_could_not_load_settings), + message = uiState.error ?: stringResource(Res.string.error_could_not_load_settings), modifier = modifier, onRetry = { viewModel.retry() }, ) @@ -153,7 +154,7 @@ fun AccountSettingsContent( ) { // Account section item(key = "account_header") { - SectionHeader(stringResource(R.string.section_profile)) + SectionHeader(stringResource(Res.string.section_profile)) } item(key = "account_info") { AccountInfo( @@ -166,7 +167,7 @@ fun AccountSettingsContent( // Balance section item(key = "balance_header") { - SectionHeader(stringResource(R.string.section_balance)) + SectionHeader(stringResource(Res.string.section_balance)) } item(key = "balance_section") { BalanceSection( @@ -177,7 +178,7 @@ fun AccountSettingsContent( // Security section item(key = "security_header") { - SectionHeader(stringResource(R.string.section_security)) + SectionHeader(stringResource(Res.string.section_security)) } item(key = "security_settings") { SecuritySection( @@ -190,15 +191,15 @@ fun AccountSettingsContent( item(key = "api_keys_row") { AccountSettingsRow( icon = Icons.Default.Key, - title = stringResource(R.string.api_keys), - subtitle = stringResource(R.string.api_keys_subtitle), + title = stringResource(Res.string.api_keys), + subtitle = stringResource(Res.string.api_keys_subtitle), onClick = onNavigateToApiKeys, ) } // Danger zone item(key = "danger_header") { - SectionHeader(stringResource(R.string.section_danger_zone)) + SectionHeader(stringResource(Res.string.section_danger_zone)) } item(key = "danger_actions") { DangerZone( @@ -236,8 +237,8 @@ fun AccountSettingsContent( // 2FA disable dialog if (uiState.showDisableTwoFactorDialog) { TwoFactorCodeDialog( - title = stringResource(R.string.dialog_title_disable_2fa), - description = stringResource(R.string.twofa_disable_instructions), + title = stringResource(Res.string.dialog_title_disable_2fa), + description = stringResource(Res.string.twofa_disable_instructions), isLoading = uiState.isTwoFactorLoading, onConfirm = viewModel::confirmDisableTwoFactor, onDismiss = viewModel::dismissDisableTwoFactorDialog, @@ -256,8 +257,8 @@ fun AccountSettingsContent( if (showLogoutDialog) { AlertDialog( onDismissRequest = { showLogoutDialog = false }, - title = { Text(stringResource(R.string.dialog_title_sign_out)) }, - text = { Text(stringResource(R.string.dialog_sign_out_message)) }, + title = { Text(stringResource(Res.string.dialog_title_sign_out)) }, + text = { Text(stringResource(Res.string.dialog_sign_out_message)) }, confirmButton = { TextButton( onClick = { @@ -265,12 +266,12 @@ fun AccountSettingsContent( viewModel.logout() }, ) { - Text(stringResource(R.string.action_sign_out)) + Text(stringResource(Res.string.action_sign_out)) } }, dismissButton = { TextButton(onClick = { showLogoutDialog = false }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -280,9 +281,9 @@ fun AccountSettingsContent( if (showDeleteDialog) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, - title = { Text(stringResource(R.string.dialog_title_delete_account)) }, + title = { Text(stringResource(Res.string.dialog_title_delete_account)) }, text = { - Text(stringResource(R.string.dialog_delete_account_message)) + Text(stringResource(Res.string.dialog_delete_account_message)) }, confirmButton = { TextButton( @@ -294,12 +295,12 @@ fun AccountSettingsContent( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(R.string.action_delete)) + Text(stringResource(Res.string.action_delete)) } }, dismissButton = { TextButton(onClick = { showDeleteDialog = false }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -308,54 +309,54 @@ fun AccountSettingsContent( // OTP dialog for account deletion when 2FA is enabled if (uiState.showDeleteAccountOtpDialog) { OtpVerificationDialog( - title = stringResource(R.string.otp_title_verify_identity), - description = stringResource(R.string.otp_desc_delete_account), + title = stringResource(Res.string.otp_title_verify_identity), + description = stringResource(Res.string.otp_desc_delete_account), isLoading = uiState.isLoading, onVerify = { token, backupCode -> viewModel.deleteAccount(token = token, backupCode = backupCode) }, onDismiss = viewModel::dismissDeleteAccountOtpDialog, - verifyLabel = stringResource(R.string.otp_verify), - cancelLabel = stringResource(R.string.otp_cancel), - backupCodeLabel = stringResource(R.string.otp_backup_code_label), - useBackupToggleLabel = stringResource(R.string.otp_use_backup_code), - useOtpToggleLabel = stringResource(R.string.otp_use_otp_code), + verifyLabel = stringResource(Res.string.otp_verify), + cancelLabel = stringResource(Res.string.otp_cancel), + backupCodeLabel = stringResource(Res.string.otp_backup_code_label), + useBackupToggleLabel = stringResource(Res.string.otp_use_backup_code), + useOtpToggleLabel = stringResource(Res.string.otp_use_otp_code), ) } // OTP dialog for enabling 2FA when re-enrolling if (uiState.showEnableTwoFactorOtpDialog) { OtpVerificationDialog( - title = stringResource(R.string.otp_title_verify_identity), - description = stringResource(R.string.otp_desc_reenroll_2fa), + title = stringResource(Res.string.otp_title_verify_identity), + description = stringResource(Res.string.otp_desc_reenroll_2fa), isLoading = uiState.isTwoFactorLoading, onVerify = { token, backupCode -> viewModel.enableTwoFactorWithOtp(token = token, backupCode = backupCode) }, onDismiss = viewModel::dismissEnableTwoFactorOtpDialog, - verifyLabel = stringResource(R.string.otp_verify), - cancelLabel = stringResource(R.string.otp_cancel), - backupCodeLabel = stringResource(R.string.otp_backup_code_label), - useBackupToggleLabel = stringResource(R.string.otp_use_backup_code), - useOtpToggleLabel = stringResource(R.string.otp_use_otp_code), + verifyLabel = stringResource(Res.string.otp_verify), + cancelLabel = stringResource(Res.string.otp_cancel), + backupCodeLabel = stringResource(Res.string.otp_backup_code_label), + useBackupToggleLabel = stringResource(Res.string.otp_use_backup_code), + useOtpToggleLabel = stringResource(Res.string.otp_use_otp_code), ) } // OTP dialog for regenerating backup codes if (uiState.showBackupCodesOtpDialog) { OtpVerificationDialog( - title = stringResource(R.string.otp_title_verify_identity), - description = stringResource(R.string.otp_desc_regenerate_backup_codes), + title = stringResource(Res.string.otp_title_verify_identity), + description = stringResource(Res.string.otp_desc_regenerate_backup_codes), isLoading = uiState.isTwoFactorLoading, onVerify = { token, backupCode -> viewModel.viewBackupCodesWithOtp(token = token, backupCode = backupCode) }, onDismiss = viewModel::dismissBackupCodesOtpDialog, - verifyLabel = stringResource(R.string.otp_verify), - cancelLabel = stringResource(R.string.otp_cancel), - backupCodeLabel = stringResource(R.string.otp_backup_code_label), - useBackupToggleLabel = stringResource(R.string.otp_use_backup_code), - useOtpToggleLabel = stringResource(R.string.otp_use_otp_code), + verifyLabel = stringResource(Res.string.otp_verify), + cancelLabel = stringResource(Res.string.otp_cancel), + backupCodeLabel = stringResource(Res.string.otp_backup_code_label), + useBackupToggleLabel = stringResource(Res.string.otp_use_backup_code), + useOtpToggleLabel = stringResource(Res.string.otp_use_otp_code), ) } } // Column @@ -397,7 +398,7 @@ private fun AccountInfo( if (avatarUrl != null) { AsyncImage( model = avatarUrl, - contentDescription = stringResource(R.string.cd_user_avatar), + contentDescription = stringResource(Res.string.cd_user_avatar), modifier = Modifier.size(48.dp), contentScale = ContentScale.Crop, ) @@ -433,7 +434,7 @@ private fun AccountInfo( IconButton(onClick = onAvatarClick) { Icon( imageVector = Icons.Default.CameraAlt, - contentDescription = stringResource(R.string.cd_change_avatar), + contentDescription = stringResource(Res.string.cd_change_avatar), modifier = Modifier.size(20.dp), ) } @@ -459,7 +460,7 @@ private fun DangerZone( enabled = !isLoading, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(R.string.action_sign_out)) + Text(stringResource(Res.string.action_sign_out)) } Button( onClick = onDeleteClick, @@ -470,7 +471,7 @@ private fun DangerZone( contentColor = MaterialTheme.colorScheme.onError, ), ) { - Text(stringResource(R.string.delete_account)) + Text(stringResource(Res.string.delete_account)) } } } diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeyCreateDialog.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ApiKeyCreateDialog.kt similarity index 79% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeyCreateDialog.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ApiKeyCreateDialog.kt index 5a3c23b..3be82b8 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeyCreateDialog.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ApiKeyCreateDialog.kt @@ -1,8 +1,5 @@ package com.librechat.android.feature.settings.screen -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -25,13 +22,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource +import com.librechat.android.feature.settings.util.copyToClipboard import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.librechat.android.core.model.ApiKey -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* /** * Two-phase API key creation dialog. @@ -71,20 +69,20 @@ private fun CreateKeyFormDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_title_create_api_key)) }, + title = { Text(stringResource(Res.string.dialog_title_create_api_key)) }, text = { Column( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = stringResource(R.string.api_key_name_hint), + text = stringResource(Res.string.api_key_name_hint), style = MaterialTheme.typography.bodyMedium, ) OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text(stringResource(R.string.hint_key_name)) }, - placeholder = { Text(stringResource(R.string.hint_key_name_placeholder)) }, + label = { Text(stringResource(Res.string.hint_key_name)) }, + placeholder = { Text(stringResource(Res.string.hint_key_name_placeholder)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -95,12 +93,12 @@ private fun CreateKeyFormDialog( onClick = { onCreateKey(name.trim()) }, enabled = name.isNotBlank() && !isCreating, ) { - Text(stringResource(if (isCreating) R.string.action_creating else R.string.action_create)) + Text(stringResource(if (isCreating) Res.string.action_creating else Res.string.action_create)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -111,18 +109,17 @@ private fun CreatedKeyDialog( apiKey: ApiKey, onDismiss: () -> Unit, ) { - val context = LocalContext.current var copied by remember { mutableStateOf(false) } AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_title_api_key_created)) }, + title = { Text(stringResource(Res.string.dialog_title_api_key_created)) }, text = { Column( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = stringResource(R.string.api_key_created_warning), + text = stringResource(Res.string.api_key_created_warning), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, ) @@ -152,22 +149,20 @@ private fun CreatedKeyDialog( ) IconButton( onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("API Key", apiKey.keyValue ?: "") - clipboard.setPrimaryClip(clip) + copyToClipboard(apiKey.keyValue ?: "", "API Key") copied = true }, ) { Icon( imageVector = Icons.Default.ContentCopy, - contentDescription = stringResource(R.string.cd_copy_api_key), + contentDescription = stringResource(Res.string.cd_copy_api_key), ) } } } if (copied) { Text( - text = stringResource(R.string.copied_to_clipboard), + text = stringResource(Res.string.copied_to_clipboard), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, ) @@ -176,7 +171,7 @@ private fun CreatedKeyDialog( }, confirmButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_done)) + Text(stringResource(Res.string.action_done)) } }, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeysScreen.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ApiKeysScreen.kt similarity index 90% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeysScreen.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ApiKeysScreen.kt index 0768e2b..b2a2423 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ApiKeysScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ApiKeysScreen.kt @@ -42,12 +42,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.librechat.android.core.model.ApiKey -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* import com.librechat.android.feature.settings.viewmodel.ApiKeysViewModel import org.koin.compose.viewmodel.koinViewModel @@ -74,12 +75,12 @@ fun ApiKeysScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_api_keys)) }, + title = { Text(stringResource(Res.string.title_api_keys)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -90,7 +91,7 @@ fun ApiKeysScreen( FloatingActionButton(onClick = viewModel::showCreateDialog) { Icon( imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.cd_create_api_key), + contentDescription = stringResource(Res.string.cd_create_api_key), ) } } @@ -129,12 +130,12 @@ fun ApiKeysScreen( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = stringResource(R.string.api_keys_not_available), + text = stringResource(Res.string.api_keys_not_available), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = stringResource(R.string.api_keys_not_supported), + text = stringResource(Res.string.api_keys_not_supported), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -157,12 +158,12 @@ fun ApiKeysScreen( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = stringResource(R.string.no_api_keys), + text = stringResource(Res.string.no_api_keys), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = stringResource(R.string.no_api_keys_desc), + text = stringResource(Res.string.no_api_keys_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -196,9 +197,9 @@ fun ApiKeysScreen( keyToDelete?.let { key -> AlertDialog( onDismissRequest = { keyToDelete = null }, - title = { Text(stringResource(R.string.dialog_title_delete_api_key)) }, + title = { Text(stringResource(Res.string.dialog_title_delete_api_key)) }, text = { - Text(stringResource(R.string.dialog_delete_api_key_message, key.name)) + Text(stringResource(Res.string.dialog_delete_api_key_message, key.name)) }, confirmButton = { TextButton( @@ -213,12 +214,12 @@ fun ApiKeysScreen( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(R.string.action_delete)) + Text(stringResource(Res.string.action_delete)) } }, dismissButton = { TextButton(onClick = { keyToDelete = null }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -286,7 +287,7 @@ private fun ApiKeyCard( if (createdAt != null) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = stringResource(R.string.created_prefix, formatDate(createdAt)), + text = stringResource(Res.string.created_prefix, formatDate(createdAt)), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -301,7 +302,7 @@ private fun ApiKeyCard( IconButton(onClick = onDeleteClick) { Icon( imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.cd_delete_api_key), + contentDescription = stringResource(Res.string.cd_delete_api_key), tint = MaterialTheme.colorScheme.error, ) } diff --git a/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt new file mode 100644 index 0000000..e05fa22 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.kt @@ -0,0 +1,13 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal expect fun AvatarUploadDialog( + currentAvatarUrl: String?, + isUploading: Boolean, + onPickImage: (Any) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/BalanceSection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/BalanceSection.kt similarity index 82% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/BalanceSection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/BalanceSection.kt index 6419652..70889db 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/BalanceSection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/BalanceSection.kt @@ -20,12 +20,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import com.librechat.android.feature.settings.R -import java.text.NumberFormat +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun BalanceSection( @@ -34,8 +34,8 @@ internal fun BalanceSection( modifier: Modifier = Modifier, ) { val balanceCd = stringResource( - R.string.cd_token_balance, - NumberFormat.getNumberInstance().format(tokenCredits), + Res.string.cd_token_balance, + formatNumber(tokenCredits), ) Column(modifier = modifier) { Column( @@ -72,11 +72,11 @@ internal fun BalanceSection( Spacer(modifier = Modifier.width(12.dp)) Column { Text( - text = stringResource(R.string.token_credits), + text = stringResource(Res.string.token_credits), style = MaterialTheme.typography.bodyMedium, ) Text( - text = stringResource(R.string.available_balance), + text = stringResource(Res.string.available_balance), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -89,7 +89,7 @@ internal fun BalanceSection( ) } else { Text( - text = NumberFormat.getNumberInstance().format(tokenCredits), + text = formatNumber(tokenCredits), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary, ) @@ -100,3 +100,15 @@ internal fun BalanceSection( HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) } } + +private fun formatNumber(value: Long): String { + val str = value.toString() + val result = StringBuilder() + var count = 0 + for (i in str.indices.reversed()) { + if (count > 0 && count % 3 == 0) result.insert(0, ',') + result.insert(0, str[i]) + count++ + } + return result.toString() +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsScreen.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsScreen.kt similarity index 91% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsScreen.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsScreen.kt index 3011b4e..26b8cc1 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsScreen.kt @@ -1,6 +1,7 @@ package com.librechat.android.feature.settings.screen import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -32,12 +33,13 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* import com.librechat.android.feature.settings.viewmodel.SettingsViewModel import org.koin.compose.viewmodel.koinViewModel @@ -53,12 +55,12 @@ fun ChatSettingsScreen( modifier = modifier, topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_chat)) }, + title = { Text(stringResource(Res.string.title_chat)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -87,13 +89,13 @@ fun ChatSettingsContent( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - Column { + Box { LazyColumn( modifier = modifier.fillMaxSize(), ) { // Chat Preferences section item(key = "chat_header") { - SectionHeader(stringResource(R.string.section_chat_preferences)) + SectionHeader(stringResource(Res.string.section_chat_preferences)) } item(key = "chat_settings") { ChatSettingsSection( @@ -120,25 +122,25 @@ fun ChatSettingsContent( // Presets section item(key = "presets_header") { - SectionHeader(stringResource(R.string.section_presets)) + SectionHeader(stringResource(Res.string.section_presets)) } item(key = "presets_row") { ChatSettingsRow( icon = Icons.Default.Brush, - title = stringResource(R.string.presets), - subtitle = stringResource(R.string.presets_subtitle), + title = stringResource(Res.string.presets), + subtitle = stringResource(Res.string.presets_subtitle), onClick = onNavigateToPresets, ) } // Advanced section item(key = "advanced_header") { - SectionHeader(stringResource(R.string.section_advanced)) + SectionHeader(stringResource(Res.string.section_advanced)) } item(key = "fork_settings_row") { ChatSettingsRow( icon = Icons.AutoMirrored.Filled.Chat, - title = stringResource(R.string.fork_behavior), + title = stringResource(Res.string.fork_behavior), subtitle = ForkMode.fromApiValue(uiState.forkMode).label, onClick = viewModel::showForkSettingsDialog, ) @@ -146,15 +148,15 @@ fun ChatSettingsContent( item(key = "commands_row") { ChatSettingsRow( icon = Icons.Default.Terminal, - title = stringResource(R.string.commands), - subtitle = stringResource(R.string.commands_enabled_count, uiState.commands.count { it.enabled }), + title = stringResource(Res.string.commands), + subtitle = stringResource(Res.string.commands_enabled_count, uiState.commands.count { it.enabled }), onClick = viewModel::showCommandsScreen, ) } // Speech section item(key = "speech_header") { - SectionHeader(stringResource(R.string.section_speech)) + SectionHeader(stringResource(Res.string.section_speech)) } item(key = "speech_settings") { SpeechSettingsSection( @@ -241,7 +243,7 @@ fun ChatSettingsContent( onNavigateBack = viewModel::hideCommandsScreen, ) } - } // Column + } // Box } @Composable @@ -277,7 +279,7 @@ private fun SpeechDetailButtons( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(stringResource(R.string.speech_to_text_settings)) + Text(stringResource(Res.string.speech_to_text_settings)) Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, @@ -294,7 +296,7 @@ private fun SpeechDetailButtons( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(stringResource(R.string.text_to_speech_settings)) + Text(stringResource(Res.string.text_to_speech_settings)) Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsSection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsSection.kt similarity index 86% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsSection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsSection.kt index 1412295..9716672 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsSection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ChatSettingsSection.kt @@ -18,13 +18,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.librechat.android.core.common.ChatLayoutConstants import com.librechat.android.core.data.datastore.ChatFontSize import com.librechat.android.core.data.datastore.LatexRenderer -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun ChatSettingsSection( @@ -56,13 +57,13 @@ internal fun ChatSettingsSection( ) { // Chat layout style selector Text( - text = stringResource(R.string.chat_layout), + text = stringResource(Res.string.chat_layout), style = MaterialTheme.typography.bodyLarge, ) Spacer(modifier = Modifier.height(4.dp)) listOf( - ChatLayoutConstants.THREAD to stringResource(R.string.chat_layout_thread), - ChatLayoutConstants.TWO_SIDED to stringResource(R.string.chat_layout_two_sided), + ChatLayoutConstants.THREAD to stringResource(Res.string.chat_layout_thread), + ChatLayoutConstants.TWO_SIDED to stringResource(Res.string.chat_layout_two_sided), ).forEach { (value, label) -> Row( modifier = Modifier @@ -88,9 +89,9 @@ internal fun ChatSettingsSection( ) Text( text = if (value == ChatLayoutConstants.THREAD) { - stringResource(R.string.chat_layout_thread_desc) + stringResource(Res.string.chat_layout_thread_desc) } else { - stringResource(R.string.chat_layout_two_sided_desc) + stringResource(Res.string.chat_layout_two_sided_desc) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -108,11 +109,11 @@ internal fun ChatSettingsSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.show_bubbles), + text = stringResource(Res.string.show_bubbles), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.show_bubbles_desc), + text = stringResource(Res.string.show_bubbles_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -133,11 +134,11 @@ internal fun ChatSettingsSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.show_avatars), + text = stringResource(Res.string.show_avatars), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.show_avatars_desc), + text = stringResource(Res.string.show_avatars_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -153,7 +154,7 @@ internal fun ChatSettingsSection( // Font size selector Text( - text = stringResource(R.string.font_size), + text = stringResource(Res.string.font_size), style = MaterialTheme.typography.bodyLarge, ) Spacer(modifier = Modifier.height(4.dp)) @@ -177,9 +178,9 @@ internal fun ChatSettingsSection( Spacer(modifier = Modifier.width(8.dp)) Text( text = when (size) { - ChatFontSize.SMALL -> stringResource(R.string.font_size_small) - ChatFontSize.MEDIUM -> stringResource(R.string.font_size_medium) - ChatFontSize.LARGE -> stringResource(R.string.font_size_large) + ChatFontSize.SMALL -> stringResource(Res.string.font_size_small) + ChatFontSize.MEDIUM -> stringResource(Res.string.font_size_medium) + ChatFontSize.LARGE -> stringResource(Res.string.font_size_large) }, style = MaterialTheme.typography.bodyLarge, ) @@ -190,13 +191,13 @@ internal fun ChatSettingsSection( // LaTeX renderer selector Text( - text = stringResource(R.string.latex_renderer), + text = stringResource(Res.string.latex_renderer), style = MaterialTheme.typography.bodyLarge, ) Spacer(modifier = Modifier.height(4.dp)) listOf( - LatexRenderer.KATEX to (stringResource(R.string.latex_katex) to stringResource(R.string.latex_katex_desc)), - LatexRenderer.NATIVE to (stringResource(R.string.latex_native) to stringResource(R.string.latex_native_desc)), + LatexRenderer.KATEX to (stringResource(Res.string.latex_katex) to stringResource(Res.string.latex_katex_desc)), + LatexRenderer.NATIVE to (stringResource(Res.string.latex_native) to stringResource(Res.string.latex_native_desc)), ).forEach { (renderer, labelAndDesc) -> val (label, description) = labelAndDesc Row( @@ -239,11 +240,11 @@ internal fun ChatSettingsSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.auto_scroll), + text = stringResource(Res.string.auto_scroll), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.auto_scroll_desc), + text = stringResource(Res.string.auto_scroll_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -264,11 +265,11 @@ internal fun ChatSettingsSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.show_thinking_blocks), + text = stringResource(Res.string.show_thinking_blocks), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.show_thinking_blocks_desc), + text = stringResource(Res.string.show_thinking_blocks_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -289,11 +290,11 @@ internal fun ChatSettingsSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.show_image_descriptions), + text = stringResource(Res.string.show_image_descriptions), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.show_image_descriptions_desc), + text = stringResource(Res.string.show_image_descriptions_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -314,11 +315,11 @@ internal fun ChatSettingsSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.dismiss_keyboard_on_send), + text = stringResource(Res.string.dismiss_keyboard_on_send), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.dismiss_keyboard_on_send_desc), + text = stringResource(Res.string.dismiss_keyboard_on_send_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/CommandsConfigScreen.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/CommandsConfigScreen.kt similarity index 91% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/CommandsConfigScreen.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/CommandsConfigScreen.kt index e136442..ff21971 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/CommandsConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/CommandsConfigScreen.kt @@ -23,9 +23,10 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* data class CommandConfig( val name: String, @@ -46,12 +47,12 @@ internal fun CommandsConfigScreen( modifier = modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_commands)) }, + title = { Text(stringResource(Res.string.title_commands)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -66,7 +67,7 @@ internal fun CommandsConfigScreen( .padding(16.dp), ) { Text( - text = stringResource(R.string.no_commands_available), + text = stringResource(Res.string.no_commands_available), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsScreen.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/DataSettingsScreen.kt similarity index 88% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsScreen.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/DataSettingsScreen.kt index b6b818c..ca93e6d 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/DataSettingsScreen.kt @@ -35,12 +35,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* import com.librechat.android.feature.settings.viewmodel.SettingsViewModel import org.koin.compose.viewmodel.koinViewModel @@ -60,12 +61,12 @@ fun DataSettingsScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_data)) }, + title = { Text(stringResource(Res.string.title_data)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -126,7 +127,7 @@ fun DataSettingsContent( ) { // Conversations section item(key = "conversations_header") { - SectionHeader(stringResource(R.string.section_conversations)) + SectionHeader(stringResource(Res.string.section_conversations)) } item(key = "data_settings") { DataSettingsSection( @@ -149,7 +150,7 @@ fun DataSettingsContent( // Memories section item(key = "memories_header") { - SectionHeader(stringResource(R.string.section_memories)) + SectionHeader(stringResource(Res.string.section_memories)) } item(key = "memories_settings") { MemoriesSettingsSection( @@ -168,7 +169,7 @@ fun DataSettingsContent( // MCP section item(key = "mcp_header") { - SectionHeader(stringResource(R.string.section_mcp_servers)) + SectionHeader(stringResource(Res.string.section_mcp_servers)) } item(key = "mcp_settings") { McpSettingsSection( @@ -202,8 +203,8 @@ fun DataSettingsContent( if (showClearCacheDialog) { AlertDialog( onDismissRequest = { showClearCacheDialog = false }, - title = { Text(stringResource(R.string.dialog_title_clear_cache)) }, - text = { Text(stringResource(R.string.dialog_clear_cache_message)) }, + title = { Text(stringResource(Res.string.dialog_title_clear_cache)) }, + text = { Text(stringResource(Res.string.dialog_clear_cache_message)) }, confirmButton = { TextButton( onClick = { @@ -211,12 +212,12 @@ fun DataSettingsContent( viewModel.clearCache() }, ) { - Text(stringResource(R.string.action_clear)) + Text(stringResource(Res.string.action_clear)) } }, dismissButton = { TextButton(onClick = { showClearCacheDialog = false }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -226,8 +227,8 @@ fun DataSettingsContent( if (showRevokeKeysDialog) { AlertDialog( onDismissRequest = { showRevokeKeysDialog = false }, - title = { Text(stringResource(R.string.dialog_title_revoke_keys)) }, - text = { Text(stringResource(R.string.dialog_revoke_keys_message)) }, + title = { Text(stringResource(Res.string.dialog_title_revoke_keys)) }, + text = { Text(stringResource(Res.string.dialog_revoke_keys_message)) }, confirmButton = { TextButton( onClick = { @@ -238,12 +239,12 @@ fun DataSettingsContent( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(R.string.action_revoke_all)) + Text(stringResource(Res.string.action_revoke_all)) } }, dismissButton = { TextButton(onClick = { showRevokeKeysDialog = false }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -288,7 +289,7 @@ private fun DataExtraActions( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(stringResource(R.string.shared_links)) + Text(stringResource(Res.string.shared_links)) Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, @@ -303,7 +304,7 @@ private fun DataExtraActions( enabled = !isCacheClearing, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(if (isCacheClearing) R.string.clearing else R.string.clear_cache)) + Text(stringResource(if (isCacheClearing) Res.string.clearing else Res.string.clear_cache)) } // Revoke API keys @@ -315,7 +316,7 @@ private fun DataExtraActions( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(if (isKeyRevoking) R.string.revoking else R.string.revoke_all_api_keys)) + Text(stringResource(if (isKeyRevoking) Res.string.revoking else Res.string.revoke_all_api_keys)) } } HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsSection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/DataSettingsSection.kt similarity index 87% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsSection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/DataSettingsSection.kt index 3ba8fc3..ef17c9d 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/DataSettingsSection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/DataSettingsSection.kt @@ -27,9 +27,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun DataSettingsSection( @@ -59,7 +60,7 @@ internal fun DataSettingsSection( contentColor = MaterialTheme.colorScheme.onError, ), ) { - Text(stringResource(if (isClearing) R.string.clearing else R.string.clear_all_conversations)) + Text(stringResource(if (isClearing) Res.string.clearing else Res.string.clear_all_conversations)) } // Archived conversations @@ -81,7 +82,7 @@ internal fun DataSettingsSection( contentDescription = null, modifier = Modifier.size(18.dp), ) - Text(stringResource(R.string.archived_conversations)) + Text(stringResource(Res.string.archived_conversations)) } Row( verticalAlignment = Alignment.CenterVertically, @@ -108,7 +109,7 @@ internal fun DataSettingsSection( onClick = onExportAllData, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(R.string.export_all_data)) + Text(stringResource(Res.string.export_all_data)) } Spacer(modifier = Modifier.height(0.dp)) @@ -119,10 +120,10 @@ internal fun DataSettingsSection( if (showClearDialog) { AlertDialog( onDismissRequest = { showClearDialog = false }, - title = { Text(stringResource(R.string.dialog_title_clear_conversations)) }, + title = { Text(stringResource(Res.string.dialog_title_clear_conversations)) }, text = { Text( - stringResource(R.string.dialog_clear_conversations_message), + stringResource(Res.string.dialog_clear_conversations_message), ) }, confirmButton = { @@ -135,12 +136,12 @@ internal fun DataSettingsSection( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(R.string.clear_all)) + Text(stringResource(Res.string.clear_all)) } }, dismissButton = { TextButton(onClick = { showClearDialog = false }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ForkSettingsDialog.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ForkSettingsDialog.kt similarity index 91% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ForkSettingsDialog.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ForkSettingsDialog.kt index 26d9337..19ceba3 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/ForkSettingsDialog.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/ForkSettingsDialog.kt @@ -21,11 +21,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.librechat.android.core.model.request.ForkOption -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* enum class ForkMode(val label: String, val description: String, val apiValue: String) { DIRECT_PATH( @@ -62,7 +63,7 @@ internal fun ForkSettingsDialog( AlertDialog( onDismissRequest = onDismiss, modifier = modifier, - title = { Text(stringResource(R.string.fork_behavior)) }, + title = { Text(stringResource(Res.string.fork_behavior)) }, text = { Column(modifier = Modifier.fillMaxWidth()) { ForkMode.entries.forEach { mode -> @@ -100,12 +101,12 @@ internal fun ForkSettingsDialog( }, confirmButton = { TextButton(onClick = { onModeSelected(currentMode) }) { - Text(stringResource(R.string.action_save)) + Text(stringResource(Res.string.action_save)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/GeneralSettingsScreen.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/GeneralSettingsScreen.kt similarity index 88% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/GeneralSettingsScreen.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/GeneralSettingsScreen.kt index ce85d77..5732016 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/GeneralSettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/GeneralSettingsScreen.kt @@ -33,7 +33,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics @@ -41,7 +41,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.librechat.android.core.data.datastore.ThemeMode -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* import com.librechat.android.feature.settings.viewmodel.SettingsViewModel import org.koin.compose.viewmodel.koinViewModel @@ -56,12 +57,12 @@ fun GeneralSettingsScreen( modifier = modifier, topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_general)) }, + title = { Text(stringResource(Res.string.title_general)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -94,7 +95,7 @@ fun GeneralSettingsContent( ) { // Appearance section item(key = "appearance_header") { - SectionHeader(stringResource(R.string.section_appearance)) + SectionHeader(stringResource(Res.string.section_appearance)) } item(key = "theme_selector") { ThemeSelector( @@ -105,12 +106,12 @@ fun GeneralSettingsContent( // Language item(key = "general_header") { - SectionHeader(stringResource(R.string.section_language)) + SectionHeader(stringResource(Res.string.section_language)) } item(key = "language_row") { GeneralSettingsRow( icon = Icons.Default.Language, - title = stringResource(R.string.language), + title = stringResource(Res.string.language), subtitle = uiState.selectedLanguage.uppercase(), onClick = viewModel::showLanguageDialog, ) @@ -118,7 +119,7 @@ fun GeneralSettingsContent( // Tablet section item(key = "tablet_header") { - SectionHeader(stringResource(R.string.section_layout)) + SectionHeader(stringResource(Res.string.section_layout)) } item(key = "tablet_sidebar_gesture") { TabletSidebarGestureToggle( @@ -129,16 +130,16 @@ fun GeneralSettingsContent( // Personalization item(key = "personalization_header") { - SectionHeader(stringResource(R.string.section_personalization)) + SectionHeader(stringResource(Res.string.section_personalization)) } item(key = "personalization_row") { GeneralSettingsRow( icon = Icons.Default.Person, - title = stringResource(R.string.personalization), + title = stringResource(Res.string.personalization), subtitle = if (uiState.personalizationEnabled) { - stringResource(R.string.status_enabled) + stringResource(Res.string.status_enabled) } else { - stringResource(R.string.status_disabled) + stringResource(Res.string.status_disabled) }, onClick = viewModel::showPersonalizationDialog, ) @@ -146,7 +147,7 @@ fun GeneralSettingsContent( // About section item(key = "about_header") { - SectionHeader(stringResource(R.string.section_about)) + SectionHeader(stringResource(Res.string.section_about)) } item(key = "about_info") { AboutInfo(serverUrl = uiState.serverUrl) @@ -217,9 +218,9 @@ private fun ThemeSelector( Spacer(modifier = Modifier.width(8.dp)) Text( text = when (mode) { - ThemeMode.SYSTEM -> stringResource(R.string.theme_system) - ThemeMode.LIGHT -> stringResource(R.string.theme_light) - ThemeMode.DARK -> stringResource(R.string.theme_dark) + ThemeMode.SYSTEM -> stringResource(Res.string.theme_system) + ThemeMode.LIGHT -> stringResource(Res.string.theme_light) + ThemeMode.DARK -> stringResource(Res.string.theme_dark) }, style = MaterialTheme.typography.bodyLarge, ) @@ -255,11 +256,11 @@ private fun TabletSidebarGestureToggle( Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.sidebar_swipe_gesture), + text = stringResource(Res.string.sidebar_swipe_gesture), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.sidebar_swipe_gesture_desc), + text = stringResource(Res.string.sidebar_swipe_gesture_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -288,11 +289,11 @@ private fun AboutInfo(serverUrl: String) { horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween, ) { Text( - text = stringResource(R.string.app_version_label), + text = stringResource(Res.string.app_version_label), style = MaterialTheme.typography.bodyMedium, ) Text( - text = stringResource(R.string.app_version_value), + text = stringResource(Res.string.app_version_value), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -303,11 +304,11 @@ private fun AboutInfo(serverUrl: String) { horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween, ) { Text( - text = stringResource(R.string.server_label), + text = stringResource(Res.string.server_label), style = MaterialTheme.typography.bodyMedium, ) Text( - text = serverUrl.ifBlank { stringResource(R.string.server_not_configured) }, + text = serverUrl.ifBlank { stringResource(Res.string.server_not_configured) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/LanguageSelectorDialog.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/LanguageSelectorDialog.kt similarity index 92% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/LanguageSelectorDialog.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/LanguageSelectorDialog.kt index 040b326..187b661 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/LanguageSelectorDialog.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/LanguageSelectorDialog.kt @@ -25,9 +25,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* data class LanguageOption( val code: String, @@ -99,13 +100,13 @@ internal fun LanguageSelectorDialog( AlertDialog( onDismissRequest = onDismiss, modifier = modifier, - title = { Text(stringResource(R.string.language)) }, + title = { Text(stringResource(Res.string.language)) }, text = { Column(modifier = Modifier.fillMaxWidth()) { OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, - label = { Text(stringResource(R.string.hint_search_languages)) }, + label = { Text(stringResource(Res.string.hint_search_languages)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -132,7 +133,7 @@ internal fun LanguageSelectorDialog( Spacer(modifier = Modifier.width(8.dp)) Icon( imageVector = Icons.Default.Check, - contentDescription = stringResource(R.string.cd_selected), + contentDescription = stringResource(Res.string.cd_selected), tint = MaterialTheme.colorScheme.primary, ) } @@ -143,7 +144,7 @@ internal fun LanguageSelectorDialog( }, confirmButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_done)) + Text(stringResource(Res.string.action_done)) } }, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerDialog.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpServerDialog.kt similarity index 86% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerDialog.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpServerDialog.kt index d49a0fd..f2a40cb 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerDialog.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpServerDialog.kt @@ -26,7 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.librechat.android.core.model.mcp.McpApiKeyConfig @@ -36,7 +36,8 @@ import com.librechat.android.core.model.mcp.McpAuthorizationType import com.librechat.android.core.model.mcp.McpOAuthConfig import com.librechat.android.core.model.mcp.McpServer import com.librechat.android.core.model.mcp.McpServerType -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* /** Add/edit MCP server dialog with server type dropdown and auth configuration. */ @OptIn(ExperimentalMaterial3Api::class) @@ -87,14 +88,14 @@ internal fun McpServerDialog( modifier = modifier, onDismissRequest = onDismiss, title = { - Text(stringResource(if (isEditing) R.string.edit_mcp_server else R.string.add_mcp_server)) + Text(stringResource(if (isEditing) Res.string.edit_mcp_server else Res.string.add_mcp_server)) }, text = { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text(stringResource(R.string.mcp_name_label)) }, + label = { Text(stringResource(Res.string.mcp_name_label)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -104,8 +105,8 @@ internal fun McpServerDialog( onValueChange = { description = it }, label = { Text( - stringResource(R.string.mcp_description_label) + " " + - "(" + stringResource(R.string.mcp_description_optional) + ")", + stringResource(Res.string.mcp_description_label) + " " + + "(" + stringResource(Res.string.mcp_description_optional) + ")", ) }, minLines = 2, @@ -116,14 +117,14 @@ internal fun McpServerDialog( OutlinedTextField( value = url, onValueChange = { url = it }, - label = { Text(stringResource(R.string.mcp_url_label)) }, + label = { Text(stringResource(Res.string.mcp_url_label)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(R.string.mcp_type_label), + text = stringResource(Res.string.mcp_type_label), style = MaterialTheme.typography.titleSmall, ) Spacer(modifier = Modifier.height(8.dp)) @@ -141,8 +142,8 @@ internal fun McpServerDialog( ) { Text( when (type) { - McpServerType.SSE -> stringResource(R.string.mcp_type_sse) - McpServerType.STREAMABLE_HTTP, McpServerType.HTTP -> stringResource(R.string.mcp_type_streamable_http) + McpServerType.SSE -> stringResource(Res.string.mcp_type_sse) + McpServerType.STREAMABLE_HTTP, McpServerType.HTTP -> stringResource(Res.string.mcp_type_streamable_http) else -> type.name }, ) @@ -152,7 +153,7 @@ internal fun McpServerDialog( Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(R.string.mcp_auth_label), + text = stringResource(Res.string.mcp_auth_label), style = MaterialTheme.typography.titleSmall, ) Spacer(modifier = Modifier.height(8.dp)) @@ -169,9 +170,9 @@ internal fun McpServerDialog( ) { Text( when (mode) { - McpAuthMode.NONE -> stringResource(R.string.mcp_auth_none) - McpAuthMode.API_KEY -> stringResource(R.string.mcp_auth_api_key) - McpAuthMode.OAUTH -> stringResource(R.string.mcp_auth_oauth) + McpAuthMode.NONE -> stringResource(Res.string.mcp_auth_none) + McpAuthMode.API_KEY -> stringResource(Res.string.mcp_auth_api_key) + McpAuthMode.OAUTH -> stringResource(Res.string.mcp_auth_oauth) }, ) } @@ -193,8 +194,8 @@ internal fun McpServerDialog( OutlinedTextField( value = apiKeyCustomHeader, onValueChange = { apiKeyCustomHeader = it }, - label = { Text(stringResource(R.string.mcp_header_name_label)) }, - placeholder = { Text(stringResource(R.string.mcp_header_name_placeholder)) }, + label = { Text(stringResource(Res.string.mcp_header_name_label)) }, + placeholder = { Text(stringResource(Res.string.mcp_header_name_placeholder)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -204,7 +205,7 @@ internal fun McpServerDialog( OutlinedTextField( value = apiKeyValue, onValueChange = { apiKeyValue = it }, - label = { Text(stringResource(R.string.mcp_api_key_label)) }, + label = { Text(stringResource(Res.string.mcp_api_key_label)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), @@ -218,7 +219,7 @@ internal fun McpServerDialog( OutlinedTextField( value = oauthClientId, onValueChange = { oauthClientId = it }, - label = { Text(stringResource(R.string.mcp_oauth_client_id)) }, + label = { Text(stringResource(Res.string.mcp_oauth_client_id)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -226,7 +227,7 @@ internal fun McpServerDialog( OutlinedTextField( value = oauthClientSecret, onValueChange = { oauthClientSecret = it }, - label = { Text(stringResource(R.string.mcp_oauth_client_secret)) }, + label = { Text(stringResource(Res.string.mcp_oauth_client_secret)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), @@ -235,7 +236,7 @@ internal fun McpServerDialog( OutlinedTextField( value = oauthAuthUrl, onValueChange = { oauthAuthUrl = it }, - label = { Text(stringResource(R.string.mcp_oauth_auth_url)) }, + label = { Text(stringResource(Res.string.mcp_oauth_auth_url)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -243,7 +244,7 @@ internal fun McpServerDialog( OutlinedTextField( value = oauthTokenUrl, onValueChange = { oauthTokenUrl = it }, - label = { Text(stringResource(R.string.mcp_oauth_token_url)) }, + label = { Text(stringResource(Res.string.mcp_oauth_token_url)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -251,8 +252,8 @@ internal fun McpServerDialog( OutlinedTextField( value = oauthScope, onValueChange = { oauthScope = it }, - label = { Text(stringResource(R.string.mcp_oauth_scope)) }, - placeholder = { Text(stringResource(R.string.mcp_oauth_scope_placeholder)) }, + label = { Text(stringResource(Res.string.mcp_oauth_scope)) }, + placeholder = { Text(stringResource(Res.string.mcp_oauth_scope_placeholder)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -292,12 +293,12 @@ internal fun McpServerDialog( }, enabled = name.isNotBlank() && url.isNotBlank(), ) { - Text(stringResource(if (isEditing) R.string.action_save else R.string.action_add)) + Text(stringResource(if (isEditing) Res.string.action_save else Res.string.action_add)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -318,13 +319,13 @@ private fun ApiKeyAuthTypeSelector( ) { OutlinedTextField( value = when (selected) { - McpAuthorizationType.BEARER -> stringResource(R.string.mcp_auth_bearer) - McpAuthorizationType.BASIC -> stringResource(R.string.mcp_auth_basic) - McpAuthorizationType.CUSTOM -> stringResource(R.string.mcp_auth_custom) + McpAuthorizationType.BEARER -> stringResource(Res.string.mcp_auth_bearer) + McpAuthorizationType.BASIC -> stringResource(Res.string.mcp_auth_basic) + McpAuthorizationType.CUSTOM -> stringResource(Res.string.mcp_auth_custom) }, onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.mcp_auth_key_type)) }, + label = { Text(stringResource(Res.string.mcp_auth_key_type)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, @@ -341,9 +342,9 @@ private fun ApiKeyAuthTypeSelector( text = { Text( when (type) { - McpAuthorizationType.BEARER -> stringResource(R.string.mcp_auth_bearer) - McpAuthorizationType.BASIC -> stringResource(R.string.mcp_auth_basic) - McpAuthorizationType.CUSTOM -> stringResource(R.string.mcp_auth_custom) + McpAuthorizationType.BEARER -> stringResource(Res.string.mcp_auth_bearer) + McpAuthorizationType.BASIC -> stringResource(Res.string.mcp_auth_basic) + McpAuthorizationType.CUSTOM -> stringResource(Res.string.mcp_auth_custom) }, ) }, diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerStatusIndicator.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpServerStatusIndicator.kt similarity index 100% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServerStatusIndicator.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpServerStatusIndicator.kt diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServersScreen.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpServersScreen.kt similarity index 91% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServersScreen.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpServersScreen.kt index ca6b9ed..0d80394 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpServersScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpServersScreen.kt @@ -45,7 +45,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -53,7 +53,8 @@ import com.librechat.android.core.model.mcp.McpServer import com.librechat.android.core.model.mcp.McpServerStatus import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.LoadingIndicator -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* import com.librechat.android.feature.settings.viewmodel.McpViewModel import org.koin.compose.viewmodel.koinViewModel @@ -91,12 +92,12 @@ fun McpServersScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_mcp_servers)) }, + title = { Text(stringResource(Res.string.title_mcp_servers)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -105,7 +106,7 @@ fun McpServersScreen( IconButton(onClick = { viewModel.showToolsSheet() }) { Icon( imageVector = Icons.Default.Build, - contentDescription = stringResource(R.string.cd_view_all_tools), + contentDescription = stringResource(Res.string.cd_view_all_tools), ) } } @@ -116,7 +117,7 @@ fun McpServersScreen( FloatingActionButton(onClick = viewModel::showAddServerDialog) { Icon( imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.cd_add_server), + contentDescription = stringResource(Res.string.cd_add_server), ) } }, @@ -125,7 +126,7 @@ fun McpServersScreen( LoadingIndicator() } else if (uiState.error != null && uiState.servers.isEmpty()) { ErrorBanner( - message = uiState.error ?: stringResource(R.string.error_failed_to_load_servers), + message = uiState.error ?: stringResource(Res.string.error_failed_to_load_servers), modifier = Modifier.padding(innerPadding), onRetry = { viewModel.loadServers() }, ) @@ -150,13 +151,13 @@ fun McpServersScreen( ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = stringResource(R.string.no_mcp_servers), + text = stringResource(Res.string.no_mcp_servers), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.mcp_add_hint), + text = stringResource(Res.string.mcp_add_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -284,7 +285,7 @@ private fun McpServerListItem( } else { Icon( imageVector = Icons.Default.Refresh, - contentDescription = stringResource(R.string.cd_reinitialize), + contentDescription = stringResource(Res.string.cd_reinitialize), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -294,7 +295,7 @@ private fun McpServerListItem( IconButton(onClick = onShowTools, modifier = Modifier.size(36.dp)) { Icon( imageVector = Icons.Default.Build, - contentDescription = stringResource(R.string.cd_view_tools), + contentDescription = stringResource(Res.string.cd_view_tools), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -303,7 +304,7 @@ private fun McpServerListItem( IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) { Icon( imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.cd_edit_server), + contentDescription = stringResource(Res.string.cd_edit_server), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -314,7 +315,7 @@ private fun McpServerListItem( ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.cd_delete_server), + contentDescription = stringResource(Res.string.cd_delete_server), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.error, ) @@ -340,8 +341,8 @@ private fun McpServerListItem( if (showDeleteConfirm) { AlertDialog( onDismissRequest = { showDeleteConfirm = false }, - title = { Text(stringResource(R.string.dialog_title_delete_server)) }, - text = { Text(stringResource(R.string.dialog_delete_server_message, server.name)) }, + title = { Text(stringResource(Res.string.dialog_title_delete_server)) }, + text = { Text(stringResource(Res.string.dialog_delete_server_message, server.name)) }, confirmButton = { TextButton( onClick = { @@ -349,12 +350,12 @@ private fun McpServerListItem( onDelete() }, ) { - Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error) + Text(stringResource(Res.string.action_delete), color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpSettingsSection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpSettingsSection.kt similarity index 92% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpSettingsSection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpSettingsSection.kt index a03dc47..56d41ca 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpSettingsSection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpSettingsSection.kt @@ -25,12 +25,13 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.librechat.android.core.model.mcp.McpServer import com.librechat.android.core.model.mcp.McpServerStatus -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun McpSettingsSection( @@ -53,13 +54,13 @@ internal fun McpSettingsSection( ) { if (error != null) { Text( - text = stringResource(R.string.mcp_not_available), + text = stringResource(Res.string.mcp_not_available), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else if (servers.isEmpty()) { Text( - text = stringResource(R.string.no_mcp_servers), + text = stringResource(Res.string.no_mcp_servers), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -91,7 +92,7 @@ internal fun McpSettingsSection( modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.add_mcp_server)) + Text(stringResource(Res.string.add_mcp_server)) } } } @@ -156,7 +157,7 @@ private fun McpServerItem( } else { Icon( imageVector = Icons.Default.Refresh, - contentDescription = stringResource(R.string.cd_reinitialize), + contentDescription = stringResource(Res.string.cd_reinitialize), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -165,7 +166,7 @@ private fun McpServerItem( IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) { Icon( imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.cd_edit), + contentDescription = stringResource(Res.string.cd_edit), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -173,7 +174,7 @@ private fun McpServerItem( IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { Icon( imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.cd_delete), + contentDescription = stringResource(Res.string.cd_delete), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.error, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolList.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpToolList.kt similarity index 94% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolList.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpToolList.kt index 7edd81f..7e31505 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolList.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpToolList.kt @@ -27,10 +27,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp import com.librechat.android.core.model.mcp.McpTool -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun McpToolList( @@ -68,7 +69,7 @@ internal fun McpToolList( } else { Icons.Default.KeyboardArrowDown }, - contentDescription = stringResource(if (expanded) R.string.cd_collapse_tools else R.string.cd_expand_tools), + contentDescription = stringResource(if (expanded) Res.string.cd_collapse_tools else Res.string.cd_expand_tools), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolsSheet.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpToolsSheet.kt similarity index 95% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolsSheet.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpToolsSheet.kt index 9e312cf..c201320 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/McpToolsSheet.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/McpToolsSheet.kt @@ -16,12 +16,13 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.librechat.android.core.model.mcp.McpTool -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* /** Bottom sheet listing MCP tools grouped by server; optional serverFilter narrows to one server. */ @OptIn(ExperimentalMaterial3Api::class) @@ -62,7 +63,7 @@ internal fun McpToolsSheet( if (filteredTools.isEmpty()) { Text( - text = stringResource(R.string.no_tools_available), + text = stringResource(Res.string.no_tools_available), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesScreen.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/MemoriesScreen.kt similarity index 88% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesScreen.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/MemoriesScreen.kt index c6abf44..14f0d8a 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/MemoriesScreen.kt @@ -43,7 +43,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow @@ -52,7 +52,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.librechat.android.core.model.Memory import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.LoadingIndicator -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* import com.librechat.android.feature.settings.viewmodel.MemoriesViewModel import org.koin.compose.viewmodel.koinViewModel @@ -84,12 +85,12 @@ fun MemoriesScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_memories)) }, + title = { Text(stringResource(Res.string.title_memories)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -99,7 +100,7 @@ fun MemoriesScreen( FloatingActionButton(onClick = viewModel::showAddDialog) { Icon( imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.cd_add_memory), + contentDescription = stringResource(Res.string.cd_add_memory), ) } }, @@ -108,7 +109,7 @@ fun MemoriesScreen( LoadingIndicator() } else if (uiState.error != null && uiState.memories.isEmpty()) { ErrorBanner( - message = uiState.error ?: stringResource(R.string.error_failed_to_load_memories), + message = uiState.error ?: stringResource(Res.string.error_failed_to_load_memories), modifier = Modifier.padding(innerPadding), onRetry = { viewModel.loadMemories() }, ) @@ -141,13 +142,13 @@ fun MemoriesScreen( ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = stringResource(R.string.no_memories_saved), + text = stringResource(Res.string.no_memories_saved), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.memories_add_hint), + text = stringResource(Res.string.memories_add_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -196,17 +197,17 @@ private fun MemoriesPreferencesToggle( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.enable_memories), + text = stringResource(Res.string.enable_memories), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.enable_memories_desc), + text = stringResource(Res.string.enable_memories_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(modifier = Modifier.width(16.dp)) - val toggleMemoriesCd = stringResource(R.string.cd_toggle_memories) + val toggleMemoriesCd = stringResource(Res.string.cd_toggle_memories) Switch( checked = enabled, onCheckedChange = onToggle, @@ -267,7 +268,7 @@ private fun MemoryListItem( IconButton(onClick = onEdit, modifier = Modifier.size(40.dp)) { Icon( imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.cd_edit_memory), + contentDescription = stringResource(Res.string.cd_edit_memory), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -278,7 +279,7 @@ private fun MemoryListItem( ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.cd_delete_memory), + contentDescription = stringResource(Res.string.cd_delete_memory), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.error, ) @@ -290,8 +291,8 @@ private fun MemoryListItem( if (showDeleteConfirm) { AlertDialog( onDismissRequest = { showDeleteConfirm = false }, - title = { Text(stringResource(R.string.dialog_title_delete_memory)) }, - text = { Text(stringResource(R.string.dialog_delete_memory_message, memory.key)) }, + title = { Text(stringResource(Res.string.dialog_title_delete_memory)) }, + text = { Text(stringResource(Res.string.dialog_delete_memory_message, memory.key)) }, confirmButton = { TextButton( onClick = { @@ -299,12 +300,12 @@ private fun MemoryListItem( onDelete() }, ) { - Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error) + Text(stringResource(Res.string.action_delete), color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesSettingsSection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/MemoriesSettingsSection.kt similarity index 85% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesSettingsSection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/MemoriesSettingsSection.kt index 04671fd..30300cd 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoriesSettingsSection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/MemoriesSettingsSection.kt @@ -31,13 +31,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.librechat.android.core.model.Memory -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun MemoriesSettingsSection( @@ -67,17 +68,17 @@ internal fun MemoriesSettingsSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.enable_memories), + text = stringResource(Res.string.enable_memories), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.enable_memories_desc), + text = stringResource(Res.string.enable_memories_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(modifier = Modifier.width(16.dp)) - val toggleMemoriesCd = stringResource(R.string.cd_toggle_memories) + val toggleMemoriesCd = stringResource(Res.string.cd_toggle_memories) Switch( checked = memoriesEnabled, onCheckedChange = onToggleEnabled, @@ -92,7 +93,7 @@ internal fun MemoriesSettingsSection( // Memory list if (memories.isEmpty()) { Text( - text = stringResource(R.string.no_memories_saved), + text = stringResource(Res.string.no_memories_saved), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -118,7 +119,7 @@ internal fun MemoriesSettingsSection( modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.add_memory)) + Text(stringResource(Res.string.add_memory)) } } HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) @@ -173,7 +174,7 @@ private fun MemoryItem( IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) { Icon( imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.cd_edit), + contentDescription = stringResource(Res.string.cd_edit), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -184,7 +185,7 @@ private fun MemoryItem( ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.cd_delete), + contentDescription = stringResource(Res.string.cd_delete), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.error, ) @@ -195,8 +196,8 @@ private fun MemoryItem( if (showDeleteConfirm) { AlertDialog( onDismissRequest = { showDeleteConfirm = false }, - title = { Text(stringResource(R.string.dialog_title_delete_memory)) }, - text = { Text(stringResource(R.string.dialog_delete_memory_message, memory.key)) }, + title = { Text(stringResource(Res.string.dialog_title_delete_memory)) }, + text = { Text(stringResource(Res.string.dialog_delete_memory_message, memory.key)) }, confirmButton = { TextButton( onClick = { @@ -204,12 +205,12 @@ private fun MemoryItem( onDelete() }, ) { - Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error) + Text(stringResource(Res.string.action_delete), color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -231,14 +232,14 @@ private fun MemoryDialog( modifier = modifier, onDismissRequest = onDismiss, title = { - Text(stringResource(if (isEditing) R.string.edit_memory else R.string.add_memory)) + Text(stringResource(if (isEditing) Res.string.edit_memory else Res.string.add_memory)) }, text = { Column { OutlinedTextField( value = key, onValueChange = { key = it }, - label = { Text(stringResource(R.string.memory_key_label)) }, + label = { Text(stringResource(Res.string.memory_key_label)) }, singleLine = true, enabled = !isEditing, modifier = Modifier.fillMaxWidth(), @@ -247,7 +248,7 @@ private fun MemoryDialog( OutlinedTextField( value = value, onValueChange = { value = it }, - label = { Text(stringResource(R.string.memory_value_label)) }, + label = { Text(stringResource(Res.string.memory_value_label)) }, minLines = 2, maxLines = 5, modifier = Modifier.fillMaxWidth(), @@ -261,12 +262,12 @@ private fun MemoryDialog( }, enabled = key.isNotBlank() && value.isNotBlank(), ) { - Text(stringResource(if (isEditing) R.string.action_save else R.string.action_add)) + Text(stringResource(if (isEditing) Res.string.action_save else Res.string.action_add)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoryEditDialog.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/MemoryEditDialog.kt similarity index 79% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoryEditDialog.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/MemoryEditDialog.kt index 4d41fd1..768ff4f 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/MemoryEditDialog.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/MemoryEditDialog.kt @@ -14,10 +14,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp import com.librechat.android.core.model.Memory -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* /** Add/edit memory dialog; key field is immutable when editing an existing memory. */ @Composable @@ -35,14 +36,14 @@ internal fun MemoryEditDialog( modifier = modifier, onDismissRequest = onDismiss, title = { - Text(stringResource(if (isEditing) R.string.edit_memory else R.string.add_memory)) + Text(stringResource(if (isEditing) Res.string.edit_memory else Res.string.add_memory)) }, text = { Column { OutlinedTextField( value = key, onValueChange = { key = it }, - label = { Text(stringResource(R.string.memory_key_label)) }, + label = { Text(stringResource(Res.string.memory_key_label)) }, singleLine = true, enabled = !isEditing, modifier = Modifier.fillMaxWidth(), @@ -51,7 +52,7 @@ internal fun MemoryEditDialog( OutlinedTextField( value = value, onValueChange = { value = it }, - label = { Text(stringResource(R.string.memory_value_label)) }, + label = { Text(stringResource(Res.string.memory_value_label)) }, minLines = 2, maxLines = 5, modifier = Modifier.fillMaxWidth(), @@ -63,12 +64,12 @@ internal fun MemoryEditDialog( onClick = { onSave(key.trim(), value.trim()) }, enabled = key.isNotBlank() && value.isNotBlank(), ) { - Text(stringResource(if (isEditing) R.string.action_save else R.string.action_add)) + Text(stringResource(if (isEditing) Res.string.action_save else Res.string.action_add)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PersonalizationDialog.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/PersonalizationDialog.kt similarity index 82% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PersonalizationDialog.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/PersonalizationDialog.kt index a396dfa..34a3f11 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PersonalizationDialog.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/PersonalizationDialog.kt @@ -20,9 +20,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* /** "About you" and "Response style" text areas with enable toggle; fields disabled when off. */ @Composable @@ -41,7 +42,7 @@ internal fun PersonalizationDialog( AlertDialog( onDismissRequest = onDismiss, modifier = modifier, - title = { Text(stringResource(R.string.personalization)) }, + title = { Text(stringResource(Res.string.personalization)) }, text = { Column( modifier = Modifier.fillMaxWidth(), @@ -52,7 +53,7 @@ internal fun PersonalizationDialog( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.enable_personalization), + text = stringResource(Res.string.enable_personalization), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f), ) @@ -66,9 +67,9 @@ internal fun PersonalizationDialog( OutlinedTextField( value = currentAboutUser, onValueChange = { currentAboutUser = it }, - label = { Text(stringResource(R.string.about_you_label)) }, + label = { Text(stringResource(Res.string.about_you_label)) }, supportingText = { - Text(stringResource(R.string.about_you_hint)) + Text(stringResource(Res.string.about_you_hint)) }, minLines = 3, maxLines = 5, @@ -81,9 +82,9 @@ internal fun PersonalizationDialog( OutlinedTextField( value = currentResponseStyle, onValueChange = { currentResponseStyle = it }, - label = { Text(stringResource(R.string.response_style_label)) }, + label = { Text(stringResource(Res.string.response_style_label)) }, supportingText = { - Text(stringResource(R.string.response_style_hint)) + Text(stringResource(Res.string.response_style_hint)) }, minLines = 3, maxLines = 5, @@ -98,12 +99,12 @@ internal fun PersonalizationDialog( onSave(currentAboutUser, currentResponseStyle, currentEnabled) }, ) { - Text(stringResource(R.string.action_save)) + Text(stringResource(Res.string.action_save)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PresetManagerScreen.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/PresetManagerScreen.kt similarity index 89% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PresetManagerScreen.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/PresetManagerScreen.kt index 46e5f13..bada44e 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/PresetManagerScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/PresetManagerScreen.kt @@ -35,14 +35,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.librechat.android.core.ui.components.EmptyState import com.librechat.android.core.ui.components.LoadingIndicator import com.librechat.android.feature.settings.PresetManagerDisplayData -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* import com.librechat.android.feature.settings.viewmodel.PresetManagerViewModel import org.koin.compose.viewmodel.koinViewModel @@ -69,12 +70,12 @@ fun PresetManagerScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_presets)) }, + title = { Text(stringResource(Res.string.title_presets)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -94,8 +95,8 @@ fun PresetManagerScreen( } !uiState.isLoading && uiState.presets.isEmpty() -> { EmptyState( - title = stringResource(R.string.no_presets), - description = stringResource(R.string.no_presets_desc), + title = stringResource(Res.string.no_presets), + description = stringResource(Res.string.no_presets_desc), icon = Icons.Default.Tune, ) } @@ -122,10 +123,10 @@ fun PresetManagerScreen( if (deleteConfirmation != null) { AlertDialog( onDismissRequest = { deleteConfirmation = null }, - title = { Text(stringResource(R.string.dialog_title_delete_preset)) }, + title = { Text(stringResource(Res.string.dialog_title_delete_preset)) }, text = { Text( - text = stringResource(R.string.dialog_delete_preset_message, deleteConfirmation?.title ?: ""), + text = stringResource(Res.string.dialog_delete_preset_message, deleteConfirmation?.title ?: ""), style = MaterialTheme.typography.bodyMedium, ) }, @@ -135,14 +136,14 @@ fun PresetManagerScreen( deleteConfirmation = null }) { Text( - text = stringResource(R.string.action_delete), + text = stringResource(Res.string.action_delete), color = MaterialTheme.colorScheme.error, ) } }, dismissButton = { TextButton(onClick = { deleteConfirmation = null }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -197,7 +198,7 @@ private fun PresetCard( IconButton(onClick = onDelete) { Icon( imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.cd_delete_preset), + contentDescription = stringResource(Res.string.cd_delete_preset), tint = MaterialTheme.colorScheme.error, ) } diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SecuritySection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SecuritySection.kt similarity index 87% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SecuritySection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SecuritySection.kt index df6577a..86cebb2 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SecuritySection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SecuritySection.kt @@ -20,9 +20,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun SecuritySection( @@ -61,11 +62,11 @@ internal fun SecuritySection( Spacer(modifier = Modifier.width(12.dp)) Column { Text( - text = stringResource(R.string.two_factor_auth), + text = stringResource(Res.string.two_factor_auth), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(if (isTwoFactorEnabled) R.string.status_enabled else R.string.status_disabled), + text = stringResource(if (isTwoFactorEnabled) Res.string.status_enabled else Res.string.status_disabled), style = MaterialTheme.typography.bodySmall, color = if (isTwoFactorEnabled) { MaterialTheme.colorScheme.primary @@ -83,7 +84,7 @@ internal fun SecuritySection( modifier = Modifier.fillMaxWidth(), ) { Text( - stringResource(if (isTwoFactorEnabled) R.string.disable_two_factor else R.string.enable_two_factor), + stringResource(if (isTwoFactorEnabled) Res.string.disable_two_factor else Res.string.enable_two_factor), ) } @@ -94,7 +95,7 @@ internal fun SecuritySection( enabled = !isLoading, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(R.string.view_backup_codes)) + Text(stringResource(Res.string.view_backup_codes)) } } diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt similarity index 82% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt index 26474ab..c5d97ab 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SettingsScreen.kt @@ -32,7 +32,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @@ -40,7 +40,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.librechat.android.core.ui.components.ErrorBanner import com.librechat.android.core.ui.components.LoadingIndicator import com.librechat.android.core.ui.components.OtpVerificationDialog -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* import com.librechat.android.feature.settings.screen.sections.AboutInfo import com.librechat.android.feature.settings.screen.sections.AccountInfo import com.librechat.android.feature.settings.screen.sections.BackupCodesDialog @@ -110,12 +111,12 @@ fun SettingsScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_settings)) }, + title = { Text(stringResource(Res.string.title_settings)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -126,7 +127,7 @@ fun SettingsScreen( LoadingIndicator() } else if (uiState.error != null && uiState.user == null) { ErrorBanner( - message = uiState.error ?: stringResource(R.string.error_could_not_load_settings), + message = uiState.error ?: stringResource(Res.string.error_could_not_load_settings), modifier = Modifier.padding(innerPadding), onRetry = { viewModel.retry() }, ) @@ -138,7 +139,7 @@ fun SettingsScreen( ) { // Account section item(key = "account_header") { - SectionHeader(stringResource(R.string.section_account)) + SectionHeader(stringResource(Res.string.section_account)) } item(key = "account_info") { AccountInfo( @@ -151,7 +152,7 @@ fun SettingsScreen( // Balance section item(key = "balance_header") { - SectionHeader(stringResource(R.string.section_balance)) + SectionHeader(stringResource(Res.string.section_balance)) } item(key = "balance_section") { BalanceSection( @@ -162,7 +163,7 @@ fun SettingsScreen( // Appearance section item(key = "appearance_header") { - SectionHeader(stringResource(R.string.section_appearance)) + SectionHeader(stringResource(Res.string.section_appearance)) } item(key = "theme_selector") { ThemeSelector( @@ -173,7 +174,7 @@ fun SettingsScreen( // Tablet section item(key = "tablet_header") { - SectionHeader(stringResource(R.string.section_tablet)) + SectionHeader(stringResource(Res.string.section_tablet)) } item(key = "tablet_sidebar_gesture") { TabletSidebarGestureToggle( @@ -184,7 +185,7 @@ fun SettingsScreen( // Chat Preferences section item(key = "chat_header") { - SectionHeader(stringResource(R.string.section_chat_preferences)) + SectionHeader(stringResource(Res.string.section_chat_preferences)) } item(key = "chat_settings") { ChatSettingsSection( @@ -211,12 +212,12 @@ fun SettingsScreen( // General section item(key = "general_header") { - SectionHeader(stringResource(R.string.section_general)) + SectionHeader(stringResource(Res.string.section_general)) } item(key = "language_row") { SettingsRow( icon = Icons.Default.Language, - title = stringResource(R.string.language), + title = stringResource(Res.string.language), subtitle = uiState.selectedLanguage.uppercase(), onClick = viewModel::showLanguageDialog, ) @@ -224,11 +225,11 @@ fun SettingsScreen( item(key = "personalization_row") { SettingsRow( icon = Icons.Default.Person, - title = stringResource(R.string.personalization), + title = stringResource(Res.string.personalization), subtitle = if (uiState.personalizationEnabled) { - stringResource(R.string.status_enabled) + stringResource(Res.string.status_enabled) } else { - stringResource(R.string.status_disabled) + stringResource(Res.string.status_disabled) }, onClick = viewModel::showPersonalizationDialog, ) @@ -236,12 +237,12 @@ fun SettingsScreen( // Advanced section item(key = "advanced_header") { - SectionHeader(stringResource(R.string.section_advanced)) + SectionHeader(stringResource(Res.string.section_advanced)) } item(key = "fork_settings_row") { SettingsRow( icon = Icons.AutoMirrored.Filled.Chat, - title = stringResource(R.string.fork_behavior), + title = stringResource(Res.string.fork_behavior), subtitle = ForkMode.fromApiValue(uiState.forkMode).label, onClick = viewModel::showForkSettingsDialog, ) @@ -249,15 +250,15 @@ fun SettingsScreen( item(key = "commands_row") { SettingsRow( icon = Icons.Default.Terminal, - title = stringResource(R.string.commands), - subtitle = stringResource(R.string.commands_enabled_count, uiState.commands.count { it.enabled }), + title = stringResource(Res.string.commands), + subtitle = stringResource(Res.string.commands_enabled_count, uiState.commands.count { it.enabled }), onClick = viewModel::showCommandsScreen, ) } // Speech section item(key = "speech_header") { - SectionHeader(stringResource(R.string.section_speech)) + SectionHeader(stringResource(Res.string.section_speech)) } item(key = "speech_settings") { SpeechSettingsSection( @@ -281,7 +282,7 @@ fun SettingsScreen( // Memories section item(key = "memories_header") { - SectionHeader(stringResource(R.string.section_memories)) + SectionHeader(stringResource(Res.string.section_memories)) } item(key = "memories_settings") { MemoriesSettingsSection( @@ -300,7 +301,7 @@ fun SettingsScreen( // Data Management section item(key = "data_header") { - SectionHeader(stringResource(R.string.section_data_management)) + SectionHeader(stringResource(Res.string.section_data_management)) } item(key = "data_settings") { DataSettingsSection( @@ -323,7 +324,7 @@ fun SettingsScreen( // MCP section item(key = "mcp_header") { - SectionHeader(stringResource(R.string.section_mcp_servers)) + SectionHeader(stringResource(Res.string.section_mcp_servers)) } item(key = "mcp_settings") { McpSettingsSection( @@ -340,7 +341,7 @@ fun SettingsScreen( // Security section item(key = "security_header") { - SectionHeader(stringResource(R.string.section_security)) + SectionHeader(stringResource(Res.string.section_security)) } item(key = "security_settings") { SecuritySection( @@ -353,15 +354,15 @@ fun SettingsScreen( item(key = "api_keys_row") { SettingsRow( icon = Icons.Default.Key, - title = stringResource(R.string.api_keys), - subtitle = stringResource(R.string.api_keys_subtitle), + title = stringResource(Res.string.api_keys), + subtitle = stringResource(Res.string.api_keys_subtitle), onClick = onNavigateToApiKeys, ) } // About section item(key = "about_header") { - SectionHeader(stringResource(R.string.section_about)) + SectionHeader(stringResource(Res.string.section_about)) } item(key = "about_info") { AboutInfo(serverUrl = uiState.serverUrl) @@ -369,7 +370,7 @@ fun SettingsScreen( // Danger zone item(key = "danger_header") { - SectionHeader(stringResource(R.string.section_danger_zone)) + SectionHeader(stringResource(Res.string.section_danger_zone)) } item(key = "danger_actions") { DangerZone( @@ -446,8 +447,8 @@ fun SettingsScreen( if (showLogoutDialog) { AlertDialog( onDismissRequest = { showLogoutDialog = false }, - title = { Text(stringResource(R.string.dialog_title_sign_out)) }, - text = { Text(stringResource(R.string.dialog_sign_out_message)) }, + title = { Text(stringResource(Res.string.dialog_title_sign_out)) }, + text = { Text(stringResource(Res.string.dialog_sign_out_message)) }, confirmButton = { TextButton( onClick = { @@ -455,12 +456,12 @@ fun SettingsScreen( viewModel.logout() }, ) { - Text(stringResource(R.string.action_sign_out)) + Text(stringResource(Res.string.action_sign_out)) } }, dismissButton = { TextButton(onClick = { showLogoutDialog = false }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -479,8 +480,8 @@ fun SettingsScreen( // 2FA disable dialog if (uiState.showDisableTwoFactorDialog) { TwoFactorCodeDialog( - title = stringResource(R.string.dialog_title_disable_2fa), - description = stringResource(R.string.twofa_disable_instructions), + title = stringResource(Res.string.dialog_title_disable_2fa), + description = stringResource(Res.string.twofa_disable_instructions), isLoading = uiState.isTwoFactorLoading, onConfirm = viewModel::confirmDisableTwoFactor, onDismiss = viewModel::dismissDisableTwoFactorDialog, @@ -499,8 +500,8 @@ fun SettingsScreen( if (showClearCacheDialog) { AlertDialog( onDismissRequest = { showClearCacheDialog = false }, - title = { Text(stringResource(R.string.dialog_title_clear_cache)) }, - text = { Text(stringResource(R.string.dialog_clear_cache_message)) }, + title = { Text(stringResource(Res.string.dialog_title_clear_cache)) }, + text = { Text(stringResource(Res.string.dialog_clear_cache_message)) }, confirmButton = { TextButton( onClick = { @@ -508,12 +509,12 @@ fun SettingsScreen( viewModel.clearCache() }, ) { - Text(stringResource(R.string.action_clear)) + Text(stringResource(Res.string.action_clear)) } }, dismissButton = { TextButton(onClick = { showClearCacheDialog = false }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -523,8 +524,8 @@ fun SettingsScreen( if (showRevokeKeysDialog) { AlertDialog( onDismissRequest = { showRevokeKeysDialog = false }, - title = { Text(stringResource(R.string.dialog_title_revoke_keys)) }, - text = { Text(stringResource(R.string.dialog_revoke_keys_message)) }, + title = { Text(stringResource(Res.string.dialog_title_revoke_keys)) }, + text = { Text(stringResource(Res.string.dialog_revoke_keys_message)) }, confirmButton = { TextButton( onClick = { @@ -535,12 +536,12 @@ fun SettingsScreen( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(R.string.action_revoke_all)) + Text(stringResource(Res.string.action_revoke_all)) } }, dismissButton = { TextButton(onClick = { showRevokeKeysDialog = false }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -550,9 +551,9 @@ fun SettingsScreen( if (showDeleteDialog) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, - title = { Text(stringResource(R.string.dialog_title_delete_account)) }, + title = { Text(stringResource(Res.string.dialog_title_delete_account)) }, text = { - Text(stringResource(R.string.dialog_delete_account_message)) + Text(stringResource(Res.string.dialog_delete_account_message)) }, confirmButton = { TextButton( @@ -564,12 +565,12 @@ fun SettingsScreen( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(R.string.action_delete)) + Text(stringResource(Res.string.action_delete)) } }, dismissButton = { TextButton(onClick = { showDeleteDialog = false }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -578,54 +579,54 @@ fun SettingsScreen( // OTP dialog for account deletion when 2FA is enabled if (uiState.showDeleteAccountOtpDialog) { OtpVerificationDialog( - title = stringResource(R.string.otp_title_verify_identity), - description = stringResource(R.string.otp_desc_delete_account), + title = stringResource(Res.string.otp_title_verify_identity), + description = stringResource(Res.string.otp_desc_delete_account), isLoading = uiState.isLoading, onVerify = { token, backupCode -> viewModel.deleteAccount(token = token, backupCode = backupCode) }, onDismiss = viewModel::dismissDeleteAccountOtpDialog, - verifyLabel = stringResource(R.string.otp_verify), - cancelLabel = stringResource(R.string.otp_cancel), - backupCodeLabel = stringResource(R.string.otp_backup_code_label), - useBackupToggleLabel = stringResource(R.string.otp_use_backup_code), - useOtpToggleLabel = stringResource(R.string.otp_use_otp_code), + verifyLabel = stringResource(Res.string.otp_verify), + cancelLabel = stringResource(Res.string.otp_cancel), + backupCodeLabel = stringResource(Res.string.otp_backup_code_label), + useBackupToggleLabel = stringResource(Res.string.otp_use_backup_code), + useOtpToggleLabel = stringResource(Res.string.otp_use_otp_code), ) } // OTP dialog for enabling 2FA when re-enrolling if (uiState.showEnableTwoFactorOtpDialog) { OtpVerificationDialog( - title = stringResource(R.string.otp_title_verify_identity), - description = stringResource(R.string.otp_desc_reenroll_2fa), + title = stringResource(Res.string.otp_title_verify_identity), + description = stringResource(Res.string.otp_desc_reenroll_2fa), isLoading = uiState.isTwoFactorLoading, onVerify = { token, backupCode -> viewModel.enableTwoFactorWithOtp(token = token, backupCode = backupCode) }, onDismiss = viewModel::dismissEnableTwoFactorOtpDialog, - verifyLabel = stringResource(R.string.otp_verify), - cancelLabel = stringResource(R.string.otp_cancel), - backupCodeLabel = stringResource(R.string.otp_backup_code_label), - useBackupToggleLabel = stringResource(R.string.otp_use_backup_code), - useOtpToggleLabel = stringResource(R.string.otp_use_otp_code), + verifyLabel = stringResource(Res.string.otp_verify), + cancelLabel = stringResource(Res.string.otp_cancel), + backupCodeLabel = stringResource(Res.string.otp_backup_code_label), + useBackupToggleLabel = stringResource(Res.string.otp_use_backup_code), + useOtpToggleLabel = stringResource(Res.string.otp_use_otp_code), ) } // OTP dialog for regenerating backup codes if (uiState.showBackupCodesOtpDialog) { OtpVerificationDialog( - title = stringResource(R.string.otp_title_verify_identity), - description = stringResource(R.string.otp_desc_regenerate_backup_codes), + title = stringResource(Res.string.otp_title_verify_identity), + description = stringResource(Res.string.otp_desc_regenerate_backup_codes), isLoading = uiState.isTwoFactorLoading, onVerify = { token, backupCode -> viewModel.viewBackupCodesWithOtp(token = token, backupCode = backupCode) }, onDismiss = viewModel::dismissBackupCodesOtpDialog, - verifyLabel = stringResource(R.string.otp_verify), - cancelLabel = stringResource(R.string.otp_cancel), - backupCodeLabel = stringResource(R.string.otp_backup_code_label), - useBackupToggleLabel = stringResource(R.string.otp_use_backup_code), - useOtpToggleLabel = stringResource(R.string.otp_use_otp_code), + verifyLabel = stringResource(Res.string.otp_verify), + cancelLabel = stringResource(Res.string.otp_cancel), + backupCodeLabel = stringResource(Res.string.otp_backup_code_label), + useBackupToggleLabel = stringResource(Res.string.otp_use_backup_code), + useOtpToggleLabel = stringResource(Res.string.otp_use_otp_code), ) } diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SharedLinksScreen.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SharedLinksScreen.kt similarity index 86% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SharedLinksScreen.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SharedLinksScreen.kt index 4847f7f..a68c99f 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SharedLinksScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SharedLinksScreen.kt @@ -1,8 +1,6 @@ package com.librechat.android.feature.settings.screen -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context +import com.librechat.android.feature.settings.util.copyToClipboard import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -46,12 +44,12 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.librechat.android.core.ui.components.EmptyState -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* import com.librechat.android.feature.settings.SharedLinkDisplayData @OptIn(ExperimentalMaterial3Api::class) @@ -68,8 +66,6 @@ fun SharedLinksScreen( modifier: Modifier = Modifier, ) { val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current - val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val listState = rememberLazyListState() var deleteTarget by remember { mutableStateOf(null) } @@ -92,12 +88,12 @@ fun SharedLinksScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_shared_links)) }, + title = { Text(stringResource(Res.string.title_shared_links)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, @@ -122,8 +118,8 @@ fun SharedLinksScreen( .padding(padding), ) { EmptyState( - title = stringResource(R.string.no_shared_links), - description = stringResource(R.string.no_shared_links_desc), + title = stringResource(Res.string.no_shared_links), + description = stringResource(Res.string.no_shared_links_desc), icon = Icons.Default.Link, ) } @@ -146,9 +142,7 @@ fun SharedLinksScreen( link = link, serverUrl = serverUrl, onCopy = { url -> - clipboardManager.setPrimaryClip( - ClipData.newPlainText("Share Link", url), - ) + copyToClipboard(url, "Share Link") }, onToggleVisibility = { if (link.shareId.isNotEmpty()) { @@ -180,9 +174,9 @@ fun SharedLinksScreen( if (linkToDelete != null) { AlertDialog( onDismissRequest = { deleteTarget = null }, - title = { Text(stringResource(R.string.dialog_title_delete_shared_link)) }, + title = { Text(stringResource(Res.string.dialog_title_delete_shared_link)) }, text = { - Text(stringResource(R.string.dialog_delete_shared_link_message, linkToDelete.title)) + Text(stringResource(Res.string.dialog_delete_shared_link_message, linkToDelete.title)) }, confirmButton = { TextButton( @@ -194,14 +188,14 @@ fun SharedLinksScreen( }, ) { Text( - text = stringResource(R.string.action_delete), + text = stringResource(Res.string.action_delete), color = MaterialTheme.colorScheme.error, ) } }, dismissButton = { TextButton(onClick = { deleteTarget = null }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -245,7 +239,7 @@ private fun SharedLinkItem( ) link.createdAt?.let { created -> Text( - text = stringResource(R.string.created_prefix, created), + text = stringResource(Res.string.created_prefix, created), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -257,7 +251,7 @@ private fun SharedLinkItem( ) { // Visibility status indicator Text( - text = stringResource(if (link.isPublic) R.string.visibility_public else R.string.visibility_private), + text = stringResource(if (link.isPublic) Res.string.visibility_public else Res.string.visibility_private), style = MaterialTheme.typography.labelSmall, color = if (link.isPublic) { MaterialTheme.colorScheme.primary @@ -275,7 +269,7 @@ private fun SharedLinkItem( } else { Icons.Default.VisibilityOff }, - contentDescription = stringResource(if (link.isPublic) R.string.cd_make_private else R.string.cd_make_public), + contentDescription = stringResource(if (link.isPublic) Res.string.cd_make_private else Res.string.cd_make_public), modifier = Modifier.size(18.dp), ) } @@ -286,7 +280,7 @@ private fun SharedLinkItem( ) { Icon( imageVector = Icons.Default.ContentCopy, - contentDescription = stringResource(R.string.cd_copy_link), + contentDescription = stringResource(Res.string.cd_copy_link), modifier = Modifier.size(18.dp), ) } @@ -294,7 +288,7 @@ private fun SharedLinkItem( IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) { Icon( imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.cd_delete_shared_link), + contentDescription = stringResource(Res.string.cd_delete_shared_link), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp), ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechDetailDialogs.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SpeechDetailDialogs.kt similarity index 89% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechDetailDialogs.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SpeechDetailDialogs.kt index b259485..9ed0246 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechDetailDialogs.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SpeechDetailDialogs.kt @@ -33,11 +33,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -56,14 +57,14 @@ internal fun SttDetailDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.speech_to_text_settings)) }, + title = { Text(stringResource(Res.string.speech_to_text_settings)) }, text = { Column( verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Engine selector Text( - text = stringResource(R.string.stt_engine_label), + text = stringResource(Res.string.stt_engine_label), style = MaterialTheme.typography.labelMedium, ) ExposedDropdownMenuBox( @@ -72,8 +73,8 @@ internal fun SttDetailDialog( ) { OutlinedTextField( value = when { - engine.equals("Device", ignoreCase = true) -> stringResource(R.string.stt_on_device) - engine.isBlank() -> stringResource(R.string.stt_default) + engine.equals("Device", ignoreCase = true) -> stringResource(Res.string.stt_on_device) + engine.isBlank() -> stringResource(Res.string.stt_default) else -> engine }, onValueChange = {}, @@ -91,7 +92,7 @@ internal fun SttDetailDialog( ) { // On-device option first, separated by a divider DropdownMenuItem( - text = { Text(stringResource(R.string.stt_on_device)) }, + text = { Text(stringResource(Res.string.stt_on_device)) }, onClick = { engine = "Device" engineExpanded = false @@ -114,25 +115,25 @@ internal fun SttDetailDialog( // Engine-specific hint if (engine.equals("Whisper", ignoreCase = true)) { Text( - text = stringResource(R.string.stt_hint_whisper), + text = stringResource(Res.string.stt_hint_whisper), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else if (engine.equals("Device", ignoreCase = true)) { Text( - text = stringResource(R.string.stt_hint_device), + text = stringResource(Res.string.stt_hint_device), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else if (engine.equals("Google", ignoreCase = true)) { Text( - text = stringResource(R.string.stt_hint_google), + text = stringResource(Res.string.stt_hint_google), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else if (engine.equals("Default", ignoreCase = true) || engine.isBlank()) { Text( - text = stringResource(R.string.stt_hint_default), + text = stringResource(Res.string.stt_hint_default), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -140,7 +141,7 @@ internal fun SttDetailDialog( // Language selector Text( - text = stringResource(R.string.stt_language_label), + text = stringResource(Res.string.stt_language_label), style = MaterialTheme.typography.labelMedium, ) ExposedDropdownMenuBox( @@ -148,7 +149,7 @@ internal fun SttDetailDialog( onExpandedChange = { languageExpanded = it }, ) { OutlinedTextField( - value = language.ifBlank { stringResource(R.string.stt_auto_detect) }, + value = language.ifBlank { stringResource(Res.string.stt_auto_detect) }, onValueChange = {}, readOnly = true, trailingIcon = { @@ -177,12 +178,12 @@ internal fun SttDetailDialog( }, confirmButton = { TextButton(onClick = { onConfirm(engine, language) }) { - Text(stringResource(R.string.action_save)) + Text(stringResource(Res.string.action_save)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -226,20 +227,20 @@ internal fun TtsDetailDialog( var sourceExpanded by remember { mutableStateOf(false) } var deviceVoiceExpanded by remember { mutableStateOf(false) } - val deviceLabel = stringResource(R.string.tts_source_device) - val serverLabel = stringResource(R.string.tts_source_server) + val deviceLabel = stringResource(Res.string.tts_source_device) + val serverLabel = stringResource(Res.string.tts_source_server) val sourceOptions = listOf("device" to deviceLabel, "server" to serverLabel) AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.text_to_speech_settings)) }, + title = { Text(stringResource(Res.string.text_to_speech_settings)) }, text = { Column( verticalArrangement = Arrangement.spacedBy(16.dp), ) { // TTS Source selector Text( - text = stringResource(R.string.tts_source_label), + text = stringResource(Res.string.tts_source_label), style = MaterialTheme.typography.labelMedium, ) ExposedDropdownMenuBox( @@ -277,13 +278,13 @@ internal fun TtsDetailDialog( // Device voice picker if (availableDeviceVoices.isNotEmpty()) { Text( - text = stringResource(R.string.tts_voice_label), + text = stringResource(Res.string.tts_voice_label), style = MaterialTheme.typography.labelMedium, ) val selectedDisplayName = availableDeviceVoices .find { it.name == currentDeviceVoice } ?.let { "${it.name} (${it.locale})" } - ?: stringResource(R.string.tts_system_default) + ?: stringResource(Res.string.tts_system_default) ExposedDropdownMenuBox( expanded = deviceVoiceExpanded, onExpandedChange = { deviceVoiceExpanded = it }, @@ -304,7 +305,7 @@ internal fun TtsDetailDialog( onDismissRequest = { deviceVoiceExpanded = false }, ) { DropdownMenuItem( - text = { Text(stringResource(R.string.tts_system_default)) }, + text = { Text(stringResource(Res.string.tts_system_default)) }, onClick = { currentDeviceVoice = "" deviceVoiceExpanded = false @@ -337,7 +338,7 @@ internal fun TtsDetailDialog( // Speech rate slider Text( - text = stringResource(R.string.tts_speech_rate, rate), + text = stringResource(Res.string.tts_speech_rate, rate), style = MaterialTheme.typography.labelMedium, ) Slider( @@ -350,7 +351,7 @@ internal fun TtsDetailDialog( // Pitch slider Text( - text = stringResource(R.string.tts_pitch, currentPitch), + text = stringResource(Res.string.tts_pitch, currentPitch), style = MaterialTheme.typography.labelMedium, ) Slider( @@ -365,7 +366,7 @@ internal fun TtsDetailDialog( if (source == "server") { // Engine selector (only for server TTS) Text( - text = stringResource(R.string.stt_engine_label), + text = stringResource(Res.string.stt_engine_label), style = MaterialTheme.typography.labelMedium, ) ExposedDropdownMenuBox( @@ -373,7 +374,7 @@ internal fun TtsDetailDialog( onExpandedChange = { engineExpanded = it }, ) { OutlinedTextField( - value = engine.ifBlank { stringResource(R.string.stt_default) }, + value = engine.ifBlank { stringResource(Res.string.stt_default) }, onValueChange = {}, readOnly = true, trailingIcon = { @@ -401,7 +402,7 @@ internal fun TtsDetailDialog( // Voice selector (only for server TTS) Text( - text = stringResource(R.string.tts_voice_label), + text = stringResource(Res.string.tts_voice_label), style = MaterialTheme.typography.labelMedium, ) ExposedDropdownMenuBox( @@ -409,7 +410,7 @@ internal fun TtsDetailDialog( onExpandedChange = { voiceExpanded = it }, ) { OutlinedTextField( - value = voice.ifBlank { stringResource(R.string.stt_default) }, + value = voice.ifBlank { stringResource(Res.string.stt_default) }, onValueChange = {}, readOnly = true, trailingIcon = { @@ -441,7 +442,7 @@ internal fun TtsDetailDialog( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.tts_cache_audio), + text = stringResource(Res.string.tts_cache_audio), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) @@ -454,9 +455,9 @@ internal fun TtsDetailDialog( } // Preview button - val previewText = stringResource(R.string.tts_preview_text) + val previewText = stringResource(Res.string.tts_preview_text) val previewCd = - stringResource(if (isPreviewPlaying) R.string.cd_stop_voice_preview else R.string.cd_preview_voice) + stringResource(if (isPreviewPlaying) Res.string.cd_stop_voice_preview else Res.string.cd_preview_voice) FilledTonalButton( onClick = { if (isPreviewPlaying) { @@ -490,7 +491,7 @@ internal fun TtsDetailDialog( modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(if (isPreviewPlaying) R.string.tts_stop_preview else R.string.tts_preview_voice)) + Text(stringResource(if (isPreviewPlaying) Res.string.tts_stop_preview else Res.string.tts_preview_voice)) } } }, @@ -499,7 +500,7 @@ internal fun TtsDetailDialog( onStopPreview() onConfirm(engine, voice, rate, currentPitch, currentDeviceVoice, caching, source) }) { - Text(stringResource(R.string.action_save)) + Text(stringResource(Res.string.action_save)) } }, dismissButton = { @@ -507,7 +508,7 @@ internal fun TtsDetailDialog( onStopPreview() onDismiss() }) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechSettingsSection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SpeechSettingsSection.kt similarity index 86% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechSettingsSection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SpeechSettingsSection.kt index 1765aa7..c50c73c 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/SpeechSettingsSection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/SpeechSettingsSection.kt @@ -25,12 +25,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.librechat.android.core.model.speech.TtsVoice -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -59,17 +60,17 @@ internal fun SpeechSettingsSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.auto_send_after_speech), + text = stringResource(Res.string.auto_send_after_speech), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.auto_send_after_speech_desc), + text = stringResource(Res.string.auto_send_after_speech_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(modifier = Modifier.width(16.dp)) - val toggleAutoSendCd = stringResource(R.string.cd_toggle_auto_send_stt) + val toggleAutoSendCd = stringResource(Res.string.cd_toggle_auto_send_stt) Switch( checked = autoSendAfterSttEnabled, onCheckedChange = onAutoSendAfterSttChange, @@ -88,17 +89,17 @@ internal fun SpeechSettingsSection( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.auto_read_responses), + text = stringResource(Res.string.auto_read_responses), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.auto_read_responses_desc), + text = stringResource(Res.string.auto_read_responses_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(modifier = Modifier.width(16.dp)) - val toggleAutoReadCd = stringResource(R.string.cd_toggle_auto_read) + val toggleAutoReadCd = stringResource(Res.string.cd_toggle_auto_read) Switch( checked = autoReadEnabled, onCheckedChange = onAutoReadChange, @@ -114,7 +115,7 @@ internal fun SpeechSettingsSection( if (ttsSource == "server") { if (availableVoices.isNotEmpty()) { Text( - text = stringResource(R.string.tts_voice), + text = stringResource(Res.string.tts_voice), style = MaterialTheme.typography.bodyLarge, ) Spacer(modifier = Modifier.height(8.dp)) @@ -125,8 +126,8 @@ internal fun SpeechSettingsSection( expanded = expanded, onExpandedChange = { expanded = it }, ) { - val voiceName = selectedVoice?.name ?: stringResource(R.string.stt_default) - val voiceCd = stringResource(R.string.cd_tts_voice_selector, voiceName) + val voiceName = selectedVoice?.name ?: stringResource(Res.string.stt_default) + val voiceCd = stringResource(Res.string.cd_tts_voice_selector, voiceName) OutlinedTextField( value = voiceName, onValueChange = {}, @@ -172,25 +173,25 @@ internal fun SpeechSettingsSection( Spacer(modifier = Modifier.height(12.dp)) // Test voice button - val testVoiceCd = stringResource(R.string.cd_test_tts_voice) + val testVoiceCd = stringResource(Res.string.cd_test_tts_voice) Button( onClick = onTestVoice, modifier = Modifier.semantics { contentDescription = testVoiceCd }, ) { - Text(stringResource(R.string.test_voice)) + Text(stringResource(Res.string.test_voice)) } } else { Text( - text = stringResource(R.string.no_server_tts_voices), + text = stringResource(Res.string.no_server_tts_voices), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } else { Text( - text = stringResource(R.string.using_device_tts), + text = stringResource(Res.string.using_device_tts), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/TabbedSettingsScreen.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/TabbedSettingsScreen.kt similarity index 94% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/TabbedSettingsScreen.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/TabbedSettingsScreen.kt index fe1ecad..3d0b6e0 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/TabbedSettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/TabbedSettingsScreen.kt @@ -22,8 +22,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.librechat.android.feature.settings.R +import org.jetbrains.compose.resources.stringResource +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* import com.librechat.android.feature.settings.viewmodel.SettingsViewModel import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel @@ -57,12 +58,12 @@ fun TabbedSettingsScreen( topBar = { Column { TopAppBar( - title = { Text(stringResource(R.string.title_settings)) }, + title = { Text(stringResource(Res.string.title_settings)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back), + contentDescription = stringResource(Res.string.cd_back), ) } }, diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AccountSection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/AccountSection.kt similarity index 90% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AccountSection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/AccountSection.kt index f201331..dd3f31c 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AccountSection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/AccountSection.kt @@ -22,11 +22,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import com.librechat.android.feature.settings.R +import coil3.compose.AsyncImage +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun AccountInfo( @@ -52,7 +53,7 @@ internal fun AccountInfo( if (avatarUrl != null) { AsyncImage( model = avatarUrl, - contentDescription = stringResource(R.string.cd_user_avatar), + contentDescription = stringResource(Res.string.cd_user_avatar), modifier = Modifier.size(48.dp), contentScale = ContentScale.Crop, ) @@ -88,7 +89,7 @@ internal fun AccountInfo( IconButton(onClick = onAvatarClick) { Icon( imageVector = Icons.Default.CameraAlt, - contentDescription = stringResource(R.string.cd_change_avatar), + contentDescription = stringResource(Res.string.cd_change_avatar), modifier = Modifier.size(20.dp), ) } diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AppearanceSection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/AppearanceSection.kt similarity index 88% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AppearanceSection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/AppearanceSection.kt index 255698e..add7918 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/AppearanceSection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/AppearanceSection.kt @@ -21,11 +21,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.librechat.android.core.data.datastore.ThemeMode -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun ThemeSelector( @@ -54,9 +55,9 @@ internal fun ThemeSelector( Spacer(modifier = Modifier.width(8.dp)) Text( text = when (mode) { - ThemeMode.SYSTEM -> stringResource(R.string.theme_system) - ThemeMode.LIGHT -> stringResource(R.string.theme_light) - ThemeMode.DARK -> stringResource(R.string.theme_dark) + ThemeMode.SYSTEM -> stringResource(Res.string.theme_system) + ThemeMode.LIGHT -> stringResource(Res.string.theme_light) + ThemeMode.DARK -> stringResource(Res.string.theme_dark) }, style = MaterialTheme.typography.bodyLarge, ) @@ -92,11 +93,11 @@ internal fun TabletSidebarGestureToggle( Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.sidebar_swipe_gesture), + text = stringResource(Res.string.sidebar_swipe_gesture), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.sidebar_swipe_gesture_desc), + text = stringResource(Res.string.sidebar_swipe_gesture_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/ChatPreferencesSection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/ChatPreferencesSection.kt similarity index 89% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/ChatPreferencesSection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/ChatPreferencesSection.kt index 6cf5352..6e1358e 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/ChatPreferencesSection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/ChatPreferencesSection.kt @@ -22,10 +22,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun AboutInfo(serverUrl: String) { @@ -40,11 +41,11 @@ internal fun AboutInfo(serverUrl: String) { horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = stringResource(R.string.app_version_label), + text = stringResource(Res.string.app_version_label), style = MaterialTheme.typography.bodyMedium, ) Text( - text = stringResource(R.string.app_version_value), + text = stringResource(Res.string.app_version_value), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -55,11 +56,11 @@ internal fun AboutInfo(serverUrl: String) { horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = stringResource(R.string.server_label), + text = stringResource(Res.string.server_label), style = MaterialTheme.typography.bodyMedium, ) Text( - text = serverUrl.ifBlank { stringResource(R.string.server_not_configured) }, + text = serverUrl.ifBlank { stringResource(Res.string.server_not_configured) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -89,7 +90,7 @@ internal fun DangerZone( enabled = !isLoading, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(R.string.action_sign_out)) + Text(stringResource(Res.string.action_sign_out)) } Button( onClick = onDeleteClick, @@ -100,7 +101,7 @@ internal fun DangerZone( contentColor = MaterialTheme.colorScheme.onError, ), ) { - Text(stringResource(R.string.delete_account)) + Text(stringResource(Res.string.delete_account)) } } } diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/DataManagementSection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/DataManagementSection.kt similarity index 85% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/DataManagementSection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/DataManagementSection.kt index a872158..cfa8fed 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/DataManagementSection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/DataManagementSection.kt @@ -17,9 +17,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun DataExtraActions( @@ -46,7 +47,7 @@ internal fun DataExtraActions( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(stringResource(R.string.shared_links)) + Text(stringResource(Res.string.shared_links)) Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, @@ -61,7 +62,7 @@ internal fun DataExtraActions( enabled = !isCacheClearing, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(if (isCacheClearing) R.string.clearing else R.string.clear_cache)) + Text(stringResource(if (isCacheClearing) Res.string.clearing else Res.string.clear_cache)) } // Revoke API keys @@ -73,7 +74,7 @@ internal fun DataExtraActions( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(if (isKeyRevoking) R.string.revoking else R.string.revoke_all_api_keys)) + Text(stringResource(if (isKeyRevoking) Res.string.revoking else Res.string.revoke_all_api_keys)) } } HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt similarity index 83% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt index 189ebb3..d742757 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/SecuritySection.kt @@ -17,9 +17,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun TwoFactorSetupDialog( @@ -32,13 +33,13 @@ internal fun TwoFactorSetupDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_title_enable_2fa)) }, + title = { Text(stringResource(Res.string.dialog_title_enable_2fa)) }, text = { Column( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = stringResource(R.string.twofa_scan_instructions), + text = stringResource(Res.string.twofa_scan_instructions), style = MaterialTheme.typography.bodyMedium, ) if (otpauthUrl != null) { @@ -61,7 +62,7 @@ internal fun TwoFactorSetupDialog( androidx.compose.material3.OutlinedTextField( value = code, onValueChange = { code = it.filter { ch -> ch.isDigit() }.take(6) }, - label = { Text(stringResource(R.string.hint_verification_code)) }, + label = { Text(stringResource(Res.string.hint_verification_code)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -72,12 +73,12 @@ internal fun TwoFactorSetupDialog( onClick = { onConfirm(code) }, enabled = code.length == 6 && !isLoading, ) { - Text(stringResource(if (isLoading) R.string.action_verifying else R.string.action_verify)) + Text(stringResource(if (isLoading) Res.string.action_verifying else Res.string.action_verify)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -107,7 +108,7 @@ internal fun TwoFactorCodeDialog( androidx.compose.material3.OutlinedTextField( value = code, onValueChange = { code = it.filter { ch -> ch.isDigit() }.take(6) }, - label = { Text(stringResource(R.string.hint_verification_code)) }, + label = { Text(stringResource(Res.string.hint_verification_code)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -118,12 +119,12 @@ internal fun TwoFactorCodeDialog( onClick = { onConfirm(code) }, enabled = code.length == 6 && !isLoading, ) { - Text(stringResource(if (isLoading) R.string.action_verifying else R.string.action_confirm)) + Text(stringResource(if (isLoading) Res.string.action_verifying else Res.string.action_confirm)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + Text(stringResource(Res.string.action_cancel)) } }, ) @@ -136,13 +137,13 @@ internal fun BackupCodesDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_title_backup_codes)) }, + title = { Text(stringResource(Res.string.dialog_title_backup_codes)) }, text = { Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = stringResource(R.string.backup_codes_instructions), + text = stringResource(Res.string.backup_codes_instructions), style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.height(4.dp)) @@ -168,7 +169,7 @@ internal fun BackupCodesDialog( }, confirmButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_done)) + Text(stringResource(Res.string.action_done)) } }, ) diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SpeechSection.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/SpeechSection.kt similarity index 88% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SpeechSection.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/SpeechSection.kt index 4f4c240..71d4947 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/screen/sections/SpeechSection.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/screen/sections/SpeechSection.kt @@ -15,9 +15,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.unit.dp -import com.librechat.android.feature.settings.R +import librechat_android.feature.settings.generated.resources.Res +import librechat_android.feature.settings.generated.resources.* @Composable internal fun SpeechDetailButtons( @@ -40,7 +41,7 @@ internal fun SpeechDetailButtons( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(stringResource(R.string.speech_to_text_settings)) + Text(stringResource(Res.string.speech_to_text_settings)) Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, @@ -57,7 +58,7 @@ internal fun SpeechDetailButtons( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(stringResource(R.string.text_to_speech_settings)) + Text(stringResource(Res.string.text_to_speech_settings)) Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, diff --git a/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/util/ContentReader.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/util/ContentReader.kt new file mode 100644 index 0000000..aef1a81 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/util/ContentReader.kt @@ -0,0 +1,5 @@ +package com.librechat.android.feature.settings.util + +interface ContentReader { + fun readBytes(uri: Any): ByteArray? +} diff --git a/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/util/PlatformCacheCleaner.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/util/PlatformCacheCleaner.kt new file mode 100644 index 0000000..883aae5 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/util/PlatformCacheCleaner.kt @@ -0,0 +1,5 @@ +package com.librechat.android.feature.settings.util + +interface PlatformCacheCleaner { + fun clearCache() +} diff --git a/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/util/PlatformClipboard.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/util/PlatformClipboard.kt new file mode 100644 index 0000000..57e8bff --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/util/PlatformClipboard.kt @@ -0,0 +1,3 @@ +package com.librechat.android.feature.settings.util + +expect fun copyToClipboard(text: String, label: String = "") diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/ApiKeysViewModel.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/ApiKeysViewModel.kt similarity index 100% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/ApiKeysViewModel.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/ApiKeysViewModel.kt diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/McpViewModel.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/McpViewModel.kt similarity index 96% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/McpViewModel.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/McpViewModel.kt index 396b38e..59a1fe1 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/McpViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/McpViewModel.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import timber.log.Timber +import co.touchlab.kermit.Logger @Immutable data class McpUiState( @@ -76,7 +76,7 @@ class McpViewModel( _uiState.value = _uiState.value.copy(connectionStatus = result.data) } is Result.Error -> { - Timber.d(result.exception, "Failed to load connection status: ${result.message}") + Logger.d(result.exception) { "Failed to load connection status: ${result.message}" } } is Result.Loading -> { /* no-op */ } } @@ -90,7 +90,7 @@ class McpViewModel( _uiState.value = _uiState.value.copy(tools = result.data) } is Result.Error -> { - Timber.d(result.exception, "Failed to load tools: ${result.message}") + Logger.d(result.exception) { "Failed to load tools: ${result.message}" } } is Result.Loading -> { /* no-op */ } } diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/MemoriesViewModel.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/MemoriesViewModel.kt similarity index 100% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/MemoriesViewModel.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/MemoriesViewModel.kt diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/PresetManagerViewModel.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/PresetManagerViewModel.kt similarity index 100% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/PresetManagerViewModel.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/PresetManagerViewModel.kt diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsStateHandle.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsStateHandle.kt similarity index 100% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsStateHandle.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsStateHandle.kt diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt similarity index 97% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt index 08fff39..85a4706 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/SettingsViewModel.kt @@ -1,8 +1,8 @@ package com.librechat.android.feature.settings.viewmodel -import android.app.Application -import android.net.Uri import androidx.compose.runtime.Immutable +import com.librechat.android.feature.settings.util.ContentReader +import com.librechat.android.feature.settings.util.PlatformCacheCleaner import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.librechat.android.core.common.ChatLayoutConstants @@ -36,7 +36,7 @@ import com.librechat.android.feature.settings.screen.DeviceVoiceInfo import com.librechat.android.feature.settings.viewmodel.delegate.DataManagementDelegate import com.librechat.android.feature.settings.viewmodel.delegate.McpServerDelegate import com.librechat.android.feature.settings.viewmodel.delegate.MemoryManagementDelegate -import com.librechat.android.feature.settings.viewmodel.delegate.SpeechSettingsDelegate +import com.librechat.android.feature.settings.viewmodel.delegate.SpeechSettingsFactory import com.librechat.android.feature.settings.viewmodel.delegate.TwoFactorSecurityDelegate import com.librechat.android.feature.settings.viewmodel.delegate.isHttpStatus import kotlinx.coroutines.flow.MutableStateFlow @@ -46,7 +46,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import timber.log.Timber +import co.touchlab.kermit.Logger data class SettingsCommand( val name: String, @@ -182,7 +182,8 @@ private data class DataStorePreferences( ) class SettingsViewModel( - private val context: Application, + private val contentReader: ContentReader, + private val cacheCleaner: PlatformCacheCleaner, private val userRepository: UserRepository, private val authRepository: AuthRepository, conversationRepository: ConversationRepository, @@ -191,7 +192,7 @@ class SettingsViewModel( private val settingsDataStore: SettingsDataStore, mcpRepository: McpRepository, memoryRepository: MemoryRepository, - speechRepository: SpeechRepository, + speechSettingsFactory: SpeechSettingsFactory, private val balanceRepository: BalanceRepository, shareRepository: ShareRepository, keyRepository: KeyRepository, @@ -203,12 +204,12 @@ class SettingsViewModel( private val stateHandle = SettingsStateHandle(_uiState, viewModelScope) // --- Delegates --- + private val speechDelegate = speechSettingsFactory.create(stateHandle) private val memoryDelegate = MemoryManagementDelegate(stateHandle, memoryRepository) private val mcpDelegate = McpServerDelegate(stateHandle, mcpRepository) private val twoFactorDelegate = TwoFactorSecurityDelegate(stateHandle, authRepository) private val dataDelegate = - DataManagementDelegate(stateHandle, context, conversationRepository, shareRepository, keyRepository) - private val speechDelegate = SpeechSettingsDelegate(stateHandle, context, speechRepository, settingsDataStore) + DataManagementDelegate(stateHandle, cacheCleaner, conversationRepository, shareRepository, keyRepository) /** Combined DataStore preferences flow. */ private val dataStorePreferences: StateFlow = combine( @@ -413,10 +414,10 @@ class SettingsViewModel( _uiState.update { it.copy(showAvatarDialog = false) } } - fun uploadAvatar(uri: Uri) { + fun uploadAvatar(uri: Any) { viewModelScope.launch { _uiState.update { it.copy(isAvatarUploading = true) } - val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + val bytes = contentReader.readBytes(uri) if (bytes == null) { _uiState.update { it.copy(isAvatarUploading = false, error = "Could not read selected image") @@ -506,7 +507,7 @@ class SettingsViewModel( _uiState.update { it.copy(tokenCredits = result.data.tokenCredits, isBalanceLoading = false) } } is Result.Error -> { - Timber.d(result.exception, "Failed to load balance: ${result.message}") + Logger.d(result.exception) { "Failed to load balance: ${result.message}" } _uiState.update { it.copy(isBalanceLoading = false) } } is Result.Loading -> { /* no-op */ } diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/ApiKeysDelegate.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/ApiKeysDelegate.kt similarity index 100% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/ApiKeysDelegate.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/ApiKeysDelegate.kt diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/DataManagementDelegate.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/DataManagementDelegate.kt similarity index 97% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/DataManagementDelegate.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/DataManagementDelegate.kt index 64afe4a..0f6108b 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/DataManagementDelegate.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/DataManagementDelegate.kt @@ -1,7 +1,7 @@ package com.librechat.android.feature.settings.viewmodel.delegate -import android.content.Context import com.librechat.android.core.common.result.Result +import com.librechat.android.feature.settings.util.PlatformCacheCleaner import com.librechat.android.core.data.repository.ConversationRepository import com.librechat.android.core.data.repository.KeyRepository import com.librechat.android.core.data.repository.ShareRepository @@ -15,7 +15,7 @@ import kotlinx.coroutines.launch */ class DataManagementDelegate( private val stateHandle: SettingsStateHandle, - private val context: Context, + private val cacheCleaner: PlatformCacheCleaner, private val conversationRepository: ConversationRepository, private val shareRepository: ShareRepository, private val keyRepository: KeyRepository, @@ -144,8 +144,7 @@ class DataManagementDelegate( stateHandle.scope.launch { stateHandle.update { copy(isCacheClearing = true) } try { - val cacheDir = context.cacheDir - cacheDir.deleteRecursively() + cacheCleaner.clearCache() stateHandle.update { copy(isCacheClearing = false) } } catch (e: Exception) { stateHandle.update { diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/McpServerDelegate.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/McpServerDelegate.kt similarity index 95% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/McpServerDelegate.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/McpServerDelegate.kt index 8dbc698..82d2dfd 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/McpServerDelegate.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/McpServerDelegate.kt @@ -8,7 +8,7 @@ import com.librechat.android.core.model.mcp.McpServer import com.librechat.android.core.model.mcp.McpServerType import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle import kotlinx.coroutines.launch -import timber.log.Timber +import co.touchlab.kermit.Logger /** * Handles MCP server management, connection status, and reinitialization. @@ -25,7 +25,7 @@ class McpServerDelegate( stateHandle.update { copy(mcpServers = result.data, mcpError = null) } } is Result.Error -> { - Timber.d(result.exception, "Failed to load MCP servers: ${result.message}") + Logger.d(result.exception) { "Failed to load MCP servers: ${result.message}" } stateHandle.update { copy(mcpError = result.message ?: "MCP not available on this server") } } is Result.Loading -> { /* no-op */ } @@ -37,7 +37,7 @@ class McpServerDelegate( stateHandle.update { copy(mcpConnectionStatus = result.data) } } is Result.Error -> { - Timber.d(result.exception, "Failed to load MCP connection status: ${result.message}") + Logger.d(result.exception) { "Failed to load MCP connection status: ${result.message}" } } is Result.Loading -> { /* no-op */ } } diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/MemoryManagementDelegate.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/MemoryManagementDelegate.kt similarity index 96% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/MemoryManagementDelegate.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/MemoryManagementDelegate.kt index 1248ef2..98bbfba 100644 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/MemoryManagementDelegate.kt +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/MemoryManagementDelegate.kt @@ -8,7 +8,7 @@ import com.librechat.android.core.model.request.UpdateMemoryPreferencesRequest import com.librechat.android.core.model.request.UpdateMemoryRequest import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle import kotlinx.coroutines.launch -import timber.log.Timber +import co.touchlab.kermit.Logger /** * Handles memory CRUD operations and memory preferences. @@ -25,7 +25,7 @@ class MemoryManagementDelegate( stateHandle.update { copy(memories = result.data) } } is Result.Error -> { - Timber.d(result.exception, "Failed to load memories: ${result.message}") + Logger.d(result.exception) { "Failed to load memories: ${result.message}" } } is Result.Loading -> { /* no-op */ } } diff --git a/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsContract.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsContract.kt new file mode 100644 index 0000000..8cfe7a5 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsContract.kt @@ -0,0 +1,30 @@ +package com.librechat.android.feature.settings.viewmodel.delegate + +import com.librechat.android.core.model.speech.TtsVoice + +interface SpeechSettingsContract { + fun loadVoices() + fun loadDeviceVoices() + fun setAutoSendAfterStt(enabled: Boolean) + fun setAutoReadEnabled(enabled: Boolean) + fun selectVoice(voice: TtsVoice) + fun testVoice() + fun previewDeviceTts(text: String, rate: Float, pitch: Float, voiceName: String?) + fun previewServerTts(text: String, voice: String?, model: String?) + fun stopTtsPreview() + fun showSttDetailDialog() + fun dismissSttDetailDialog() + fun saveSttSettings(engine: String, language: String) + fun showTtsDetailDialog() + fun dismissTtsDetailDialog() + fun saveTtsSettings( + engine: String, + voice: String, + rate: Float, + pitch: Float, + deviceVoiceName: String, + caching: Boolean, + source: String, + ) + fun release() +} diff --git a/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsFactory.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsFactory.kt new file mode 100644 index 0000000..f20d28a --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/SpeechSettingsFactory.kt @@ -0,0 +1,7 @@ +package com.librechat.android.feature.settings.viewmodel.delegate + +import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle + +fun interface SpeechSettingsFactory { + fun create(stateHandle: SettingsStateHandle): SpeechSettingsContract +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/TwoFactorSecurityDelegate.kt b/feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/TwoFactorSecurityDelegate.kt similarity index 100% rename from feature/settings/src/main/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/TwoFactorSecurityDelegate.kt rename to feature/settings/src/commonMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/TwoFactorSecurityDelegate.kt diff --git a/feature/settings/src/iosMain/kotlin/com/librechat/android/feature/settings/di/SettingsPlatformModule.ios.kt b/feature/settings/src/iosMain/kotlin/com/librechat/android/feature/settings/di/SettingsPlatformModule.ios.kt new file mode 100644 index 0000000..6ef982e --- /dev/null +++ b/feature/settings/src/iosMain/kotlin/com/librechat/android/feature/settings/di/SettingsPlatformModule.ios.kt @@ -0,0 +1,85 @@ +package com.librechat.android.feature.settings.di + +import co.touchlab.kermit.Logger +import com.librechat.android.feature.settings.util.ContentReader +import com.librechat.android.feature.settings.util.PlatformCacheCleaner +import com.librechat.android.feature.settings.viewmodel.delegate.IosSpeechSettingsDelegate +import com.librechat.android.feature.settings.viewmodel.delegate.SpeechSettingsFactory +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import org.koin.core.module.Module +import org.koin.dsl.bind +import org.koin.dsl.module +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSData +import platform.Foundation.NSFileManager +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask +import platform.Foundation.dataWithContentsOfURL +import platform.posix.memcpy + +actual val settingsPlatformModule: Module = module { + single { + @OptIn(ExperimentalForeignApi::class) + object : ContentReader { + override fun readBytes(uri: Any): ByteArray? { + val nsUrl = uri as? NSURL + if (nsUrl == null) { + Logger.w("SettingsContentReader") { "readBytes called with non-NSURL: ${uri::class}" } + return null + } + val data = NSData.dataWithContentsOfURL(nsUrl) + if (data == null) { + Logger.w("SettingsContentReader") { "Failed to read data from: $nsUrl" } + return null + } + val size = data.length.toInt() + if (size == 0) return ByteArray(0) + val result = ByteArray(size) + result.usePinned { pinned -> + memcpy(pinned.addressOf(0), data.bytes, data.length) + } + return result + } + } + } bind ContentReader::class + single { + @OptIn(ExperimentalForeignApi::class) + object : PlatformCacheCleaner { + override fun clearCache() { + try { + val paths = NSSearchPathForDirectoriesInDomains( + NSCachesDirectory, + NSUserDomainMask, + true, + ) + val cachePath = paths.firstOrNull() as? String + if (cachePath == null) { + Logger.w("CacheCleaner") { "Could not resolve caches directory" } + return + } + val fm = NSFileManager.defaultManager + val contents = fm.contentsOfDirectoryAtPath(cachePath, null) as? List<*> + contents?.forEach { item -> + val name = item as? String ?: return@forEach + fm.removeItemAtPath("$cachePath/$name", null) + } + Logger.i("CacheCleaner") { "Cleared iOS caches directory" } + } catch (e: Exception) { + Logger.w(e) { "Failed to clear caches" } + } + } + } + } bind PlatformCacheCleaner::class + single { + SpeechSettingsFactory { stateHandle -> + IosSpeechSettingsDelegate( + stateHandle = stateHandle, + speechRepository = get(), + settingsDataStore = get(), + ) + } + } +} diff --git a/feature/settings/src/iosMain/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.ios.kt b/feature/settings/src/iosMain/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.ios.kt new file mode 100644 index 0000000..6a6d771 --- /dev/null +++ b/feature/settings/src/iosMain/kotlin/com/librechat/android/feature/settings/screen/AvatarUploadDialog.ios.kt @@ -0,0 +1,26 @@ +package com.librechat.android.feature.settings.screen + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal actual fun AvatarUploadDialog( + currentAvatarUrl: String?, + isUploading: Boolean, + onPickImage: (Any) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier, +) { + // iOS: placeholder dialog — image picker not yet implemented + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Update Avatar") }, + text = { Text("Avatar upload is not yet available on iOS.") }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("OK") } + }, + ) +} diff --git a/feature/settings/src/iosMain/kotlin/com/librechat/android/feature/settings/util/PlatformClipboard.ios.kt b/feature/settings/src/iosMain/kotlin/com/librechat/android/feature/settings/util/PlatformClipboard.ios.kt new file mode 100644 index 0000000..4b2b227 --- /dev/null +++ b/feature/settings/src/iosMain/kotlin/com/librechat/android/feature/settings/util/PlatformClipboard.ios.kt @@ -0,0 +1,7 @@ +package com.librechat.android.feature.settings.util + +import platform.UIKit.UIPasteboard + +actual fun copyToClipboard(text: String, label: String) { + UIPasteboard.generalPasteboard.string = text +} diff --git a/feature/settings/src/iosMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/IosSpeechSettingsDelegate.kt b/feature/settings/src/iosMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/IosSpeechSettingsDelegate.kt new file mode 100644 index 0000000..591697b --- /dev/null +++ b/feature/settings/src/iosMain/kotlin/com/librechat/android/feature/settings/viewmodel/delegate/IosSpeechSettingsDelegate.kt @@ -0,0 +1,234 @@ +package com.librechat.android.feature.settings.viewmodel.delegate + +import co.touchlab.kermit.Logger +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.datastore.SettingsDataStore +import com.librechat.android.core.data.repository.SpeechRepository +import com.librechat.android.core.model.speech.TtsVoice +import com.librechat.android.feature.settings.screen.DeviceVoiceInfo +import com.librechat.android.feature.settings.viewmodel.SettingsStateHandle +import kotlinx.coroutines.launch +import platform.AVFAudio.AVSpeechBoundary +import platform.AVFAudio.AVSpeechSynthesisVoice +import platform.AVFAudio.AVSpeechSynthesizer +import platform.AVFAudio.AVSpeechSynthesizerDelegateProtocol +import platform.AVFAudio.AVSpeechUtterance +import platform.darwin.NSObject + +private const val DEFAULT_SPEECH_RATE = 0.5f +private const val MIN_SPEECH_RATE = 0.0f +private const val MAX_SPEECH_RATE = 1.0f + +class IosSpeechSettingsDelegate( + private val stateHandle: SettingsStateHandle, + private val speechRepository: SpeechRepository, + private val settingsDataStore: SettingsDataStore, +) : SpeechSettingsContract { + + private val synthesizer = AVSpeechSynthesizer() + + private val synthesizerDelegate = object : NSObject(), AVSpeechSynthesizerDelegateProtocol { + @kotlinx.cinterop.ObjCSignatureOverride + override fun speechSynthesizer( + synthesizer: AVSpeechSynthesizer, + didFinishSpeechUtterance: AVSpeechUtterance, + ) { + stateHandle.update { copy(isTtsPreviewPlaying = false) } + } + + @kotlinx.cinterop.ObjCSignatureOverride + override fun speechSynthesizer( + synthesizer: AVSpeechSynthesizer, + didCancelSpeechUtterance: AVSpeechUtterance, + ) { + stateHandle.update { copy(isTtsPreviewPlaying = false) } + } + } + + init { + synthesizer.delegate = synthesizerDelegate + } + + override fun loadVoices() { + stateHandle.scope.launch { + when (val result = speechRepository.getVoices()) { + is Result.Success -> { + stateHandle.update { copy(availableVoices = result.data) } + } + is Result.Error -> { + Logger.d(result.exception) { "Failed to load voices: ${result.message}" } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + override fun loadDeviceVoices() { + val voices = AVSpeechSynthesisVoice.speechVoices() + .filterIsInstance() + .map { voice -> + DeviceVoiceInfo( + name = voice.name, + locale = voice.language, + ) + } + .sortedBy { it.name } + stateHandle.update { copy(availableDeviceVoices = voices) } + } + + override fun setAutoSendAfterStt(enabled: Boolean) { + stateHandle.scope.launch { + settingsDataStore.setAutoSendAfterStt(enabled) + } + } + + override fun setAutoReadEnabled(enabled: Boolean) { + stateHandle.scope.launch { + settingsDataStore.setAutoReadEnabled(enabled) + } + } + + override fun selectVoice(voice: TtsVoice) { + stateHandle.scope.launch { + settingsDataStore.setSelectedVoiceId(voice.id) + } + } + + override fun testVoice() { + val voice = stateHandle.state.selectedVoice + stateHandle.scope.launch { + val result = speechRepository.synthesizeSpeech( + text = "This is a test of the selected voice.", + voice = voice?.id, + ) + when (result) { + is Result.Success -> { + // Server returns audio bytes; fall back to device TTS for playback + previewDeviceTts("This is a test of the selected voice.", 1.0f, 1.0f, null) + } + is Result.Error -> { + stateHandle.update { copy(error = result.message ?: "Voice test failed") } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + override fun previewDeviceTts(text: String, rate: Float, pitch: Float, voiceName: String?) { + stopTtsPreview() + stateHandle.update { copy(isTtsPreviewPlaying = true) } + + val utterance = AVSpeechUtterance.speechUtteranceWithString(text) + utterance.rate = (rate * DEFAULT_SPEECH_RATE).coerceIn(MIN_SPEECH_RATE, MAX_SPEECH_RATE) + utterance.pitchMultiplier = pitch.coerceIn(0.5f, 2.0f) + + if (!voiceName.isNullOrBlank()) { + val targetVoice = AVSpeechSynthesisVoice.speechVoices() + .filterIsInstance() + .find { it.name == voiceName } + if (targetVoice != null) { + utterance.voice = targetVoice + } + } else { + utterance.voice = AVSpeechSynthesisVoice.voiceWithLanguage(null) + } + + synthesizer.speakUtterance(utterance) + } + + override fun previewServerTts(text: String, voice: String?, model: String?) { + stopTtsPreview() + stateHandle.update { copy(isTtsPreviewPlaying = true) } + stateHandle.scope.launch { + val result = speechRepository.synthesizeSpeech( + text = text, + voice = voice, + model = model, + ) + when (result) { + is Result.Success -> { + // Server returns audio bytes; fall back to device TTS for playback + previewDeviceTts(text, 1.0f, 1.0f, null) + } + is Result.Error -> { + stateHandle.update { + copy( + isTtsPreviewPlaying = false, + error = result.message ?: "Voice preview failed", + ) + } + } + is Result.Loading -> { /* no-op */ } + } + } + } + + override fun stopTtsPreview() { + if (synthesizer.isSpeaking()) { + synthesizer.stopSpeakingAtBoundary(AVSpeechBoundary.AVSpeechBoundaryImmediate) + } + stateHandle.update { copy(isTtsPreviewPlaying = false) } + } + + override fun showSttDetailDialog() { + stateHandle.update { copy(showSttDetailDialog = true) } + } + + override fun dismissSttDetailDialog() { + stateHandle.update { copy(showSttDetailDialog = false) } + } + + override fun saveSttSettings(engine: String, language: String) { + stateHandle.update { + copy(sttEngine = engine, sttLanguage = language, showSttDetailDialog = false) + } + stateHandle.scope.launch { + settingsDataStore.setSttEngine(engine) + settingsDataStore.setSttLanguage(language) + } + } + + override fun showTtsDetailDialog() { + stateHandle.update { copy(showTtsDetailDialog = true) } + } + + override fun dismissTtsDetailDialog() { + stateHandle.update { copy(showTtsDetailDialog = false) } + } + + override fun saveTtsSettings( + engine: String, + voice: String, + rate: Float, + pitch: Float, + deviceVoiceName: String, + caching: Boolean, + source: String, + ) { + stateHandle.update { + copy( + ttsEngine = engine, + ttsVoice = voice, + ttsCaching = caching, + ttsSource = source, + showTtsDetailDialog = false, + ) + } + stateHandle.scope.launch { + settingsDataStore.setTtsSource(source) + settingsDataStore.setTtsSpeechRate(rate) + settingsDataStore.setTtsPitch(pitch) + settingsDataStore.setTtsVoiceName(deviceVoiceName) + settingsDataStore.setTtsEngine(engine) + settingsDataStore.setTtsVoice(voice) + settingsDataStore.setTtsCaching(caching) + } + } + + override fun release() { + if (synthesizer.isSpeaking()) { + synthesizer.stopSpeakingAtBoundary(AVSpeechBoundary.AVSpeechBoundaryImmediate) + } + synthesizer.delegate = null + } +} diff --git a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/SettingsNavigation.kt deleted file mode 100644 index 0af9902..0000000 --- a/feature/settings/src/main/kotlin/com/librechat/android/feature/settings/navigation/SettingsNavigation.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.librechat.android.feature.settings.navigation - -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.librechat.android.core.ui.components.ScreenTransitionWrapper -import com.librechat.android.feature.settings.screen.AccountSettingsScreen -import com.librechat.android.feature.settings.screen.ApiKeysScreen -import com.librechat.android.feature.settings.screen.ChatSettingsScreen -import com.librechat.android.feature.settings.screen.DataSettingsScreen -import com.librechat.android.feature.settings.screen.GeneralSettingsScreen -import com.librechat.android.feature.settings.screen.PresetManagerScreen -import com.librechat.android.feature.settings.screen.SharedLinksScreen -import com.librechat.android.feature.settings.screen.TabbedSettingsScreen -import com.librechat.android.feature.settings.viewmodel.SettingsViewModel -import org.koin.compose.viewmodel.koinViewModel - -const val SETTINGS_TABBED_ROUTE = "settings" -const val SETTINGS_GENERAL_ROUTE = "settings/general" -const val SETTINGS_CHAT_ROUTE = "settings/chat" -const val SETTINGS_ACCOUNT_ROUTE = "settings/account" -const val SETTINGS_DATA_ROUTE = "settings/data" -const val SHARED_LINKS_ROUTE = "settings/shared-links" -const val PRESET_MANAGER_ROUTE = "settings/presets" -const val API_KEYS_ROUTE = "settings/api_keys" - -fun NavGraphBuilder.settingsGraph( - onLogout: () -> Unit, - onNavigateBack: () -> Unit, - onNavigateToArchived: () -> Unit = {}, - onNavigateToSharedLinks: () -> Unit = {}, - onNavigateBackFromSharedLinks: () -> Unit = {}, - onNavigateToPresets: () -> Unit = {}, - onNavigateBackFromPresets: () -> Unit = {}, - onNavigateToApiKeys: () -> Unit = {}, - onNavigateBackFromApiKeys: () -> Unit = {}, -) { - // Primary tabbed settings screen - composable(SETTINGS_TABBED_ROUTE) { - ScreenTransitionWrapper(transition) { - TabbedSettingsScreen( - onNavigateBack = onNavigateBack, - onLogout = onLogout, - onNavigateToArchived = onNavigateToArchived, - onNavigateToSharedLinks = onNavigateToSharedLinks, - onNavigateToPresets = onNavigateToPresets, - onNavigateToApiKeys = onNavigateToApiKeys, - ) - } - } - - // Individual category routes kept for backward compatibility / deep linking - composable(SETTINGS_GENERAL_ROUTE) { - ScreenTransitionWrapper(transition) { - GeneralSettingsScreen( - onNavigateBack = onNavigateBack, - ) - } - } - composable(SETTINGS_CHAT_ROUTE) { - ScreenTransitionWrapper(transition) { - ChatSettingsScreen( - onNavigateBack = onNavigateBack, - onNavigateToPresets = onNavigateToPresets, - ) - } - } - composable(SETTINGS_ACCOUNT_ROUTE) { - ScreenTransitionWrapper(transition) { - AccountSettingsScreen( - onLogout = onLogout, - onNavigateBack = onNavigateBack, - onNavigateToApiKeys = onNavigateToApiKeys, - ) - } - } - composable(SETTINGS_DATA_ROUTE) { - ScreenTransitionWrapper(transition) { - DataSettingsScreen( - onNavigateBack = onNavigateBack, - onNavigateToArchived = onNavigateToArchived, - onNavigateToSharedLinks = onNavigateToSharedLinks, - ) - } - } - composable(SHARED_LINKS_ROUTE) { - val viewModel: SettingsViewModel = koinViewModel() - val uiState = viewModel.uiState.collectAsStateWithLifecycle() - - // Load shared links when this screen is first shown - androidx.compose.runtime.LaunchedEffect(Unit) { - viewModel.loadSharedLinks() - } - - ScreenTransitionWrapper(transition) { - SharedLinksScreen( - links = uiState.value.sharedLinks, - isLoading = uiState.value.isSharedLinksLoading, - hasNextPage = uiState.value.sharedLinksHasNextPage, - serverUrl = uiState.value.serverUrl, - onLoadMore = viewModel::loadMoreSharedLinks, - onToggleVisibility = viewModel::toggleSharedLinkVisibility, - onDelete = viewModel::deleteSharedLink, - onNavigateBack = onNavigateBackFromSharedLinks, - ) - } - } - composable(PRESET_MANAGER_ROUTE) { - ScreenTransitionWrapper(transition) { - PresetManagerScreen( - onNavigateBack = onNavigateBackFromPresets, - ) - } - } - composable(API_KEYS_ROUTE) { - ScreenTransitionWrapper(transition) { - ApiKeysScreen( - onNavigateBack = onNavigateBackFromApiKeys, - ) - } - } -} diff --git a/gradle.properties b/gradle.properties index fe8ddf5..c5cf9f6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,3 +4,8 @@ kotlin.code.style=official android.nonTransitiveRClass=true org.gradle.parallel=true org.gradle.caching=true +org.gradle.configuration-cache=true +# AGP 9: disable built-in Kotlin to avoid conflict with explicit KGP +android.builtInKotlin=false +# AGP 9: use legacy DSL for KMP modules (until AGP 10 migration) +android.newDsl=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 661d581..139aea4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,28 +1,33 @@ [versions] # Core Android agp = "8.7.3" -kotlin = "2.1.0" -ksp = "2.1.0-1.0.29" +kotlin = "2.1.20" +ksp = "2.1.20-1.0.31" compose-bom = "2024.12.01" +compose-multiplatform = "1.8.0" material3 = "1.3.1" activity-compose = "1.9.3" navigation-compose = "2.8.5" lifecycle = "2.8.7" +lifecycle-kmp = "2.8.7" +navigation-kmp = "2.8.5" # DI -koin = "4.1.0" +koin = "4.1.1" # Network -ktor = "3.0.3" +ktor = "3.1.3" kotlinx-serialization = "1.7.3" +kotlinx-datetime = "0.6.1" # Local Data -room = "2.6.1" +room = "2.7.1" # Upgraded for KMP support (was 2.6.1) +sqlite = "2.5.0" # Must match Kotlin 2.1.x ABI; 2.6.x requires Kotlin 2.2.0 datastore = "1.1.1" -security-crypto = "1.1.0-alpha06" +security-crypto = "1.1.0" # Async -coroutines = "1.9.0" +coroutines = "1.10.2" # Paging paging = "3.3.5" @@ -37,17 +42,22 @@ markdown-renderer = "0.27.0" media3 = "1.2.0" # Image Loading -coil = "2.7.0" +coil3 = "3.1.0" # Logging -timber = "5.0.1" +kermit = "2.0.6" + +# KMP / iOS +skie = "0.10.10" # Testing junit = "4.13.2" -turbine = "1.2.0" +turbine = "1.2.1" mockk = "1.13.13" truth = "1.4.4" espresso = "3.6.1" +android-test-runner = "1.6.2" +android-test-ext = "1.2.1" # Code Quality detekt = "1.23.8" @@ -55,12 +65,6 @@ ktlint = "12.1.2" compose-lint-checks = "1.4.2" kover = "0.9.8" -# Fuzzy Search -fuzzywuzzy = "1.4.0" - -# YAML -snakeyaml-engine = "2.8" - # Build desugar = "2.1.4" @@ -83,31 +87,42 @@ navigation-compose = { group = "androidx.navigation", name = "navigation-compose # Lifecycle lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +lifecycle-runtime-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-kmp" } +lifecycle-viewmodel-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-kmp" } +navigation-compose-kmp = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref = "navigation-kmp" } # Koin koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" } -koin-core = { group = "io.insert-koin", name = "koin-core" } -koin-android = { group = "io.insert-koin", name = "koin-android" } -koin-compose = { group = "io.insert-koin", name = "koin-compose" } -koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmodel" } -koin-compose-viewmodel-navigation = { group = "io.insert-koin", name = "koin-compose-viewmodel-navigation" } -koin-test = { group = "io.insert-koin", name = "koin-test" } +koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" } +koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } +koin-compose = { group = "io.insert-koin", name = "koin-compose", version.ref = "koin" } +koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmodel", version.ref = "koin" } +koin-compose-viewmodel-navigation = { group = "io.insert-koin", name = "koin-compose-viewmodel-navigation", version.ref = "koin" } +koin-test = { group = "io.insert-koin", name = "koin-test", version.ref = "koin" } # Ktor ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } +ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } # Kotlinx Serialization kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +# Kotlinx Datetime +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinx-datetime" } + # Room room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } +room-paging = { group = "androidx.room", name = "room-paging", version.ref = "room" } +sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled", version.ref = "sqlite" } # DataStore datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } @@ -121,6 +136,7 @@ coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines- # Paging paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" } paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } +paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" } # Browser browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } @@ -135,15 +151,18 @@ media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "medi media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" } # Image Loading -coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +coil3-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil3" } +coil3-network-ktor = { group = "io.coil-kt.coil3", name = "coil-network-ktor3", version.ref = "coil3" } # Logging -timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } +kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit" } # Gradle Plugins (for build-logic) android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +kotlin-multiplatform-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } compose-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } +compose-multiplatform-gradlePlugin = { group = "org.jetbrains.compose", name = "compose-gradle-plugin", version.ref = "compose-multiplatform" } ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "symbol-processing-gradle-plugin", version.ref = "ksp" } room-gradlePlugin = { group = "androidx.room", name = "room-gradle-plugin", version.ref = "room" } @@ -155,12 +174,6 @@ leakcanary = { group = "com.squareup.leakcanary", name = "leakcanary-android", v compose-lint-checks = { group = "com.slack.lint.compose", name = "compose-lint-checks", version.ref = "compose-lint-checks" } kover-gradlePlugin = { group = "org.jetbrains.kotlinx", name = "kover-gradle-plugin", version.ref = "kover" } -# Fuzzy Search -fuzzywuzzy = { group = "me.xdrop", name = "fuzzywuzzy", version.ref = "fuzzywuzzy" } - -# YAML -snakeyaml-engine = { group = "org.snakeyaml", name = "snakeyaml-engine", version.ref = "snakeyaml-engine" } - # Desugar desugar-jdk = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar" } @@ -169,6 +182,8 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } +android-test-runner = { group = "androidx.test", name = "runner", version.ref = "android-test-runner" } +android-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "android-test-ext" } compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" } compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } @@ -208,10 +223,13 @@ testing = [ android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } room = { id = "androidx.room", version.ref = "room" } +skie = { id = "co.touchlab.skie", version.ref = "skie" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 61285a6..9bbc975 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c8..c61a118 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index adff685..faf9300 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015 the original authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -114,6 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -171,6 +172,7 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -210,7 +212,8 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index e509b2d..9b42019 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,10 +70,11 @@ goto fail :execute @rem Setup the command line +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell diff --git a/iosApp/README.md b/iosApp/README.md new file mode 100644 index 0000000..8aca837 --- /dev/null +++ b/iosApp/README.md @@ -0,0 +1,75 @@ +# LibreChat iOS App + +Compose Multiplatform iOS client for LibreChat, sharing business logic and UI with the Android app via Kotlin Multiplatform. + +## Project Structure + +``` +iosApp/ + iosApp/ + iOSApp.swift — SwiftUI App entry point (initializes Koin DI) + RootView.swift — Auth state router (login vs chat) + ComposeView.swift — CMP UIViewController wrapper + LoginView.swift — Server URL + email/password login screen + RegisterView.swift — Account registration screen + ForgotPasswordView.swift — Password reset screen + FilesView.swift — File management screen + ChatStreamView.swift — SSE streaming display with stream controls + KoinHelper.swift — Swift-side Koin dependency resolver + ContentView.swift — Placeholder view (for Previews) + SharedFrameworkTest.swift — Compile-time smoke test for KMP + SKIE bridging + Info.plist — Bundle config (ID: com.librechat.ios, URL scheme: librechat) +``` + +## Architecture + +- **Shared Framework**: The `:shared` Gradle module exports `core:common`, `core:model`, `core:network`, and `core:data` as a single `Shared.framework` for iOS. All feature modules and `core:ui` are included for Compose Multiplatform screen sharing. +- **SKIE**: Enhances Kotlin→Swift interop automatically: + - `sealed interface StreamEvent` → Swift exhaustive enum via `onEnum(of:)` + - `Flow` → `AsyncSequence` (e.g., SSE streaming, connectivity observer) + - `suspend fun` → `async throws` (e.g., `sdk.login()`) +- **DI**: Koin is initialized in `iOSApp.init()` via `IosKoinHelperKt.startIosKoin()` +- **Crash Reporting**: `installCrashReporting()` sets up a Kotlin/Native unhandled exception hook that logs via Kermit + NSLog and raises as NSException for readable iOS crash logs. +- **Platform Impls**: + - `IosTokenDataStore` (`core/data/src/iosMain/`) — Keychain-backed token storage via Security.framework + - `ServerDataStore` (`core/data/src/commonMain/`) — DataStore-backed server URL (shared with Android) + - `IosConnectivityObserver` (`core/common/src/iosMain/`) — NWPathMonitor via C-level `nw_path_monitor_*` APIs + - `IosSharedModule` (`shared/src/iosMain/`) — Koin module wiring Darwin Ktor engine + all platform deps + +## Building + +### Prerequisites +- Xcode 15.0+ with iOS 16.0+ SDK +- JDK 17 (for Gradle/Kotlin compilation) + +### Build the Shared Framework +```bash +./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 +``` + +### Open in Xcode +```bash +open iosApp/iosApp.xcodeproj +``` + +Build with scheme `iosApp` targeting an iOS Simulator. The Gradle build phase in the Xcode project automatically builds the shared framework. + +## Info.plist Permissions + +The following keys are configured in `Info.plist`: +- `NSMicrophoneUsageDescription` — Microphone for voice input +- `NSSpeechRecognitionUsageDescription` — Speech-to-text +- `NSCameraUsageDescription` — Camera for photo capture +- `NSPhotoLibraryUsageDescription` — Photo library access +- `CADisableMinimumFrameDurationOnPhone` — 120Hz ProMotion support + +## CI + +iOS builds run on `macos-14` (Apple Silicon) via `.github/workflows/ios.yml`: +- Builds the shared KMP framework +- Builds the iOS app via `xcodebuild` (scheme: `iosApp`, no signing) +- Runs KMP unit tests on the iOS simulator target + +## URL Scheme + +The app registers the `librechat://` URL scheme for deep linking (conversations, OAuth callbacks), matching the Android app's behavior. diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4f7d6d7 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,350 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + AAA00001 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00001; }; + AAA00002 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00002; }; + AAA00003 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00003; }; + AAA00004 /* ChatStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00004; }; + AAA00005 /* KoinHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00005; }; + AAA00006 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00006; }; + AAA00007 /* SharedFrameworkTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00007; }; + AAA00008 /* RegisterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00008A; }; + AAA00009 /* ForgotPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00009; }; + AAA0000A /* FilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB0000A; }; + AAA0000B /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB0000B; }; + AAA0000C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BBB0000C; }; + AAA00010 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BBB00010; }; + AAA00011 /* Shared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BBB00010; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + CCC00001 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + AAA00011 /* Shared.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + SSS00001 /* Copy Compose Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Copy Compose Resources"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Aggregate and copy Compose Multiplatform resources into the app bundle.\n# Runs the Gradle aggregation task to ensure resources from all feature\n# modules are collected, then copies them into the .app bundle where\n# the Compose resource reader expects them at runtime.\n\nif [ \"$CONFIGURATION\" = \"Debug\" ]; then\n ARCH_DIR=\"iosSimulatorArm64\"\n GRADLE_TASK=\":shared:iosSimulatorArm64AggregateResources\"\nelse\n ARCH_DIR=\"iosArm64\"\n GRADLE_TASK=\":shared:iosArm64AggregateResources\"\nfi\n\nPROJECT_ROOT=\"$SRCROOT/..\"\nSRC=\"$PROJECT_ROOT/shared/build/kotlin-multiplatform-resources/aggregated-resources/$ARCH_DIR/composeResources\"\nDST=\"$BUILT_PRODUCTS_DIR/$PRODUCT_NAME.app/compose-resources/composeResources\"\n\n# Run Gradle aggregation to ensure resources are up to date\necho \"Running $GRADLE_TASK...\"\ncd \"$PROJECT_ROOT\" && ./gradlew \"$GRADLE_TASK\" --quiet 2>&1\nGRADLE_EXIT=$?\nif [ $GRADLE_EXIT -ne 0 ]; then\n echo \"error: Gradle task $GRADLE_TASK failed with exit code $GRADLE_EXIT\"\n exit $GRADLE_EXIT\nfi\n\n# Clean stale resources before copying fresh ones\nif [ -d \"$DST\" ]; then\n rm -rf \"$DST\"\nfi\n\nif [ -d \"$SRC\" ]; then\n mkdir -p \"$DST\"\n cp -R \"$SRC/\" \"$DST/\"\n echo \"Copied Compose resources from $SRC to $DST\"\nelse\n echo \"error: Compose resources not found at $SRC after Gradle aggregation\"\n exit 1\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXResourcesBuildPhase section */ + RRR00001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AAA0000C /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXFileReference section */ + BBB00001 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; + BBB00002 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + BBB00003 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; + BBB00004 /* ChatStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStreamView.swift; sourceTree = ""; }; + BBB00005 /* KoinHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinHelper.swift; sourceTree = ""; }; + BBB00006 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + BBB00007 /* SharedFrameworkTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFrameworkTest.swift; sourceTree = ""; }; + BBB00008 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + BBB00008A /* RegisterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterView.swift; sourceTree = ""; }; + BBB00009 /* ForgotPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgotPasswordView.swift; sourceTree = ""; }; + BBB0000A /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = ""; }; + BBB0000B /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; + BBB0000C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + BBB00010 /* Shared.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Shared.framework; path = "../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework"; sourceTree = ""; }; + DDD00001 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + EEE00001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AAA00010 /* Shared.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + FFF00001 = { + isa = PBXGroup; + children = ( + FFF00002 /* iosApp */, + FFF00003 /* Frameworks */, + FFF00004 /* Products */, + ); + sourceTree = ""; + }; + FFF00002 /* iosApp */ = { + isa = PBXGroup; + children = ( + BBB00001 /* iOSApp.swift */, + BBB00002 /* RootView.swift */, + BBB00003 /* LoginView.swift */, + BBB00004 /* ChatStreamView.swift */, + BBB00005 /* KoinHelper.swift */, + BBB00006 /* ContentView.swift */, + BBB00007 /* SharedFrameworkTest.swift */, + BBB00008A /* RegisterView.swift */, + BBB00009 /* ForgotPasswordView.swift */, + BBB0000A /* FilesView.swift */, + BBB0000B /* ComposeView.swift */, + BBB0000C /* Assets.xcassets */, + BBB00008 /* Info.plist */, + ); + path = iosApp; + sourceTree = ""; + }; + FFF00003 /* Frameworks */ = { + isa = PBXGroup; + children = ( + BBB00010 /* Shared.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + FFF00004 /* Products */ = { + isa = PBXGroup; + children = ( + DDD00001 /* iosApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + GGG00001 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = HHH00003 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + III00001 /* Sources */, + RRR00001 /* Resources */, + EEE00001 /* Frameworks */, + CCC00001 /* Embed Frameworks */, + SSS00001 /* Copy Compose Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iosApp; + productName = iosApp; + productReference = DDD00001 /* iosApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + JJJ00001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = HHH00001 /* Build configuration list for PBXProject "iosApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = FFF00001; + productRefGroup = FFF00004 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + GGG00001 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + III00001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AAA00001 /* iOSApp.swift in Sources */, + AAA00002 /* RootView.swift in Sources */, + AAA00003 /* LoginView.swift in Sources */, + AAA00004 /* ChatStreamView.swift in Sources */, + AAA00005 /* KoinHelper.swift in Sources */, + AAA00006 /* ContentView.swift in Sources */, + AAA00007 /* SharedFrameworkTest.swift in Sources */, + AAA00008 /* RegisterView.swift in Sources */, + AAA00009 /* ForgotPasswordView.swift in Sources */, + AAA0000A /* FilesView.swift in Sources */, + AAA0000B /* ComposeView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + KKK00001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + KKK00002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_OPTIMIZATION_LEVEL = s; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + KKK00003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/../shared/build/bin/iosSimulatorArm64/debugFramework", + ); + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = iosApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lsqlite3", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.librechat.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + KKK00004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/../shared/build/bin/iosSimulatorArm64/releaseFramework", + ); + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = iosApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lsqlite3", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.librechat.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + HHH00001 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + KKK00001 /* Debug */, + KKK00002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + HHH00003 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + KKK00003 /* Debug */, + KKK00004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + + }; + rootObject = JJJ00001 /* Project object */; +} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..22bf61d --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "app_icon_1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..f0cce304 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/ChatStreamView.swift b/iosApp/iosApp/ChatStreamView.swift new file mode 100644 index 0000000..f7a9276 --- /dev/null +++ b/iosApp/iosApp/ChatStreamView.swift @@ -0,0 +1,266 @@ +import SwiftUI +import Shared + +struct ChatStreamView: View { + @ObservedObject var authState: AuthState + + @State private var streamOutput = "" + @State private var isStreaming = false + @State private var statusText = "Ready" + @State private var streamTask: Task? + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Status bar + HStack { + Circle() + .fill(isStreaming ? .green : .gray) + .frame(width: 8, height: 8) + Text(statusText) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text("Connected to \(authState.serverUrl)") + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(.systemGroupedBackground)) + + // Stream output + ScrollViewReader { proxy in + ScrollView { + Text(streamOutput.isEmpty ? "No stream data yet. Tap 'Test SSE Stream' to verify streaming works." : streamOutput) + .font(.system(.body, design: .monospaced)) + .foregroundColor(streamOutput.isEmpty ? .secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .id("bottom") + } + .onChange(of: streamOutput) { _ in + withAnimation { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + } + + Divider() + + // Controls + HStack(spacing: 12) { + Button { + testSseStream() + } label: { + Label("Test SSE Stream", systemImage: "antenna.radiowaves.left.and.right") + .font(.subheadline) + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + .disabled(isStreaming) + + if isStreaming { + Button { + stopStream() + } label: { + Label("Stop", systemImage: "stop.fill") + .font(.subheadline) + } + .buttonStyle(.bordered) + .tint(.red) + } + + Spacer() + + Button { + streamOutput = "" + statusText = "Ready" + } label: { + Image(systemName: "trash") + } + .disabled(streamOutput.isEmpty) + } + .padding() + } + .navigationTitle("LibreChat iOS") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Text("Signed in as \(authState.userName)") + Button("Sign Out", role: .destructive) { + signOut() + } + } label: { + Image(systemName: "person.circle") + } + } + } + } + } + + private func testSseStream() { + isStreaming = true + statusText = "Connecting..." + streamOutput += "[SSE Test] Starting stream connection...\n" + + let sdk = KoinHelper.sdk + + // For the SSE test, we use the ChatApi to start a minimal chat, + // then connect to the stream. This proves the full pipeline works. + streamTask = Task { + do { + // Start a chat request to get a streamId + // ChatRequest constructor parameters match the Kotlin/Native export + let request = ChatRequest( + text: "Hello! This is a test from the iOS app.", + conversationId: nil, + parentMessageId: "00000000-0000-0000-0000-000000000000", + endpoint: EModelEndpoint.agents, + endpointType: nil, + model: nil, + agentId: nil, + isContinued: false, + isEdited: false, + isRegenerate: false, + overrideParentMessageId: nil, + responseMessageId: nil, + temperature: nil, + topP: nil, + maxOutputTokens: nil, + maxContextTokens: nil, + system: nil, + reasoningEffort: nil, + effort: nil, + thinkingLevel: nil, + stop: nil, + tools: nil, + iconURL: nil, + greeting: nil, + spec: nil, + modelLabel: nil, + maxTokens: nil, + promptPrefix: nil, + chatGptLabel: nil, + resendFiles: nil, + imageDetail: nil, + key: nil, + extra: nil, + webSearch: nil, + files: nil, + addedConvo: nil, + ephemeralAgent: nil + ) + + await MainActor.run { + statusText = "Sending chat request..." + streamOutput += "[SSE Test] Sending chat request to agents endpoint...\n" + } + + let startResponse = try await sdk.chatApi.startChat(endpoint: "agents", request: request) + let streamPath = "api/agents/chat/stream/\(startResponse.conversationId)" + + await MainActor.run { + statusText = "Streaming..." + streamOutput += "[SSE Test] Got streamId: \(startResponse.conversationId)\n" + streamOutput += "[SSE Test] Connecting to SSE stream...\n\n" + } + + // Connect to the SSE stream — SKIE converts Flow to AsyncSequence + // Note: SKIE's Flow→AsyncSequence is non-throwing. Any flow exceptions + // are caught by the outer try/catch in SseClient.connect() and emitted + // as StreamEvent.Error events. The Kotlin flow MUST NOT throw or + // SKIE's iterator will call fatalError. + let streamingClient = KoinHelper.streamingHttpClient + let eventFlow = sdk.sseClient.connect( + client: streamingClient, + streamPath: streamPath, + resume: false, + connectivityFlow: nil + ) + + // Iterate over the AsyncSequence (SKIE-bridged Flow) + for await event in eventFlow { + if Task.isCancelled { break } + + await MainActor.run { + handleStreamEvent(event) + } + } + + await MainActor.run { + isStreaming = false + statusText = "Stream complete" + streamOutput += "\n[SSE Test] Stream ended.\n" + } + } catch { + await MainActor.run { + isStreaming = false + statusText = "Error" + streamOutput += "\n[SSE Error] \(error.localizedDescription)\n" + } + } + } + } + + private func handleStreamEvent(_ event: StreamEvent) { + // SKIE converts sealed interface to Swift enum via onEnum(of:) + switch onEnum(of: event) { + case .contentDelta(let delta): + streamOutput += delta.chunk + + case .thinkingDelta(let thinking): + streamOutput += "[thinking] \(thinking.chunk)" + + case .toolCallStart(let toolCall): + streamOutput += "\n[tool: \(toolCall.toolName)] Starting...\n" + + case .toolCallComplete(let toolCall): + streamOutput += "[tool: \(toolCall.toolCallId)] Complete\n" + + case .created(let created): + streamOutput += "[created] conversationId: \(created.conversationId)\n" + + case .retrying(let retry): + statusText = "Retrying (\(retry.attempt)/\(retry.maxAttempts))..." + streamOutput += "[retry] Attempt \(retry.attempt) of \(retry.maxAttempts)\n" + + case .error(let error): + statusText = "Error" + streamOutput += "\n[error] \(error.message)\n" + + case .final(_): + statusText = "Complete" + streamOutput += "\n[final] Stream complete.\n" + + case .sync(let sync): + streamOutput += "[sync] Received \(sync.aggregatedContent.count) content parts\n" + + case .step(let step): + streamOutput += "[step: \(step.stepType)] \(step.stepData)\n" + + case .attachmentCreated(let attachment): + streamOutput += "[attachment] \(attachment.filename)\n" + } + } + + private func stopStream() { + streamTask?.cancel() + streamTask = nil + isStreaming = false + statusText = "Stopped" + streamOutput += "\n[SSE Test] Stream cancelled by user.\n" + } + + private func signOut() { + Task { + try? await KoinHelper.sdk.tokenManager.clearTokens() + await MainActor.run { + authState.isLoggedIn = false + authState.userName = "" + } + } + } +} diff --git a/iosApp/iosApp/ComposeView.swift b/iosApp/iosApp/ComposeView.swift new file mode 100644 index 0000000..2b03378 --- /dev/null +++ b/iosApp/iosApp/ComposeView.swift @@ -0,0 +1,21 @@ +import SwiftUI +import Shared + +/// Bridges the Compose Multiplatform UI into SwiftUI. +/// +/// Wraps `MainViewControllerKt.MainViewController()` which renders +/// the full CMP navigation shell (Chat, Agents, Files, Settings tabs) +/// with all 80+ shared composables. +/// +/// Koin must be initialized before this view appears — handled in +/// `iOSApp.init()` via `IosKoinHelperKt.startIosKoin()`. +struct LibreChatComposeView: UIViewControllerRepresentable { + + func makeUIViewController(context: Context) -> UIViewController { + MainViewControllerKt.MainViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + // No dynamic updates needed — CMP manages its own state internally + } +} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift new file mode 100644 index 0000000..f51b2b5 --- /dev/null +++ b/iosApp/iosApp/ContentView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +/// Placeholder content view — the actual root is RootView. +/// Kept for Preview support. +struct ContentView: View { + var body: some View { + VStack(spacing: 16) { + Image(systemName: "message.fill") + .font(.system(size: 64)) + .foregroundColor(.blue) + Text("LibreChat iOS") + .font(.largeTitle) + .fontWeight(.bold) + Text("KMP + SKIE Integrated") + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/iosApp/iosApp/FilesView.swift b/iosApp/iosApp/FilesView.swift new file mode 100644 index 0000000..c900b9d --- /dev/null +++ b/iosApp/iosApp/FilesView.swift @@ -0,0 +1,366 @@ +import SwiftUI +import Shared + +struct FilesView: View { + @ObservedObject var authState: AuthState + + @State private var files: [FileObject] = [] + @State private var isLoading = true + @State private var isRefreshing = false + @State private var isUploading = false + @State private var errorMessage: String? + @State private var selectedFile: FileObject? + @State private var showFilePicker = false + @State private var deleteConfirmFile: FileObject? + + var body: some View { + NavigationStack { + Group { + if isLoading && files.isEmpty { + ProgressView("Loading files...") + } else if let error = errorMessage, files.isEmpty { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundColor(.orange) + Text(error) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + Button("Retry") { loadFiles() } + .buttonStyle(.borderedProminent) + } + .padding() + } else if files.isEmpty { + VStack(spacing: 16) { + Image(systemName: "folder") + .font(.system(size: 48)) + .foregroundColor(.secondary) + Text("No Files") + .font(.title3) + .fontWeight(.medium) + Text("Upload files to get started") + .foregroundColor(.secondary) + } + } else { + fileListView + } + } + .navigationTitle("Files") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showFilePicker = true + } label: { + Image(systemName: "plus") + } + .disabled(isUploading) + } + } + .refreshable { + await refreshFiles() + } + .fileImporter( + isPresented: $showFilePicker, + allowedContentTypes: [.data], + allowsMultipleSelection: false + ) { result in + handleFileImport(result) + } + .sheet(item: $selectedFile) { file in + FilePreviewSheet(file: file, serverUrl: authState.serverUrl) + } + .alert("Delete File?", isPresented: .init( + get: { deleteConfirmFile != nil }, + set: { if !$0 { deleteConfirmFile = nil } } + )) { + Button("Cancel", role: .cancel) { deleteConfirmFile = nil } + Button("Delete", role: .destructive) { + if let file = deleteConfirmFile { + deleteFile(file) + deleteConfirmFile = nil + } + } + } message: { + if let file = deleteConfirmFile { + Text("Are you sure you want to delete \"\(file.filename)\"? This cannot be undone.") + } + } + } + .task { loadFiles() } + } + + private var fileListView: some View { + List { + if isUploading { + HStack(spacing: 12) { + ProgressView() + Text("Uploading...") + .foregroundColor(.secondary) + } + } + + ForEach(files, id: \.fileId) { file in + FileRow(file: file) + .contentShape(Rectangle()) + .onTapGesture { + selectedFile = file + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + deleteConfirmFile = file + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + .listStyle(.plain) + } + + private func loadFiles() { + isLoading = true + errorMessage = nil + + Task { + do { + let fileRepo = KoinHelper.fileRepository + let result = try await fileRepo.getFiles() + + await MainActor.run { + isLoading = false + if let success = result as? ResultSuccess { + files = (success.data as? [FileObject]) ?? [] + } else if let error = result as? ResultError { + errorMessage = error.message ?? "Failed to load files" + } + } + } catch { + await MainActor.run { + isLoading = false + errorMessage = error.localizedDescription + } + } + } + } + + private func refreshFiles() async { + do { + let fileRepo = KoinHelper.fileRepository + let result = try await fileRepo.getFiles() + + await MainActor.run { + if let success = result as? ResultSuccess { + files = (success.data as? [FileObject]) ?? [] + } + } + } catch { + // Silently fail on refresh + } + } + + private func handleFileImport(_ result: Swift.Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + uploadFile(url) + case .failure(let error): + errorMessage = error.localizedDescription + } + } + + private func uploadFile(_ url: URL) { + isUploading = true + + Task { + do { + guard url.startAccessingSecurityScopedResource() else { + await MainActor.run { + isUploading = false + errorMessage = "Could not access file" + } + return + } + defer { url.stopAccessingSecurityScopedResource() } + + let data = try Data(contentsOf: url) + let filename = url.lastPathComponent + let mimeType = "application/octet-stream" // Simplified; could use UTType + + let fileRepo = KoinHelper.fileRepository + let bytes = KotlinByteArray(size: Int32(data.count)) + data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + for i in 0.. { + if let newFile = success.data { + files.insert(newFile, at: 0) + } + } else if let error = result as? ResultError { + errorMessage = error.message ?? "Upload failed" + } + } + } catch { + await MainActor.run { + isUploading = false + errorMessage = error.localizedDescription + } + } + } + } + + private func deleteFile(_ file: FileObject) { + Task { + do { + let entry = DeleteFileEntry(fileId: file.fileId, filepath: file.filepath) + let fileRepo = KoinHelper.fileRepository + let result = try await fileRepo.deleteFiles(files: [entry]) + + await MainActor.run { + if result is ResultSuccess { + files.removeAll { $0.fileId == file.fileId } + } else if let error = result as? ResultError { + errorMessage = error.message ?? "Failed to delete" + } + } + } catch { + await MainActor.run { + errorMessage = error.localizedDescription + } + } + } + } +} + +// MARK: - File Row + +struct FileRow: View { + let file: FileObject + + var body: some View { + HStack(spacing: 12) { + Image(systemName: iconName(for: file.type)) + .font(.title2) + .foregroundColor(.secondary) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(file.filename) + .lineLimit(1) + + HStack(spacing: 4) { + Text(formatSize(file.bytes)) + if let date = file.createdAt { + Text("·") + Text(date.prefix(10)) + } + } + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 4) + } + + private func iconName(for type: String) -> String { + if type.hasPrefix("image/") { return "photo" } + if type.hasPrefix("video/") { return "video" } + if type.hasPrefix("audio/") { return "music.note" } + if type == "application/pdf" { return "doc.richtext" } + return "doc" + } + + private func formatSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} + +// MARK: - File Preview Sheet + +struct FilePreviewSheet: View { + let file: FileObject + let serverUrl: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + VStack(spacing: 16) { + Image(systemName: iconName(for: file.type)) + .font(.system(size: 64)) + .foregroundColor(.blue) + + Text(file.filename) + .font(.title3) + .fontWeight(.medium) + .multilineTextAlignment(.center) + + VStack(alignment: .leading, spacing: 8) { + infoRow("Type", value: file.type) + infoRow("Size", value: formatSize(file.bytes)) + if let date = file.createdAt { + infoRow("Created", value: String(date.prefix(10))) + } + if let source = file.source { + infoRow("Source", value: source) + } + } + .padding() + .background(Color(.systemGroupedBackground)) + .cornerRadius(12) + + Spacer() + } + .padding() + .navigationTitle("File Details") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } + + private func infoRow(_ label: String, value: String) -> some View { + HStack { + Text(label) + .foregroundColor(.secondary) + Spacer() + Text(value) + .lineLimit(1) + } + } + + private func iconName(for type: String) -> String { + if type.hasPrefix("image/") { return "photo" } + if type.hasPrefix("video/") { return "video" } + if type.hasPrefix("audio/") { return "music.note" } + if type == "application/pdf" { return "doc.richtext" } + return "doc" + } + + private func formatSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} + +// MARK: - FileObject Identifiable conformance + +extension FileObject: @retroactive Identifiable { + public var id: String { fileId } +} diff --git a/iosApp/iosApp/ForgotPasswordView.swift b/iosApp/iosApp/ForgotPasswordView.swift new file mode 100644 index 0000000..8537f0f --- /dev/null +++ b/iosApp/iosApp/ForgotPasswordView.swift @@ -0,0 +1,131 @@ +import SwiftUI +import Shared + +struct ForgotPasswordView: View { + @Environment(\.dismiss) private var dismiss + + @State private var email = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var isSent = false + + var body: some View { + ScrollView { + VStack(spacing: 24) { + Image(systemName: "lock.rotation") + .font(.system(size: 56)) + .foregroundColor(.blue) + .padding(.top, 40) + + if isSent { + successView + } else { + formView + } + } + .padding(.horizontal, 32) + } + .navigationTitle("Reset Password") + .navigationBarTitleDisplayMode(.inline) + } + + private var successView: some View { + VStack(spacing: 16) { + Image(systemName: "envelope.badge.fill") + .font(.system(size: 48)) + .foregroundColor(.green) + + Text("Check Your Email") + .font(.title3) + .fontWeight(.semibold) + + Text("If an account exists for \(email), you'll receive a password reset link.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Button("Back to Sign In") { + dismiss() + } + .buttonStyle(.borderedProminent) + .padding(.top, 8) + } + } + + private var formView: some View { + VStack(spacing: 20) { + Text("Forgot your password?") + .font(.title3) + .fontWeight(.semibold) + + Text("Enter your email address and we'll send you a link to reset your password.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + VStack(alignment: .leading, spacing: 8) { + Text("Email") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + + TextField("email@example.com", text: $email) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(.emailAddress) + } + + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundColor(.red) + .multilineTextAlignment(.center) + } + + Button { + requestReset() + } label: { + if isLoading { + ProgressView() + .tint(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else { + Text("Send Reset Link") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + } + .buttonStyle(.borderedProminent) + .disabled(isLoading || email.isEmpty) + } + } + + private func requestReset() { + isLoading = true + errorMessage = nil + + Task { + do { + let authRepo = KoinHelper.authRepository + let result = try await authRepo.requestPasswordReset(email: email) + + await MainActor.run { + isLoading = false + if result is ResultSuccess { + isSent = true + } else if let error = result as? ResultError { + errorMessage = error.message ?? "Failed to send reset email" + } + } + } catch { + await MainActor.run { + isLoading = false + errorMessage = error.localizedDescription + } + } + } + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..2f66448 --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,67 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + LibreChat + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + com.librechat.ios + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + CADisableMinimumFrameDurationOnPhone + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSMicrophoneUsageDescription + LibreChat needs microphone access for voice input to transcribe speech to text. + NSSpeechRecognitionUsageDescription + LibreChat uses speech recognition to convert your voice into text messages. + NSCameraUsageDescription + LibreChat needs camera access to take photos for sharing in conversations. + NSPhotoLibraryUsageDescription + LibreChat needs photo library access to share images in conversations. + CFBundleURLTypes + + + CFBundleURLName + com.librechat.ios + CFBundleURLSchemes + + librechat + + + + + diff --git a/iosApp/iosApp/KoinHelper.swift b/iosApp/iosApp/KoinHelper.swift new file mode 100644 index 0000000..576faba --- /dev/null +++ b/iosApp/iosApp/KoinHelper.swift @@ -0,0 +1,37 @@ +import Foundation +import Shared + +/// Helper to resolve Koin dependencies from Swift. +/// Uses IosKoinAccessor (Kotlin-side) to avoid exposing Koin internals to Swift. +/// Koin is started in iOSApp.init() via IosKoinHelperKt.startIosKoin(). +/// +/// All accessors use try! because these are startup-time singletons that must +/// resolve. If Koin is misconfigured, we want a clear crash with the Koin error +/// message rather than a mysterious SIGABRT from trapOnUndeclaredException. +enum KoinHelper { + + static var sdk: LibreChatSDK { + try! IosKoinAccessor.shared.getSDK() + } + + static var serverDataStore: ServerDataStore { + try! IosKoinAccessor.shared.getServerDataStore() + } + + /// The streaming-qualified HttpClient for SSE connections + static var streamingHttpClient: Ktor_client_coreHttpClient { + try! IosKoinAccessor.shared.getStreamingHttpClient() + } + + static var authRepository: any AuthRepository { + try! IosKoinAccessor.shared.getAuthRepository() + } + + static var fileRepository: any FileRepository { + try! IosKoinAccessor.shared.getFileRepository() + } + + static var configRepository: any ConfigRepository { + try! IosKoinAccessor.shared.getConfigRepository() + } +} diff --git a/iosApp/iosApp/LoginView.swift b/iosApp/iosApp/LoginView.swift new file mode 100644 index 0000000..ae51956 --- /dev/null +++ b/iosApp/iosApp/LoginView.swift @@ -0,0 +1,155 @@ +import SwiftUI +import Shared + +struct LoginView: View { + @ObservedObject var authState: AuthState + + @State private var serverUrl = "" + @State private var email = "" + @State private var password = "" + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 24) { + // Logo + Image(systemName: "message.fill") + .font(.system(size: 56)) + .foregroundColor(.blue) + .padding(.top, 40) + + Text("LibreChat") + .font(.largeTitle) + .fontWeight(.bold) + + // Server URL + VStack(alignment: .leading, spacing: 8) { + Text("Server URL") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + + TextField("https://your-server.com", text: $serverUrl) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(.URL) + } + + // Email + VStack(alignment: .leading, spacing: 8) { + Text("Email") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + + TextField("email@example.com", text: $email) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(.emailAddress) + } + + // Password + VStack(alignment: .leading, spacing: 8) { + Text("Password") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + + SecureField("Password", text: $password) + .textFieldStyle(.roundedBorder) + } + + // Error message + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundColor(.red) + .multilineTextAlignment(.center) + } + + // Login button + Button { + performLogin() + } label: { + if isLoading { + ProgressView() + .tint(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else { + Text("Sign In") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + } + .buttonStyle(.borderedProminent) + .disabled(isLoading || serverUrl.isEmpty || email.isEmpty || password.isEmpty) + + // Forgot password link + NavigationLink(destination: ForgotPasswordView()) { + Text("Forgot Password?") + .font(.subheadline) + .foregroundColor(.blue) + } + + // Register link + HStack { + Text("Don't have an account?") + .font(.subheadline) + .foregroundColor(.secondary) + NavigationLink(destination: RegisterView(authState: authState, serverUrl: $serverUrl)) { + Text("Sign Up") + .font(.subheadline) + .fontWeight(.semibold) + } + } + } + .padding(.horizontal, 32) + } + .navigationBarTitleDisplayMode(.inline) + } + } + + private func performLogin() { + guard !serverUrl.isEmpty, !email.isEmpty, !password.isEmpty else { return } + + isLoading = true + errorMessage = nil + + // Normalize server URL + var normalizedUrl = serverUrl + if !normalizedUrl.hasPrefix("http://") && !normalizedUrl.hasPrefix("https://") { + normalizedUrl = "https://\(normalizedUrl)" + } + if normalizedUrl.hasSuffix("/") { + normalizedUrl = String(normalizedUrl.dropLast()) + } + + let sdk = KoinHelper.sdk + + Task { + do { + // Set the server URL (async — DataStore persistence) + try await KoinHelper.serverDataStore.setServerUrl(url: normalizedUrl) + + let result = try await sdk.login(email: email, password: password) + await MainActor.run { + authState.userName = result.response.user?.name ?? email + authState.serverUrl = normalizedUrl + authState.isLoggedIn = true + isLoading = false + } + } catch { + await MainActor.run { + errorMessage = error.localizedDescription + isLoading = false + } + } + } + } +} diff --git a/iosApp/iosApp/RegisterView.swift b/iosApp/iosApp/RegisterView.swift new file mode 100644 index 0000000..6ba24b1 --- /dev/null +++ b/iosApp/iosApp/RegisterView.swift @@ -0,0 +1,183 @@ +import SwiftUI +import Shared + +struct RegisterView: View { + @ObservedObject var authState: AuthState + @Binding var serverUrl: String + @Environment(\.dismiss) private var dismiss + + @State private var name = "" + @State private var email = "" + @State private var username = "" + @State private var password = "" + @State private var confirmPassword = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var isRegistered = false + + var body: some View { + ScrollView { + VStack(spacing: 20) { + Text("Create Account") + .font(.title2) + .fontWeight(.bold) + .padding(.top, 20) + + if isRegistered { + registrationSuccessView + } else { + registrationFormView + } + } + .padding(.horizontal, 32) + } + .navigationTitle("Register") + .navigationBarTitleDisplayMode(.inline) + } + + private var registrationSuccessView: some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 56)) + .foregroundColor(.green) + + Text("Registration Successful!") + .font(.title3) + .fontWeight(.semibold) + + Text("You can now sign in with your credentials.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Button("Back to Sign In") { + dismiss() + } + .buttonStyle(.borderedProminent) + } + .padding(.top, 20) + } + + private var registrationFormView: some View { + VStack(spacing: 16) { + formField("Name", text: $name, keyboardType: .default) + formField("Email", text: $email, keyboardType: .emailAddress) + formField("Username", text: $username, keyboardType: .default) + + VStack(alignment: .leading, spacing: 8) { + Text("Password") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + SecureField("Password", text: $password) + .textFieldStyle(.roundedBorder) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Confirm Password") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + SecureField("Confirm Password", text: $confirmPassword) + .textFieldStyle(.roundedBorder) + } + + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundColor(.red) + .multilineTextAlignment(.center) + } + + Button { + performRegistration() + } label: { + if isLoading { + ProgressView() + .tint(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else { + Text("Create Account") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + } + .buttonStyle(.borderedProminent) + .disabled(isLoading || !isFormValid) + } + } + + private func formField(_ label: String, text: Binding, keyboardType: UIKeyboardType) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(label) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + TextField(label, text: text) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(keyboardType) + } + } + + private var isFormValid: Bool { + !name.isEmpty && !email.isEmpty && !username.isEmpty && + !password.isEmpty && !confirmPassword.isEmpty + } + + private func performRegistration() { + guard password == confirmPassword else { + errorMessage = "Passwords do not match" + return + } + guard password.count >= 8 else { + errorMessage = "Password must be at least 8 characters" + return + } + + isLoading = true + errorMessage = nil + + Task { + do { + // Ensure server URL is set + if !serverUrl.isEmpty { + var normalizedUrl = serverUrl + if !normalizedUrl.hasPrefix("http://") && !normalizedUrl.hasPrefix("https://") { + normalizedUrl = "https://\(normalizedUrl)" + } + if normalizedUrl.hasSuffix("/") { + normalizedUrl = String(normalizedUrl.dropLast()) + } + try await KoinHelper.serverDataStore.setServerUrl(url: normalizedUrl) + } + + let authRepo = KoinHelper.authRepository + let result = try await authRepo.register( + name: name, + email: email, + username: username, + password: password + ) + + await MainActor.run { + isLoading = false + // Check if registration succeeded + if result is ResultSuccess { + isRegistered = true + } else if let error = result as? ResultError { + errorMessage = error.message ?? "Registration failed" + } + } + } catch { + await MainActor.run { + isLoading = false + errorMessage = error.localizedDescription + } + } + } + } +} diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift new file mode 100644 index 0000000..c23ae0b --- /dev/null +++ b/iosApp/iosApp/RootView.swift @@ -0,0 +1,41 @@ +import SwiftUI +import Shared + +struct RootView: View { + @StateObject private var authState = AuthState() + + var body: some View { + Group { + if authState.isLoggedIn { + MainTabView(authState: authState) + } else { + LoginView(authState: authState) + } + } + } +} + +struct MainTabView: View { + @ObservedObject var authState: AuthState + + var body: some View { + TabView { + ChatStreamView(authState: authState) + .tabItem { + Label("Chat", systemImage: "message") + } + + FilesView(authState: authState) + .tabItem { + Label("Files", systemImage: "folder") + } + } + } +} + +@MainActor +class AuthState: ObservableObject { + @Published var isLoggedIn = false + @Published var userName: String = "" + @Published var serverUrl: String = "" +} diff --git a/iosApp/iosApp/SharedFrameworkTest.swift b/iosApp/iosApp/SharedFrameworkTest.swift new file mode 100644 index 0000000..34903d6 --- /dev/null +++ b/iosApp/iosApp/SharedFrameworkTest.swift @@ -0,0 +1,79 @@ +import Foundation +// Import the KMP shared framework — validates Kotlin/Native → Swift bridging compiles +import Shared + +/// Validates the full SKIE-enhanced KMP iOS framework export: +/// 1. Model classes (Message, Conversation, User, Agent, etc.) +/// 2. SDK entry points (LibreChatSDK, AuthApi, SseClient) +/// 3. Platform implementations (IosConnectivityObserver) +/// 4. SKIE enhancements (sealed → enum, Flow → AsyncSequence, suspend → async) +/// +/// To verify: build the Shared framework, then compile this file against it. +/// If this compiles, the full KMP→SKIE→Swift pipeline is working correctly. + +func verifySharedFrameworkImport() { + // 1. Model class instantiation (unchanged from Phase 2) + let message = Message( + messageId: "msg-swift-001", + conversationId: "conv-swift-001", + parentMessageId: nil, + responseMessageId: nil, + overrideParentMessageId: nil, + user: nil, + model: "gpt-4o", + endpoint: "openAI", + sender: "Assistant", + text: "Hello from Swift!", + isCreatedByUser: false, + error: false, + unfinished: false, + finishReason: "stop", + tokenCount: nil, + iconURL: nil, + content: nil, + files: nil, + attachments: nil, + feedback: nil, + threadId: nil, + metadata: nil, + createdAt: nil, + updatedAt: nil, + title: nil + ) + assert(message.messageId == "msg-swift-001") + assert(message.text == "Hello from Swift!") + + // 2. SDK classes are visible + // Note: We can't instantiate LibreChatSDK without Koin, but we can verify the type exists + let _: LibreChatSDK.Type = LibreChatSDK.self + let _: AuthApi.Type = AuthApi.self + let _: SseClient.Type = SseClient.self + let _: ChatApi.Type = ChatApi.self + + // 3. Platform implementations are visible + let _: IosConnectivityObserver.Type = IosConnectivityObserver.self + + // 4. SKIE-enhanced sealed class → Swift enum (StreamEvent) + // Kotlin sealed interface subclasses are exported as separate classes + // SKIE's onEnum(of:) enables exhaustive switching + let contentDelta = StreamEventContentDelta( + chunk: "Hello", + messageId: nil, + agentId: nil, + groupId: nil + ) + switch onEnum(of: contentDelta as (any StreamEvent)) { + case .contentDelta(let d): + assert(d.chunk == "Hello") + case .error, .final, .toolCallStart, .toolCallComplete, + .thinkingDelta, .attachmentCreated, .retrying, .sync, + .step, .created: + assertionFailure("Wrong case") + } + + // 5. LoginResponse and request types + let loginReq = LoginRequest(email: "test@test.com", password: "pass") + assert(loginReq.email == "test@test.com") + + print("✅ All shared framework + SKIE integration verified successfully") +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift new file mode 100644 index 0000000..53f6829 --- /dev/null +++ b/iosApp/iosApp/iOSApp.swift @@ -0,0 +1,18 @@ +import SwiftUI +import Shared + +@main +struct iOSApp: App { + + init() { + // Initialize Koin DI — must happen before any KMP code is used + IosKoinHelperKt.startIosKoin() + } + + var body: some Scene { + WindowGroup { + LibreChatComposeView() + .ignoresSafeArea() + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e060a1f..044616f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,3 +30,4 @@ include(":feature:conversations") include(":feature:settings") include(":feature:agents") include(":feature:files") +include(":shared") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 0000000..60efd8b --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + id("librechat.kmp.library") + id("librechat.kmp.compose") + id("librechat.kmp.koin") + id("librechat.kotlin.serialization") + alias(libs.plugins.skie) +} + +android { + namespace = "com.librechat.shared" +} + +kotlin { + listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> + target.binaries.framework { + baseName = "Shared" + isStatic = true + // Export core modules so iOS can see all public types through the single framework + export(project(":core:common")) + export(project(":core:model")) + export(project(":core:network")) + export(project(":core:data")) + } + } + + sourceSets { + commonMain.dependencies { + api(project(":core:common")) + api(project(":core:model")) + api(project(":core:network")) + api(project(":core:data")) + api(project(":core:ui")) + implementation(project(":feature:auth")) + implementation(project(":feature:chat")) + implementation(project(":feature:conversations")) + implementation(project(":feature:settings")) + implementation(project(":feature:agents")) + implementation(project(":feature:files")) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) + implementation(libs.coroutines.core) + implementation(libs.kermit) + implementation(libs.navigation.compose.kmp) + implementation(libs.lifecycle.runtime.compose.kmp) + implementation(libs.lifecycle.viewmodel.compose.kmp) + implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) + implementation(libs.koin.compose.viewmodel.navigation) + } + iosMain.dependencies { + implementation(libs.ktor.client.darwin) + } + } +} + +skie { + features { + // All features enabled by default: + // - Sealed classes → Swift enums (onEnum(of:)) + // - Flow → AsyncSequence + // - Suspend → async/await + // No configuration needed for defaults + } +} diff --git a/shared/src/androidMain/kotlin/com/librechat/android/shared/AndroidPlatform.kt b/shared/src/androidMain/kotlin/com/librechat/android/shared/AndroidPlatform.kt new file mode 100644 index 0000000..22b7016 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/librechat/android/shared/AndroidPlatform.kt @@ -0,0 +1,8 @@ +package com.librechat.android.shared + +/** + * Android-side shared module marker. + * The Android app uses its own Koin modules directly (NetworkModule, DataModule, etc.) + * rather than going through the shared SDK facade. + * This source set exists for compilation symmetry with iosMain. + */ diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000..469d4b4 --- /dev/null +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,29 @@ + + + + No internet connection + + + New chat + Favorites + No conversations found + Remove bookmark + Bookmark + Agents + Files + Settings + + + Search + Clear search + Back to conversations + + + Search conversations\u2026 + + + Backend Version Mismatch + This app was built for LibreChat v%1$s, but your server is running v%2$s. Some features may not work correctly. + Dismiss + Don\'t warn again + diff --git a/shared/src/commonMain/kotlin/com/librechat/android/shared/LibreChatSDK.kt b/shared/src/commonMain/kotlin/com/librechat/android/shared/LibreChatSDK.kt new file mode 100644 index 0000000..63a5248 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/librechat/android/shared/LibreChatSDK.kt @@ -0,0 +1,37 @@ +package com.librechat.android.shared + +import com.librechat.android.core.network.api.AuthApi +import com.librechat.android.core.network.api.ChatApi +import com.librechat.android.core.network.api.LoginResult +import com.librechat.android.core.network.client.TokenManager +import com.librechat.android.core.network.sse.SseClient + +/** + * Top-level entry point that iOS uses to interact with the KMP business logic. + * Aggregates the key APIs needed for login + SSE streaming. + * + * SSE usage: call `sseClient.connect(streamingHttpClient, streamPath)` directly. + * SKIE converts the returned `Flow` to `AsyncSequence` on iOS. + */ +class LibreChatSDK( + val authApi: AuthApi, + val chatApi: ChatApi, + val sseClient: SseClient, + val tokenManager: TokenManager, +) { + + /** + * Perform login and store tokens in one call. + * On iOS, SKIE converts this suspend fun to async throws. + */ + @Throws(Exception::class) + suspend fun login(email: String, password: String): LoginResult { + val result = authApi.login(email, password) + val accessToken = result.response.token + val refreshToken = result.refreshToken + if (accessToken != null && refreshToken != null) { + tokenManager.setTokens(accessToken, refreshToken) + } + return result + } +} diff --git a/shared/src/commonMain/kotlin/com/librechat/android/shared/app/LibreChatApp.kt b/shared/src/commonMain/kotlin/com/librechat/android/shared/app/LibreChatApp.kt new file mode 100644 index 0000000..bede91a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/librechat/android/shared/app/LibreChatApp.kt @@ -0,0 +1,36 @@ +package com.librechat.android.shared.app + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.librechat.android.core.data.datastore.ThemeDataStore +import com.librechat.android.core.data.datastore.ThemeMode +import com.librechat.android.core.ui.theme.LibreChatTheme +import com.librechat.android.shared.navigation.LibreChatNavHost +import org.koin.compose.koinInject + +/** + * Root composable for the CMP app (used by iOS via MainViewController). + * + * Provides the full navigation shell with drawer-based sidebar matching + * the web frontend pattern: conversation list, search, favorites, and + * footer links to Agents, Files, and Settings. + * + * Auth is handled within the navigation graph — if not logged in, the + * auth graph is shown as the start destination. + */ +@Composable +fun LibreChatApp() { + val themeDataStore = koinInject() + val themeMode by themeDataStore.themeMode.collectAsState(initial = themeDataStore.initialThemeMode) + val systemDark = isSystemInDarkTheme() + val darkTheme = when (themeMode) { + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + ThemeMode.SYSTEM -> systemDark + } + LibreChatTheme(darkTheme = darkTheme) { + LibreChatNavHost() + } +} diff --git a/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/AppModule.kt b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/AppModule.kt new file mode 100644 index 0000000..10ae187 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/AppModule.kt @@ -0,0 +1,8 @@ +package com.librechat.android.shared.navigation + +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val sharedAppModule = module { + viewModelOf(::NavHostViewModel) +} diff --git a/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/DrawerContent.kt b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/DrawerContent.kt new file mode 100644 index 0000000..caa910e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/DrawerContent.kt @@ -0,0 +1,502 @@ +package com.librechat.android.shared.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.librechat.android.core.ui.components.endpointIconPainter +import com.librechat.android.core.ui.components.isMonochromeIcon +import librechat_android.shared.generated.resources.Res +import librechat_android.shared.generated.resources.agents +import librechat_android.shared.generated.resources.bookmark +import librechat_android.shared.generated.resources.cd_clear_search +import librechat_android.shared.generated.resources.cd_search +import librechat_android.shared.generated.resources.favorites +import librechat_android.shared.generated.resources.files +import librechat_android.shared.generated.resources.new_chat +import librechat_android.shared.generated.resources.no_conversations_found +import librechat_android.shared.generated.resources.remove_bookmark +import librechat_android.shared.generated.resources.search_conversations_placeholder +import librechat_android.shared.generated.resources.settings +import org.jetbrains.compose.resources.stringResource + +// Pre-computed shapes to avoid creating new ones per item per frame +private val ItemShape = RoundedCornerShape(8.dp) +private val ActiveIndicatorShape = RoundedCornerShape(2.dp) + +/** + * Stateful DrawerContent that collects its own state from the ViewModel. + */ +@Composable +fun DrawerContent( + viewModel: NavHostViewModel, + onNewChat: () -> Unit, + onConversationClick: (String) -> Unit, + onSettingsClick: () -> Unit, + onAgentsClick: () -> Unit, + onFilesClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.drawerUiState.collectAsStateWithLifecycle() + DrawerContent( + uiState = uiState, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onNewChat = onNewChat, + onConversationClick = onConversationClick, + onSettingsClick = onSettingsClick, + onAgentsClick = onAgentsClick, + onFilesClick = onFilesClick, + onToggleFavorite = viewModel::toggleFavorite, + onRefresh = viewModel::refreshConversations, + onLoadMore = viewModel::loadMoreConversations, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DrawerContent( + uiState: DrawerUiState, + onSearchQueryChanged: (String) -> Unit, + onNewChat: () -> Unit, + onConversationClick: (String) -> Unit, + onSettingsClick: () -> Unit, + onAgentsClick: () -> Unit, + onFilesClick: () -> Unit, + modifier: Modifier = Modifier, + onToggleFavorite: (String) -> Unit = {}, + onRefresh: () -> Unit = {}, + onLoadMore: () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxHeight() + .width(300.dp) + .statusBarsPadding() + .padding(top = 16.dp), + ) { + // "New Chat" button at top + Surface( + onClick = onNewChat, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + shape = ItemShape, + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(Res.string.new_chat), + style = MaterialTheme.typography.titleSmall, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Search bar + OutlinedTextField( + value = uiState.searchQuery, + onValueChange = onSearchQueryChanged, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(Res.string.cd_search), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + trailingIcon = { + if (uiState.searchQuery.isNotEmpty()) { + IconButton(onClick = { onSearchQueryChanged("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(Res.string.cd_clear_search), + modifier = Modifier.size(20.dp), + ) + } + } + }, + placeholder = { + Text( + text = stringResource(Res.string.search_conversations_placeholder), + style = MaterialTheme.typography.bodySmall, + ) + }, + singleLine = true, + shape = ItemShape, + textStyle = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Conversation list with favorites section and date groups + val listState = rememberLazyListState() + + val shouldLoadMore = remember { + derivedStateOf { + val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + lastVisibleItem >= totalItems - 8 && totalItems > 0 + } + } + + LaunchedEffect(shouldLoadMore.value) { + if (shouldLoadMore.value && uiState.hasMore && !uiState.isLoadingMore) { + onLoadMore() + } + } + + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.weight(1f), + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + // Favorites section + if (uiState.favoriteConversations.isNotEmpty() && uiState.searchQuery.isEmpty()) { + item(key = "favorites_header") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(Res.string.favorites), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { heading() }, + ) + } + } + + items( + items = uiState.favoriteConversations, + key = { "fav_${it.conversationId}" }, + contentType = { "conversation" }, + ) { data -> + DrawerConversationItem( + data = data, + onClick = { onConversationClick(data.conversationId) }, + onToggleFavorite = { onToggleFavorite(data.conversationId) }, + ) + } + + item(key = "favorites_divider") { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + } + + if (uiState.groupedConversations.isEmpty() && uiState.searchQuery.isNotEmpty()) { + item(key = "empty_search") { + Text( + text = stringResource(Res.string.no_conversations_found), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp), + ) + } + } + + uiState.groupedConversations.forEach { (dateGroup, displayItems) -> + item(key = "header_$dateGroup") { + Text( + text = dateGroup, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 12.dp, + bottom = 4.dp, + ), + ) + } + + items( + items = displayItems, + key = { it.conversationId }, + contentType = { "conversation" }, + ) { data -> + DrawerConversationItem( + data = data, + onClick = { onConversationClick(data.conversationId) }, + onToggleFavorite = { onToggleFavorite(data.conversationId) }, + ) + } + } + + if (uiState.isLoadingMore) { + item(key = "loading_more") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + } + } + } + } + + // Bottom section: divider + footer links + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + + DrawerFooterItem( + icon = Icons.Default.SmartToy, + label = stringResource(Res.string.agents), + onClick = onAgentsClick, + ) + DrawerFooterItem( + icon = Icons.Default.Folder, + label = stringResource(Res.string.files), + onClick = onFilesClick, + ) + DrawerFooterItem( + icon = Icons.Default.Settings, + label = stringResource(Res.string.settings), + onClick = onSettingsClick, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Composable +private fun DrawerConversationItem( + data: DrawerConversationDisplayData, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onToggleFavorite: () -> Unit = {}, +) { + val iconPainter = data.endpoint?.let { endpointIconPainter(it) } + + val backgroundColor = if (data.isActive) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surface + } + + Row( + modifier = modifier + .padding(horizontal = 4.dp, vertical = 1.dp) + .fillMaxWidth() + .background(backgroundColor, ItemShape) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (data.isActive) { + Box( + modifier = Modifier + .width(3.dp) + .height(24.dp) + .background(MaterialTheme.colorScheme.primary, ActiveIndicatorShape), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + if (data.isFavorite) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } else if (iconPainter != null) { + val isMonochrome = data.endpoint?.isMonochromeIcon() == true + Icon( + painter = iconPainter, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (isMonochrome) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + Color.Unspecified + }, + ) + } else { + Icon( + imageVector = Icons.Default.SmartToy, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (data.isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = data.title, + style = MaterialTheme.typography.bodyMedium, + color = if (data.isActive) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + val subtitle = remember(data.model, data.relativeTime) { + buildString { + data.model?.let { model -> + append(model.take(20)) + } + if (data.relativeTime.isNotEmpty()) { + if (isNotEmpty()) append(" \u00B7 ") + append(data.relativeTime) + } + } + } + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Icon( + imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder, + contentDescription = if (data.isFavorite) { + stringResource(Res.string.remove_bookmark) + } else { + stringResource(Res.string.bookmark) + }, + modifier = Modifier + .size(32.dp) + .clickable(onClick = onToggleFavorite) + .padding(8.dp), + tint = if (data.isFavorite) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} + +@Composable +private fun DrawerFooterItem( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/LibreChatNavHost.kt b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/LibreChatNavHost.kt new file mode 100644 index 0000000..d3f8038 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/LibreChatNavHost.kt @@ -0,0 +1,370 @@ +package com.librechat.android.shared.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.savedstate.read +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.librechat.android.core.ui.components.BannerDisplay +import com.librechat.android.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE +import com.librechat.android.feature.agents.navigation.agentsGraph +import com.librechat.android.feature.auth.navigation.AUTH_GRAPH_ROUTE +import com.librechat.android.feature.auth.navigation.authGraph +import com.librechat.android.feature.chat.navigation.CHAT_GRAPH_ROUTE +import com.librechat.android.feature.chat.navigation.CHAT_ROUTE +import com.librechat.android.feature.chat.navigation.NEW_CHAT_ROUTE +import com.librechat.android.feature.chat.navigation.chatGraph +import com.librechat.android.feature.chat.navigation.navigateToChat +import com.librechat.android.feature.conversations.navigation.conversationsGraph +import com.librechat.android.feature.files.navigation.filesGraph +import com.librechat.android.feature.settings.navigation.API_KEYS_ROUTE +import com.librechat.android.feature.settings.navigation.PRESET_MANAGER_ROUTE +import com.librechat.android.feature.settings.navigation.SETTINGS_ACCOUNT_ROUTE +import com.librechat.android.feature.settings.navigation.SETTINGS_CHAT_ROUTE +import com.librechat.android.feature.settings.navigation.SETTINGS_DATA_ROUTE +import com.librechat.android.feature.settings.navigation.SETTINGS_GENERAL_ROUTE +import com.librechat.android.feature.settings.navigation.SETTINGS_TABBED_ROUTE +import com.librechat.android.feature.settings.navigation.SHARED_LINKS_ROUTE +import com.librechat.android.feature.settings.navigation.settingsGraph +import kotlinx.coroutines.launch +import librechat_android.shared.generated.resources.Res +import librechat_android.shared.generated.resources.dismiss +import librechat_android.shared.generated.resources.dont_warn_again +import librechat_android.shared.generated.resources.version_mismatch_message +import librechat_android.shared.generated.resources.version_mismatch_title +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +/** Maps a [SettingsCategory] to its corresponding navigation route. */ +fun SettingsCategory.toRoute(): String = when (this) { + SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE + SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE + SettingsCategory.ACCOUNT -> SETTINGS_ACCOUNT_ROUTE + SettingsCategory.DATA -> SETTINGS_DATA_ROUTE +} + +/** + * Shared root composable for LibreChat navigation. + * Uses ModalNavigationDrawer (phone layout) for the sidebar-first pattern. + * + * Platform-specific features (deep links, tablet layout with BackHandler + swipe) + * are handled by the Android app module. This shared version provides the core + * phone layout that works on both Android and iOS. + */ +@Composable +fun LibreChatNavHost( + modifier: Modifier = Modifier, + navHostViewModel: NavHostViewModel = koinViewModel(), +) { + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() + + val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE || + currentDestination?.parent?.route == AUTH_GRAPH_ROUTE + + // Track active conversation from nav back stack. + // Use SavedState.read {} to extract nav args (KMP-compatible, no Bundle.getString). + LaunchedEffect(navBackStackEntry) { + val destRoute = navBackStackEntry?.destination?.route + val conversationId = if (destRoute == CHAT_ROUTE) { + navBackStackEntry?.arguments?.read { getStringOrNull("conversationId") } + } else { + null + } + navHostViewModel.setActiveConversation(conversationId) + } + + // Handle session expiry + LaunchedEffect(Unit) { + navHostViewModel.sessionExpired.collect { + navController.navigate(AUTH_GRAPH_ROUTE) { + popUpTo(0) { inclusive = true } + } + } + } + + val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE + + PhoneLayout( + navController = navController, + navHostViewModel = navHostViewModel, + startDestination = startDestination, + isInAuthFlow = isInAuthFlow, + modifier = modifier, + ) + + // Version mismatch warning dialog + val versionMismatch by navHostViewModel.versionMismatch.collectAsStateWithLifecycle() + versionMismatch?.let { mismatch -> + VersionMismatchDialog( + supportedVersion = mismatch.supportedVersion, + backendVersion = mismatch.backendVersion, + onDismiss = navHostViewModel::dismissVersionWarning, + onDismissPermanently = navHostViewModel::dismissVersionWarningPermanently, + ) + } +} + +@Composable +private fun VersionMismatchDialog( + supportedVersion: String, + backendVersion: String, + onDismiss: () -> Unit, + onDismissPermanently: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(Res.string.version_mismatch_title), + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Text( + text = stringResource(Res.string.version_mismatch_message, supportedVersion, backendVersion), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.dismiss)) + } + }, + dismissButton = { + TextButton(onClick = onDismissPermanently) { + Text(stringResource(Res.string.dont_warn_again)) + } + }, + ) +} + +@Composable +private fun PhoneLayout( + navController: androidx.navigation.NavHostController, + navHostViewModel: NavHostViewModel, + startDestination: String, + isInAuthFlow: Boolean, + modifier: Modifier = Modifier, +) { + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + val banners by navHostViewModel.banners.collectAsStateWithLifecycle() + val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() + + // Reset sidebar mode to Conversations when the drawer closes + LaunchedEffect(drawerState.isClosed) { + if (drawerState.isClosed) { + navHostViewModel.setSidebarMode(SidebarMode.Conversations) + } + } + + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = !isInAuthFlow, + drawerContent = { + ModalDrawerSheet { + SidebarScaffold( + viewModel = navHostViewModel, + onNewChat = { + scope.launch { drawerState.close() } + val currentRoute = navController.currentBackStackEntry?.destination?.route + if (currentRoute != NEW_CHAT_ROUTE) { + navController.navigate(NEW_CHAT_ROUTE) { + popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } + launchSingleTop = true + } + } + }, + onConversationClick = { conversationId -> + scope.launch { drawerState.close() } + navController.navigateToChat(conversationId) + }, + onSettingsClick = { + scope.launch { drawerState.close() } + navController.navigate(SETTINGS_TABBED_ROUTE) { + launchSingleTop = true + } + }, + onSettingsCategorySelected = { category -> + navController.navigate(category.toRoute()) { + launchSingleTop = true + } + }, + onAgentsClick = { + scope.launch { drawerState.close() } + navController.navigate(TopLevelDestination.AGENTS.route) { + launchSingleTop = true + } + }, + onFilesClick = { + scope.launch { drawerState.close() } + navController.navigate(TopLevelDestination.FILES.route) { + launchSingleTop = true + } + }, + ) + } + }, + ) { + Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + if (!isInAuthFlow && banners.isNotEmpty()) { + BannerDisplay( + banners = banners, + dismissedIds = dismissedBannerIds, + onDismiss = navHostViewModel::dismissBanner, + modifier = Modifier.padding(top = 4.dp), + ) + } + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier.fillMaxSize(), + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(300), + ) + }, + exitTransition = { + fadeOut(animationSpec = tween(150)) + }, + popEnterTransition = { + fadeIn( + initialAlpha = 0.5f, + animationSpec = tween(300, easing = LinearEasing), + ) + scaleIn( + initialScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + ) + }, + popExitTransition = { + slideOut( + targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + ) + }, + ) { + authGraph( + navController = navController, + onAuthComplete = { + navHostViewModel.onAuthComplete() + navController.navigate(CHAT_GRAPH_ROUTE) { + popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true } + } + }, + ) + chatGraph( + navController = navController, + onOpenDrawer = { + scope.launch { drawerState.open() } + }, + ) + conversationsGraph( + onConversationClick = { conversationId -> + navController.navigateToChat(conversationId) + }, + onNavigateToArchived = { + navController.navigate("conversations/archived") + }, + onNavigateBackFromArchived = { + navController.popBackStack() + }, + ) + agentsGraph( + onAgentClick = { agentId -> + navController.navigate("agents/$agentId") + }, + onBack = { navController.popBackStack() }, + onStartChat = { agentId -> + navController.navigate(NEW_CHAT_ROUTE) { + popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } + launchSingleTop = true + } + }, + onCreateAgent = { + navController.navigate(AGENT_EDITOR_CREATE_ROUTE) + }, + onEditAgent = { agentId -> + navController.navigate("agents/editor/$agentId") + }, + ) + filesGraph( + onBack = { navController.popBackStack() }, + ) + settingsGraph( + onLogout = { + navHostViewModel.logout() + navController.navigate(AUTH_GRAPH_ROUTE) { + popUpTo(0) { inclusive = true } + } + }, + onNavigateBack = { navController.popBackStack() }, + onNavigateToArchived = { + navController.navigate("conversations/archived") { + launchSingleTop = true + } + }, + onNavigateToSharedLinks = { + navController.navigate(SHARED_LINKS_ROUTE) { + launchSingleTop = true + } + }, + onNavigateBackFromSharedLinks = { + navController.popBackStack() + }, + onNavigateToPresets = { + navController.navigate(PRESET_MANAGER_ROUTE) { + launchSingleTop = true + } + }, + onNavigateBackFromPresets = { + navController.popBackStack() + }, + onNavigateToApiKeys = { + navController.navigate(API_KEYS_ROUTE) { + launchSingleTop = true + } + }, + onNavigateBackFromApiKeys = { + navController.popBackStack() + }, + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/NavHostViewModel.kt b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/NavHostViewModel.kt new file mode 100644 index 0000000..d9ebdbf --- /dev/null +++ b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/NavHostViewModel.kt @@ -0,0 +1,441 @@ +package com.librechat.android.shared.navigation + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.librechat.android.core.common.extensions.toInstantOrNull +import com.librechat.android.core.common.extensions.toRelativeDateGroup +import com.librechat.android.core.common.result.Result +import com.librechat.android.core.data.repository.AuthRepository +import com.librechat.android.core.data.repository.BannerRepository +import com.librechat.android.core.data.repository.ConfigRepository +import com.librechat.android.core.data.repository.ConversationRepository +import com.librechat.android.core.model.Banner +import com.librechat.android.core.model.Conversation +import com.librechat.android.core.model.EModelEndpoint +import com.librechat.android.core.network.client.TokenManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import co.touchlab.kermit.Logger +import com.librechat.android.core.common.extensions.formatMonthAbbrev +import kotlin.time.Clock +import kotlin.time.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +/** + * Lightweight snapshot of fields DrawerConversationItem actually renders. + * Avoids passing the full 28-field Conversation through composition. + */ +@Immutable +data class DrawerConversationDisplayData( + val conversationId: String, + val title: String, + val model: String?, + val endpoint: EModelEndpoint?, + val relativeTime: String, + val isActive: Boolean, + val isFavorite: Boolean, +) + +/** + * Combined UI state for the drawer sidebar. + */ +@Immutable +data class DrawerUiState( + val groupedConversations: List>> = emptyList(), + val favoriteConversations: List = emptyList(), + val searchQuery: String = "", + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val hasMore: Boolean = true, +) + +/** + * UI state for a backend version mismatch warning. + */ +@Immutable +data class VersionMismatchState( + val supportedVersion: String, + val backendVersion: String, +) + +class NavHostViewModel( + private val authRepository: AuthRepository, + private val bannerRepository: BannerRepository, + private val configRepository: ConfigRepository, + private val conversationRepository: ConversationRepository, + private val tokenManager: TokenManager, + private val settingsDataStore: com.librechat.android.core.data.datastore.SettingsDataStore, +) : ViewModel() { + + private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + + private val _versionMismatch = MutableStateFlow(null) + val versionMismatch: StateFlow = _versionMismatch.asStateFlow() + + private val _recentConversations = MutableStateFlow>(emptyList()) + private val _groupedConversations = MutableStateFlow>>>(emptyList()) + private val _activeConversationId = MutableStateFlow(null) + private val _searchQuery = MutableStateFlow("") + private val _favorites = MutableStateFlow>(emptySet()) + private val _favoriteConversations = MutableStateFlow>(emptyList()) + private var nextCursor: String? = null + private val _hasMore = MutableStateFlow(true) + private val _isLoadingMore = MutableStateFlow(false) + private val _isRefreshing = MutableStateFlow(false) + + private val _banners = MutableStateFlow>(emptyList()) + val banners: StateFlow> = _banners.asStateFlow() + + private val _dismissedBannerIds = MutableStateFlow>(emptySet()) + val dismissedBannerIds: StateFlow> = _dismissedBannerIds.asStateFlow() + + val sessionExpired: SharedFlow = tokenManager.sessionExpiredFlow + + val tabletSidebarOpen: StateFlow = settingsDataStore.tabletSidebarOpen + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + initialValue = false, // Avoid runBlocking — not safe on iOS/Kotlin Native main thread + ) + + val tabletSidebarGestureEnabled: StateFlow = settingsDataStore.tabletSidebarGestureEnabled + .stateIn(viewModelScope, SharingStarted.Eagerly, true) + + private val _sidebarMode = MutableStateFlow(SidebarMode.Conversations) + val sidebarMode: StateFlow = _sidebarMode.asStateFlow() + + private val _selectedSettingsCategory = MutableStateFlow(null) + val selectedSettingsCategory: StateFlow = _selectedSettingsCategory.asStateFlow() + + fun setSidebarMode(mode: SidebarMode) { + _sidebarMode.value = mode + } + + fun selectSettingsCategory(category: SettingsCategory) { + _selectedSettingsCategory.value = category + } + + private var searchJob: Job? = null + + val drawerUiState: StateFlow = combine( + combine( + _groupedConversations, + _activeConversationId, + _favorites, + _favoriteConversations, + _searchQuery, + ) { grouped, activeId, favIds, favConvos, query -> + DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query) + }, + combine(_isRefreshing, _isLoadingMore, _hasMore) { refreshing, loadingMore, hasMore -> + Triple(refreshing, loadingMore, hasMore) + }, + ) { data, (refreshing, loadingMore, hasMore) -> + DrawerUiState( + groupedConversations = data.grouped.map { (group, convos) -> + group to convos.map { it.toDrawerDisplayData(data.activeId, data.favIds) } + }, + favoriteConversations = data.favConvos.map { it.toDrawerDisplayData(data.activeId, data.favIds) }, + searchQuery = data.query, + isRefreshing = refreshing, + isLoadingMore = loadingMore, + hasMore = hasMore, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState()) + + init { + viewModelScope.launch { + try { + val loggedIn = authRepository.isLoggedIn() + _isLoggedIn.value = loggedIn + if (loggedIn) { + checkBackendVersion() + } + } catch (e: Exception) { + Logger.w(e) { "Failed to check auth state on init" } + } + } + observeConversations() + observeBookmarks() + fetchBanners() + loadInitialConversations() + } + + private fun observeConversations() { + viewModelScope.launch { + try { + conversationRepository.observeConversations().collect { result -> + if (result is Result.Success) { + _recentConversations.value = result.data + if (_searchQuery.value.isBlank()) { + _groupedConversations.value = groupConversationsByDate(result.data) + } + updateFavoriteConversations() + } + } + } catch (e: Exception) { + Logger.w(e) { "Failed to observe conversations" } + } + } + } + + private fun loadInitialConversations() { + viewModelScope.launch { + try { + val result = conversationRepository.loadNextPage(cursor = null) + if (result is Result.Success) { + nextCursor = result.data + _hasMore.value = result.data != null + } + } catch (e: Exception) { + Logger.w(e) { "Failed to load initial conversations" } + } + } + } + + fun loadMoreConversations() { + if (_isLoadingMore.value || !_hasMore.value) return + _isLoadingMore.value = true + viewModelScope.launch { + try { + val result = conversationRepository.loadNextPage(cursor = nextCursor) + if (result is Result.Success) { + nextCursor = result.data + _hasMore.value = result.data != null + } else { + Logger.w { "Failed to load more conversations" } + } + } catch (e: Exception) { + Logger.w(e) { "Failed to load more conversations" } + } finally { + _isLoadingMore.value = false + } + } + } + + fun onAuthComplete() { + _isLoggedIn.value = true + loadInitialConversations() + fetchBanners() + checkBackendVersion() + } + + fun refreshConversations() { + _isRefreshing.value = true + viewModelScope.launch { + try { + nextCursor = null + _hasMore.value = true + val result = conversationRepository.loadNextPage(cursor = null) + if (result is Result.Success) { + nextCursor = result.data + _hasMore.value = result.data != null + } + } catch (e: Exception) { + Logger.w(e) { "Failed to refresh conversations" } + } finally { + _isRefreshing.value = false + } + } + } + + fun setActiveConversation(conversationId: String?) { + _activeConversationId.value = conversationId + } + + fun onSearchQueryChanged(query: String) { + _searchQuery.value = query + searchJob?.cancel() + + if (query.isBlank()) { + _groupedConversations.value = groupConversationsByDate(_recentConversations.value) + return + } + + searchJob = viewModelScope.launch { + delay(300) + val filtered = _recentConversations.value.filter { conversation -> + conversation.title?.contains(query, ignoreCase = true) == true + } + _groupedConversations.value = groupConversationsByDate(filtered) + } + } + + private fun observeBookmarks() { + viewModelScope.launch { + try { + settingsDataStore.bookmarkedConversationIds.collect { bookmarkedIds -> + _favorites.value = bookmarkedIds + updateFavoriteConversations() + } + } catch (e: Exception) { + Logger.w(e) { "Failed to observe bookmarks" } + } + } + } + + fun toggleFavorite(conversationId: String) { + viewModelScope.launch { + settingsDataStore.toggleBookmark(conversationId) + } + } + + private fun updateFavoriteConversations() { + val bookmarkedIds = _favorites.value + val allConversations = _recentConversations.value + _favoriteConversations.value = allConversations.filter { conversation -> + conversation.conversationId in bookmarkedIds || + conversation.tags.any { + it.equals("favorite", ignoreCase = true) || + it.equals("bookmark", ignoreCase = true) + } + } + } + + private fun fetchBanners() { + viewModelScope.launch { + try { + val result = bannerRepository.getBanners() + if (result is Result.Success) { + val now = Clock.System.now() + _banners.value = result.data.filter { banner -> + val from = banner.displayFrom?.let { runCatching { Instant.parse(it) }.getOrNull() } + val to = banner.displayTo?.let { runCatching { Instant.parse(it) }.getOrNull() } + val afterStart = from == null || now >= from + val beforeEnd = to == null || now < to + afterStart && beforeEnd + } + } + } catch (e: Exception) { + Logger.w(e) { "Failed to fetch banners" } + } + } + } + + fun setTabletSidebarOpen(open: Boolean) { + viewModelScope.launch { + settingsDataStore.setTabletSidebarOpen(open) + } + } + + fun dismissBanner(bannerId: String) { + _dismissedBannerIds.value = _dismissedBannerIds.value + bannerId + } + + private fun checkBackendVersion() { + viewModelScope.launch { + val result = configRepository.checkBackendVersion() + if (result is Result.Success) { + val checkResult = result.data + val detectedVersion = checkResult.backendVersion + if (!checkResult.isCompatible && detectedVersion != null) { + val dismissedVersion = settingsDataStore.dismissedVersionWarning.first() + if (dismissedVersion != detectedVersion) { + _versionMismatch.value = VersionMismatchState( + supportedVersion = checkResult.supportedVersion, + backendVersion = detectedVersion, + ) + } + } + } + } + } + + fun dismissVersionWarning() { + _versionMismatch.value = null + } + + fun dismissVersionWarningPermanently() { + val backendVersion = _versionMismatch.value?.backendVersion + _versionMismatch.value = null + if (backendVersion != null) { + viewModelScope.launch { + settingsDataStore.setDismissedVersionWarning(backendVersion) + } + } + } + + fun logout() { + viewModelScope.launch { + authRepository.logout() + _isLoggedIn.value = false + _recentConversations.value = emptyList() + _groupedConversations.value = emptyList() + _activeConversationId.value = null + _searchQuery.value = "" + _favorites.value = emptySet() + _favoriteConversations.value = emptyList() + _sidebarMode.value = SidebarMode.Conversations + _selectedSettingsCategory.value = null + } + } + + private fun groupConversationsByDate( + conversations: List, + ): List>> { + if (conversations.isEmpty()) return emptyList() + return conversations + .groupBy { conversation -> + conversation.updatedAt + ?.toInstantOrNull() + ?.toRelativeDateGroup() + ?: "Unknown" + } + .toList() + } +} + +private data class DrawerDataSnapshot( + val grouped: List>>, + val activeId: String?, + val favIds: Set, + val favConvos: List, + val query: String, +) + +private fun Conversation.toDrawerDisplayData( + activeConversationId: String?, + favoriteIds: Set, +): DrawerConversationDisplayData { + val convId = conversationId ?: "" + return DrawerConversationDisplayData( + conversationId = convId, + title = title ?: "New Chat", + model = model, + endpoint = endpoint, + relativeTime = updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "", + isActive = convId == activeConversationId, + isFavorite = convId in favoriteIds, + ) +} + +private fun Instant.toRelativeTimeString(): String { + val now = Clock.System.now() + val duration = now - this + val minutes = duration.inWholeMinutes + val hours = duration.inWholeHours + val days = duration.inWholeDays + + return when { + minutes < 1 -> "Just now" + minutes < 60 -> "${minutes}m ago" + hours < 24 -> "${hours}h ago" + days < 7 -> "${days}d ago" + else -> { + val date = toLocalDateTime(TimeZone.currentSystemDefault()).date + "${formatMonthAbbrev(date.monthNumber)} ${date.dayOfMonth}" + } + } +} diff --git a/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/SettingsSidebarContent.kt b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/SettingsSidebarContent.kt new file mode 100644 index 0000000..b85e8b7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/SettingsSidebarContent.kt @@ -0,0 +1,149 @@ +package com.librechat.android.shared.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import librechat_android.shared.generated.resources.Res +import librechat_android.shared.generated.resources.cd_back_to_conversations +import librechat_android.shared.generated.resources.settings +import org.jetbrains.compose.resources.stringResource + +private val CategoryItemShape = RoundedCornerShape(12.dp) + +/** + * Settings sidebar content shown when the sidebar mode is [SidebarMode.Settings]. + */ +@Composable +fun SettingsSidebarContent( + selectedCategory: SettingsCategory?, + onBackToConversations: () -> Unit, + onCategorySelected: (SettingsCategory) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxHeight() + .width(300.dp) + .statusBarsPadding() + .padding(top = 8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBackToConversations) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.cd_back_to_conversations), + ) + } + Text( + text = stringResource(Res.string.settings), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .weight(1f) + .semantics { heading() }, + ) + } + + Spacer(modifier = Modifier.padding(top = 8.dp)) + + SettingsCategoryItem( + icon = Icons.Default.Settings, + category = SettingsCategory.GENERAL, + isSelected = selectedCategory == SettingsCategory.GENERAL, + onClick = { onCategorySelected(SettingsCategory.GENERAL) }, + ) + SettingsCategoryItem( + icon = Icons.AutoMirrored.Filled.Chat, + category = SettingsCategory.CHAT, + isSelected = selectedCategory == SettingsCategory.CHAT, + onClick = { onCategorySelected(SettingsCategory.CHAT) }, + ) + SettingsCategoryItem( + icon = Icons.Default.AccountCircle, + category = SettingsCategory.ACCOUNT, + isSelected = selectedCategory == SettingsCategory.ACCOUNT, + onClick = { onCategorySelected(SettingsCategory.ACCOUNT) }, + ) + SettingsCategoryItem( + icon = Icons.Default.Storage, + category = SettingsCategory.DATA, + isSelected = selectedCategory == SettingsCategory.DATA, + onClick = { onCategorySelected(SettingsCategory.DATA) }, + ) + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun SettingsCategoryItem( + icon: ImageVector, + category: SettingsCategory, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surface + } + val contentColor = if (isSelected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + .background(backgroundColor, CategoryItemShape) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = contentColor, + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = category.label, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/SidebarMode.kt b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/SidebarMode.kt new file mode 100644 index 0000000..632866d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/SidebarMode.kt @@ -0,0 +1,17 @@ +package com.librechat.android.shared.navigation + +sealed interface SidebarMode { + data object Conversations : SidebarMode + data object Settings : SidebarMode +} + +/** + * Settings categories shown as a navigation menu in the sidebar. + * Each maps to a dedicated settings sub-page in the main content area. + */ +enum class SettingsCategory(val label: String) { + GENERAL("General"), + CHAT("Chat"), + ACCOUNT("Account"), + DATA("Data"), +} diff --git a/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/SidebarScaffold.kt b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/SidebarScaffold.kt new file mode 100644 index 0000000..5c060c1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/SidebarScaffold.kt @@ -0,0 +1,69 @@ +package com.librechat.android.shared.navigation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +/** + * Sidebar scaffold that switches between Conversations and Settings modes. + * Uses [AnimatedContent] for a smooth horizontal slide transition. + */ +@Composable +fun SidebarScaffold( + viewModel: NavHostViewModel, + onNewChat: () -> Unit, + onConversationClick: (String) -> Unit, + onSettingsClick: () -> Unit, + onSettingsCategorySelected: (SettingsCategory) -> Unit, + onAgentsClick: () -> Unit, + onFilesClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val sidebarMode by viewModel.sidebarMode.collectAsStateWithLifecycle() + val selectedCategory by viewModel.selectedSettingsCategory.collectAsStateWithLifecycle() + + AnimatedContent( + targetState = sidebarMode, + modifier = modifier, + transitionSpec = { + if (targetState is SidebarMode.Settings) { + slideInHorizontally { fullWidth -> fullWidth } togetherWith + slideOutHorizontally { fullWidth -> -fullWidth } + } else { + slideInHorizontally { fullWidth -> -fullWidth } togetherWith + slideOutHorizontally { fullWidth -> fullWidth } + } + }, + label = "SidebarModeTransition", + ) { mode -> + when (mode) { + is SidebarMode.Conversations -> { + DrawerContent( + viewModel = viewModel, + onNewChat = onNewChat, + onConversationClick = onConversationClick, + onSettingsClick = onSettingsClick, + onAgentsClick = onAgentsClick, + onFilesClick = onFilesClick, + ) + } + is SidebarMode.Settings -> { + SettingsSidebarContent( + selectedCategory = selectedCategory, + onBackToConversations = { + viewModel.setSidebarMode(SidebarMode.Conversations) + }, + onCategorySelected = { category -> + viewModel.selectSettingsCategory(category) + onSettingsCategorySelected(category) + }, + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/TopLevelDestination.kt b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/TopLevelDestination.kt new file mode 100644 index 0000000..4be76ab --- /dev/null +++ b/shared/src/commonMain/kotlin/com/librechat/android/shared/navigation/TopLevelDestination.kt @@ -0,0 +1,24 @@ +package com.librechat.android.shared.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Destinations accessible from the navigation drawer footer. + * The primary navigation is sidebar-first (drawer with conversation list), + * matching the web frontend pattern. + */ +enum class TopLevelDestination( + val route: String, + val icon: ImageVector, + val label: String, +) { + CHAT("chat_graph", Icons.AutoMirrored.Filled.Chat, "Chat"), + CONVERSATIONS("conversations", Icons.Default.Forum, "Conversations"), + AGENTS("agents", Icons.Default.SmartToy, "Agents"), + FILES("files", Icons.Default.Folder, "Files"), +} diff --git a/shared/src/iosMain/kotlin/com/librechat/android/shared/IosCrashReporting.kt b/shared/src/iosMain/kotlin/com/librechat/android/shared/IosCrashReporting.kt new file mode 100644 index 0000000..d6412e9 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/librechat/android/shared/IosCrashReporting.kt @@ -0,0 +1,43 @@ +package com.librechat.android.shared + +import co.touchlab.kermit.Logger +import kotlin.experimental.ExperimentalNativeApi +import platform.Foundation.NSException +import platform.Foundation.NSLog + +private val log = Logger.withTag("CrashReporting") + +/** + * Installs a Kotlin/Native unhandled exception hook that: + * 1. Logs the full Kotlin stack trace via Kermit + NSLog (visible in Xcode/Console.app) + * 2. Wraps the Kotlin exception as an NSException with readable class name, message, + * and stack trace — so iOS crash logs show meaningful Kotlin context instead of + * just "Kotlin_ObjCExport_trapOnUndeclaredException" with a SIGABRT. + * + * Call once during app startup (before any coroutines or UI work). + */ +@OptIn(ExperimentalNativeApi::class) +fun installCrashReporting() { + setUnhandledExceptionHook { throwable -> + // Log the full Kotlin stack trace so it's captured in OS logs + val stackTrace = throwable.stackTraceToString() + val exceptionName = throwable::class.qualifiedName ?: throwable::class.simpleName ?: "Unknown" + val message = throwable.message ?: "(no message)" + + log.e { "FATAL: Unhandled Kotlin exception: $exceptionName: $message" } + log.e { stackTrace } + + // Also write directly to NSLog in case Kermit writers aren't flushed + NSLog("FATAL: Unhandled Kotlin exception: %@: %@", exceptionName, message) + NSLog("Stack trace:\n%@", stackTrace) + + // Raise as NSException so iOS crash reporters capture a meaningful report + // instead of a bare SIGABRT with no context + NSException( + name = "KotlinException: $exceptionName", + reason = message, + userInfo = null, + ).raise() + } + log.d { "iOS crash reporting hook installed" } +} diff --git a/shared/src/iosMain/kotlin/com/librechat/android/shared/IosKoinAccessor.kt b/shared/src/iosMain/kotlin/com/librechat/android/shared/IosKoinAccessor.kt new file mode 100644 index 0000000..2684c7b --- /dev/null +++ b/shared/src/iosMain/kotlin/com/librechat/android/shared/IosKoinAccessor.kt @@ -0,0 +1,42 @@ +package com.librechat.android.shared + +import com.librechat.android.core.common.di.KoinQualifiers +import com.librechat.android.core.data.datastore.ServerDataStore +import com.librechat.android.core.data.repository.AuthRepository +import com.librechat.android.core.data.repository.FileRepository +import com.librechat.android.core.data.repository.ConfigRepository +import io.ktor.client.HttpClient +import org.koin.core.Koin + +/** + * Swift-callable accessors for Koin-managed singletons. + * The Koin instance is stored here by startIosKoin() and used + * by Swift via typed helper functions. + * + * All functions use @Throws to ensure Kotlin exceptions propagate + * as NSError to Swift rather than causing SIGABRT via + * trapOnUndeclaredException. + */ +object IosKoinAccessor { + + internal lateinit var koin: Koin + + @Throws(Exception::class) + fun getSDK(): LibreChatSDK = koin.get() + + @Throws(Exception::class) + fun getServerDataStore(): ServerDataStore = koin.get() + + @Throws(Exception::class) + fun getStreamingHttpClient(): HttpClient = + koin.get(qualifier = KoinQualifiers.Streaming) + + @Throws(Exception::class) + fun getAuthRepository(): AuthRepository = koin.get() + + @Throws(Exception::class) + fun getFileRepository(): FileRepository = koin.get() + + @Throws(Exception::class) + fun getConfigRepository(): ConfigRepository = koin.get() +} diff --git a/shared/src/iosMain/kotlin/com/librechat/android/shared/IosKoinHelper.kt b/shared/src/iosMain/kotlin/com/librechat/android/shared/IosKoinHelper.kt new file mode 100644 index 0000000..1a751b9 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/librechat/android/shared/IosKoinHelper.kt @@ -0,0 +1,40 @@ +package com.librechat.android.shared + +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity +import co.touchlab.kermit.LogWriter +import com.librechat.android.shared.navigation.sharedAppModule +import org.koin.core.context.startKoin +import platform.Foundation.NSLog + +/** + * LogWriter that routes Kermit output through NSLog so it appears in + * the unified OS log (visible via `log stream` / Console.app / Xcode). + */ +private class IosNSLogWriter : LogWriter() { + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + val level = severity.name.first() + NSLog("[$level/$tag] $message") + throwable?.let { NSLog("[$level/$tag] ${it.stackTraceToString()}") } + } +} + +/** + * Entry point for iOS to initialize Koin DI. + * Call from Swift: IosKoinHelperKt.startIosKoin() + */ +fun startIosKoin() { + // Route Kermit logs through NSLog for OS log visibility + Logger.setLogWriters(IosNSLogWriter()) + + // Install crash reporting hook early so all Kotlin exceptions get proper stack traces + installCrashReporting() + Logger.setMinSeverity(Severity.Debug) + Logger.withTag("Koin").d { "startIosKoin: initializing Koin DI" } + + val app = startKoin { + modules(iosSharedModule, sharedAppModule) + } + IosKoinAccessor.koin = app.koin + Logger.withTag("Koin").d { "startIosKoin: Koin initialized successfully" } +} diff --git a/shared/src/iosMain/kotlin/com/librechat/android/shared/IosSharedModule.kt b/shared/src/iosMain/kotlin/com/librechat/android/shared/IosSharedModule.kt new file mode 100644 index 0000000..31c2ab4 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/librechat/android/shared/IosSharedModule.kt @@ -0,0 +1,166 @@ +package com.librechat.android.shared + +import com.librechat.android.core.common.di.KoinQualifiers +import com.librechat.android.core.common.di.commonModule +import com.librechat.android.core.data.di.dataModule +import com.librechat.android.feature.agents.di.agentsModule +import com.librechat.android.feature.files.di.filesModule +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.settings.di.settingsModule +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 com.librechat.android.core.network.sse.SseClient +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.http.takeFrom +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +/** + * iOS-specific Koin module providing platform implementations and HTTP clients. + * Includes dataModule which provides repositories, DAOs, DataStore, and token storage. + */ +val iosSharedModule = module { + + includes(commonModule) + includes(dataModule) + includes(authModule) + includes(chatModule) + includes(conversationsModule) + includes(settingsModule) + includes(agentsModule) + includes(filesModule) + + single { + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + explicitNulls = false + coerceInputValues = true + } + } + + // Main HttpClient (with auth interceptor) + single { + LibreChatHttpClient.create( + engineFactory = Darwin, + json = get(), + tokenManager = get(), + serverUrlProvider = get(), + ) + } + + // Streaming HttpClient (long-lived SSE connections) + single(KoinQualifiers.Streaming) { + val tokenManager = get() + val serverUrlProvider = get() + HttpClient(Darwin) { + 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, + LibreChatHttpClient.BROWSER_USER_AGENT, + ) + } + } + } + + // Refresh HttpClient (no auth interceptor, short timeout) + single(KoinQualifiers.Refresh) { + val json = get() + val serverUrlProvider = get() + HttpClient(Darwin) { + 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) + } + } + } + + // API services (all take HttpClient as constructor parameter) + singleOf(::AuthApi) + singleOf(::ChatApi) + singleOf(::ConfigApi) + singleOf(::ConversationsApi) + singleOf(::FilesApi) + singleOf(::FilesExtApi) + singleOf(::MessagesApi) + singleOf(::UserApi) + singleOf(::AgentsApi) + singleOf(::PresetsApi) + singleOf(::PromptsApi) + singleOf(::TagsApi) + singleOf(::ShareApi) + singleOf(::SearchApi) + singleOf(::BalanceApi) + singleOf(::BannerApi) + singleOf(::KeysApi) + singleOf(::ApiKeysApi) + singleOf(::McpApi) + singleOf(::MemoriesApi) + singleOf(::SpeechApi) + singleOf(::SseClient) + + // SDK facade + single { + LibreChatSDK( + authApi = get(), + chatApi = get(), + sseClient = get(), + tokenManager = get(), + ) + } +} diff --git a/shared/src/iosMain/kotlin/com/librechat/android/shared/MainViewController.kt b/shared/src/iosMain/kotlin/com/librechat/android/shared/MainViewController.kt new file mode 100644 index 0000000..a59c359 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/librechat/android/shared/MainViewController.kt @@ -0,0 +1,14 @@ +package com.librechat.android.shared + +import androidx.compose.ui.window.ComposeUIViewController +import com.librechat.android.shared.app.LibreChatApp +import platform.UIKit.UIViewController + +/** + * CMP entry point for iOS. + * Called from Swift to create a UIViewController that renders + * the full Compose Multiplatform UI. + */ +fun MainViewController(): UIViewController = ComposeUIViewController { + LibreChatApp() +}