Initial release

This commit is contained in:
Garfie 2026-03-09 22:00:00 -05:00
commit f182b2b82a
553 changed files with 64717 additions and 0 deletions

31
.github/CLAUDE.md vendored Normal file
View file

@ -0,0 +1,31 @@
# GitHub CI/CD
## Workflow: Android CI
File: `.github/workflows/android.yml`
### Triggers
- Push to `main` or `develop`
- Pull requests targeting `main`
### Pipeline Steps
1. Checkout code
2. Set up JDK 17 (Temurin)
3. Setup Gradle with caching (`~/.gradle/caches` and `~/.gradle/wrapper`)
4. `./gradlew lint` -- Android lint checks
5. `./gradlew test` -- unit tests across all modules
6. `./gradlew assembleDebug` -- build debug APK
7. Upload `app/build/outputs/apk/debug/app-debug.apk` as artifact (named `debug-apk`)
### Environment
- Runs on `ubuntu-latest`
- Gradle cache key based on `**/*.gradle*` and `gradle-wrapper.properties`
### Notes
- No release signing configured yet (debug builds only)
- No instrumented/UI tests in CI (only unit tests)
- APK artifact is available for download from the Actions run summary

50
.github/workflows/android.yml vendored Normal file
View file

@ -0,0 +1,50 @@
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

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# Gradle
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
# IDE
.idea/
*.iml
local.properties
# Kotlin
.kotlin/
# Android
*.apk
*.aab
*.dex
*.class
# Keystore
*.jks
*.keystore
# OS
.DS_Store
Thumbs.db

50
CLAUDE.md Normal file
View file

@ -0,0 +1,50 @@
# LibreChat Android
Native Android client for LibreChat. Connects to existing LibreChat backend servers (no backend changes). Users specify server URL during onboarding.
## Tech Stack
- **UI**: Jetpack Compose + Compose Navigation
- **DI**: Hilt
- **Network**: Ktor Client (OkHttp engine)
- **Serialization**: Kotlinx Serialization
- **Local Storage**: Room (cache), DataStore (prefs), EncryptedSharedPreferences (tokens)
- **Build**: Gradle 8.11.1, AGP 8.7.3, Kotlin 2.1.0, compileSdk 35, minSdk 26
## Module Layout
```
app/ → Single Activity, adaptive navigation (phone/tablet)
build-logic/ → 7 convention plugins for consistent Gradle config
core/common/ → Result type, dispatcher DI, coroutine scopes, extensions
core/model/ → @Serializable data classes (pure Kotlin, no Android deps)
core/network/ → Ktor client, 16 API services, SSE client, auth interceptor
core/data/ → Room DB, DataStore, EncryptedSharedPrefs, repository impls
core/ui/ → Material 3 theme, shared composables
feature/auth/ → Server URL, 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, danger zone
feature/agents/ → Agent marketplace with search and categories
feature/files/ → File upload, management, image viewer
```
Each module has its own `CLAUDE.md` with specific guidance.
## Architecture Rules
- Feature modules depend on `:core:*` only, never on each other
- Single Activity with Compose Navigation
- 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)
## Backend Quirks
- Mutation endpoints wrap body in `arg` field: `{ "arg": { ... } }`
- Two-phase SSE: POST → `{ streamId }`, then GET stream. `streamId === conversationId`
- `GET /api/config` drives feature availability — never hardcode
- ua-parser-js middleware rejects non-browser User-Agents with 403 (workaround: Chrome UA string)
- Refresh token sent via request body (not HTTP-only cookies)
@~/.claude/librechat-android-local.md

292
DISCOVERY.md Normal file
View file

@ -0,0 +1,292 @@
# LibreChat Android - Discovery & Context Document
## Project Goal
Build a native Android app (LibreChat-Android) with full feature parity to the LibreChat web application. The app must connect to an existing LibreChat backend server (no backend changes). Users specify the server URL during onboarding.
## Tech Stack (Mandated)
- **UI**: Jetpack Compose + Compose Navigation
- **DI**: Hilt
- **Network**: Ktor Client
- **Serialization**: Kotlinx Serialization
- **Min SDK**: TBD (recommend 26+)
---
## LibreChat Web Application Overview
### Architecture
- Monorepo: `/api` (Express/Node backend), `/client` (React/Vite frontend), `/packages` (shared libs)
- MongoDB database, optional Redis, optional Meilisearch
- JWT-based auth with refresh tokens
### Core Features (Full Parity Required)
#### 1. Authentication & Onboarding
- **Server URL entry** (Android-only: user specifies their LibreChat server)
- Local login (email/password)
- OAuth2 social logins (Google, GitHub, Discord, Facebook, Apple, OpenID, SAML)
- Registration with email verification
- Password reset flow
- Two-factor authentication (TOTP + backup codes)
- Terms of service acceptance
#### 2. Chat (Primary Experience)
- Conversation list in sidebar (cursor-paginated, sorted by updatedAt)
- Create new conversations
- Send messages, receive streaming AI responses via SSE
- Message tree with parent/child relationships and branching
- Sibling message navigation (e.g., "1/3" switcher for regenerated responses)
- Edit user messages (creates new branch)
- Regenerate AI responses
- Continue incomplete responses
- Stop generation mid-stream
- Fork conversations from any message
- Duplicate conversations
- Markdown rendering with syntax highlighting
- LaTeX/math rendering
- Code blocks with copy button and language badge
- Image display (inline + expandable)
- File attachments in messages
- Tool call display (expandable cards showing input/output)
- Message feedback (thumbs up/down with optional comment)
- Typing/streaming indicator
- Time-based greeting on landing ("Good morning", etc.)
- Custom welcome messages from server config
#### 3. Model/Endpoint Selection
- Multiple AI providers: OpenAI, Anthropic, Azure, Google, Groq, Mistral, Ollama, custom
- Model selector dropdown with search
- Endpoint icons (branded)
- Model parameters (temperature, top_p, frequency_penalty, etc.)
- Model specs from server config
#### 4. Agents & Assistants
- Agent marketplace (grid of cards with avatar, name, description, category)
- Agent chat with tool calling
- OpenAI Assistants integration
- Agent/assistant selection UI
#### 5. File Management
- Upload files (images, PDFs, documents, code)
- Drag-and-drop (not applicable on Android, use file picker)
- File preview in messages
- File download
- Image generation display
- Audio recording for voice input (STT)
- Text-to-speech playback (TTS)
#### 6. Conversation Management
- Rename conversations (inline)
- Archive/unarchive
- Delete (single + bulk)
- Share (generate public link)
- Export (JSON, markdown)
- Import conversations
- Tags for organization
- Bookmarks/favorites
- Search conversations (full-text via Meilisearch)
#### 7. Presets
- Save chat configurations as presets
- Load presets
- Delete presets
#### 8. Prompts Library
- Create/edit/delete prompts
- Share prompts
- Prompt versioning
- @mentions for prompts in chat input
- Prompt variables
#### 9. User Settings
- Theme (dark/light)
- Language selection (46+ languages)
- Font size
- Speech settings
- Data export
- Account management (profile, delete account)
- Balance/credits display
#### 10. Artifacts
- Split-pane code preview/editor
- Tabs: Preview, Code, Info
- Live editing
---
## API Endpoints (Key Routes)
### Authentication
```
POST /api/auth/login → { token, user } or { twoFAPending, tempToken }
POST /api/auth/register → { status, message }
POST /api/auth/logout → { status }
POST /api/auth/refresh → { token, user } (refresh token in cookies)
POST /api/auth/requestPasswordReset
POST /api/auth/resetPassword
GET /api/auth/2fa/enable → { secret, qrCode }
POST /api/auth/2fa/verify
POST /api/auth/2fa/confirm → { confirmed, backupCodes }
POST /api/auth/2fa/disable
POST /api/auth/2fa/verify-temp → { token, user }
```
### Configuration
```
GET /api/config → startup config (models, features, auth methods, interface config)
GET /api/endpoints → available AI providers and models
```
### Conversations
```
GET /api/convos → { conversations, nextCursor }
GET /api/convos/:conversationId → single conversation
POST /api/convos/update → update title
POST /api/convos/archive → archive/unarchive
DELETE /api/convos → delete conversation(s)
POST /api/convos/fork → fork from message
POST /api/convos/duplicate → duplicate conversation
POST /api/convos/import → import (multipart)
GET /api/convos/gen_title/:conversationId → generated title
```
### Messages
```
GET /api/messages/:conversationId → messages for conversation
GET /api/messages/:conversationId/:messageId
POST /api/messages/:conversationId → save message
PUT /api/messages/:conversationId/:messageId → update message
DELETE /api/messages/:conversationId/:messageId
PUT /api/messages/:conversationId/:messageId/feedback
```
### Chat (Streaming)
```
POST /api/agents/chat → { streamId } (start generation)
GET /api/agents/chat/stream/:streamId?resume=true → SSE stream
POST /api/agents/chat/abort → abort generation
GET /api/agents/chat/active → { activeJobIds }
GET /api/agents/chat/status/:conversationId → job status
```
### Files
```
GET /api/files → user's files
POST /api/files → upload (multipart)
GET /api/files/download/:userId/:file_id
DELETE /api/files → delete file(s)
POST /api/files/speech/stt → speech-to-text
POST /api/files/speech/tts → text-to-speech
```
### Other
```
GET/POST/DELETE /api/presets
GET/POST/PUT/DELETE /api/prompts
GET/POST/DELETE /api/tags
GET/POST/PATCH/DELETE /api/share
GET /api/balance
GET /api/search
GET /api/user
GET /api/banner
```
---
## Data Models
### User
- id, name, email, username, avatar, role, provider
- emailVerified, twoFactorEnabled
- favorites, termsAccepted
### Conversation
- conversationId (UUID), title, user, endpoint, model
- agent_id, assistant_id, tags, isArchived
- Model parameters (temperature, top_p, etc.)
### Message
- messageId (UUID), conversationId, parentMessageId
- user, sender, text, content (structured parts)
- isCreatedByUser, model, endpoint
- files, attachments, feedback
- error, unfinished, finish_reason, tokenCount
### File
- file_id, filename, filepath, type, bytes, source
- user, conversationId, messageId
---
## SSE Streaming Protocol
### Flow
1. POST to `/api/agents/chat` with message payload → returns `{ streamId }`
2. Connect SSE to `GET /api/agents/chat/stream/:streamId`
3. Receive events: message, step, created, attachment, final, sync, error
4. On disconnect: reconnect with `?resume=true` for sync event
### SSE Event Format
```
event: message
data: {"type":"content","chunk":"...","status":"streaming"}
event: message
data: {"type":"tool_call","toolName":"...","input":{...}}
event: message
data: {"sync":true,"resumeState":{"runSteps":[...],"aggregatedContent":[...]}}
```
### Reconnection
- Exponential backoff: 1s, 2s, 4s, 8s... max 30s
- Max 5 retries
- Resume preserves state via sync event
---
## Authentication Details
### Token Management
- JWT access token in `Authorization: Bearer <token>` header
- Refresh token stored as HTTP-only cookie (for web; Android should store securely)
- Access token expiry: ~15 minutes
- Refresh token expiry: ~24 hours
- Auto-refresh on 401 response
### OAuth Flow (Android)
- Open browser/Custom Chrome Tab for OAuth provider
- Callback redirect to app via deep link
- Exchange code for token
---
## UI/UX Reference (from Web App)
### Theme
- Light: White bg (#fff), text #212121, surface hover #e3e3e3
- Dark: #0d0d0d bg, text #ececf1, surface hover #424242
- Material 3 equivalents should be used on Android
### Key Screens
1. **Server URL Entry** (Android-only onboarding)
2. **Login/Register** with social login buttons
3. **Chat List** (sidebar on web → drawer or dedicated screen on Android)
4. **Chat View** (messages + input)
5. **Landing/New Chat** (greeting + model icon)
6. **Model Selector** (dropdown → bottom sheet on Android)
7. **Settings** (tabbed → Material 3 navigation)
8. **Agent Marketplace** (grid of cards)
9. **Search** (full-text search)
10. **File Viewer/Picker**
### Navigation Patterns (Android Adaptation)
- Web sidebar → Navigation drawer or bottom navigation
- Web modals → Bottom sheets or new screens
- Web dropdowns → Material 3 menus or bottom sheets
- Web hover actions → Long-press menus or always-visible icons
### Responsive Behavior
- Single-pane on phones (chat list or chat view, not both)
- Potential dual-pane on tablets
- Bottom navigation for primary actions

38
KNOWN_ISSUES.md Normal file
View file

@ -0,0 +1,38 @@
# Known Issues
## Tablet back navigation doesn't dismiss sidebar first
**Status**: Known issue, to be fixed later
**Symptoms**: In tablet mode with the sidebar open, pressing back navigates away from the current chat instead of dismissing the sidebar first. The sidebar remains covering the screen.
**Root cause**: The `BackHandler` in `TabletLayout.kt` is present but the system navigation's back handler takes priority. Needs investigation into back handler dispatch ordering relative to the NavHost.
**Workaround**: Tap the hamburger menu button to toggle the sidebar closed.
## Chat search: sub-message scroll precision
**Status**: Requires live debugging session
**Symptoms**: When navigating search results (prev/next) in the chat search feature, the view scrolls to the correct message but not to the specific text occurrence within long messages. The highlighted orange match can be off-screen if the message is tall.
**What works**: Match counting per-occurrence, yellow/orange highlighting, dark mode contrast, keyboard dismiss — all work correctly. The only issue is the scroll target precision within a message.
**Investigation so far** (2 rounds of fixes attempted):
1. **BringIntoViewRequester approach** (`122419c`): Attached a `BringIntoViewRequester` to the `HighlightedTextSegment` containing the focused occurrence. Called `bringIntoView()` after `animateScrollToItem()`. Didn't work because the `LazyColumn` treats each `MessageBubble` as a single item — `bringIntoView()` degrades to `scrollToItem()` and can't scroll within an item.
2. **onGloballyPositioned + animateScrollBy approach** (`c75a76a`): Replaced `BringIntoViewRequester` with a callback chain. The focused `HighlightedTextSegment` reports its pixel coordinates via `Modifier.onGloballyPositioned`. `MessageList` compares the segment's `boundsInRoot()` to the viewport and calls `animateScrollBy()` with the delta. Still not working — likely a timing issue where `onGloballyPositioned` fires before the initial `animateScrollToItem()` completes, or coordinate space mismatches between the segment and the viewport.
**Key files**:
- `feature/chat/.../components/MessageList.kt` — scroll logic, `pendingFineTuneScroll` flag
- `feature/chat/.../components/MarkdownContent.kt``HighlightedTextSegment` with `onGloballyPositioned`
- `feature/chat/.../components/ContentPartRenderer.kt` — forwards `onFocusedOccurrencePositioned` callback
- `feature/chat/.../components/MessageBubble.kt` — forwards callback when `isCurrentSearchMatch`
**What's needed**: On-device debugging session with Logcat/Timber to trace:
- Whether `onGloballyPositioned` fires at the right time (after `animateScrollToItem` completes)
- The actual coordinate values from `boundsInRoot()` vs viewport bounds
- Whether `animateScrollBy()` is called with the correct delta
- Whether the `pendingFineTuneScroll` guard allows or blocks the fine-tune scroll
**Workaround**: User can manually scroll within the message to find the orange-highlighted occurrence after the search navigates to the correct message.

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

67
README.md Normal file
View file

@ -0,0 +1,67 @@
# LibreChat Android
Native Android client for [LibreChat](https://www.librechat.ai/). Connects to any self-hosted LibreChat server — no backend modifications required.
## Features
- **Chat** — Real-time streaming (SSE), message branching & sibling navigation, stop/regenerate/continue, markdown with syntax highlighting, LaTeX math rendering, code blocks with copy, image display, file attachments, tool call progress cards
- **Model Selection** — Searchable bottom sheet grouped by endpoint, model comparison mode
- **Agents** — Marketplace with search and categories, MCP server configuration
- **Conversations** — Paginated list with date grouping, tags, search, rename, archive, delete, share, fork, duplicate, export/import
- **Presets & Prompts** — Save/load chat presets, prompts library with @mention insertion
- **Authentication** — Login, registration, forgot password, two-factor (TOTP + backup codes), OAuth (Google, GitHub, Discord, Facebook, Apple, OpenID)
- **Files** — Upload, list, delete, inline image rendering with pinch-to-zoom
- **Voice** — Speech-to-text input, text-to-speech playback (device and server engines)
- **Settings** — Theme (system/light/dark), account management, data controls
- **Tablet** — Adaptive dual-pane layout (600dp+) with persistent sidebar
- **Accessibility** — Semantic headings, content descriptions, 48dp touch targets, live regions
## Server Setup
The app works with any standard LibreChat server. During onboarding, you'll enter your server URL (e.g., `https://chat.example.com` or `http://192.168.1.100:3080`).
### Required Configuration
Add the following to your LibreChat server's `.env` file:
```env
# Safety net for native app clients.
# The app sends a browser User-Agent to pass the uaParser middleware,
# but if it ever fails to parse, this prevents ban point accumulation.
NON_BROWSER_VIOLATION_SCORE=0
```
Without this setting, the server's violation system may accumulate ban points against the Android client if the User-Agent check fails, eventually locking the account out.
### Notes
- **Registration** — The app respects your server's registration settings. If registration is disabled server-side, only the login form is shown.
## Building from Source
**Requirements:** Android Studio, JDK 17+
```bash
./gradlew assembleDebug
```
The debug APK will be at `app/build/outputs/apk/debug/app-debug.apk`.
For a release build:
```bash
./gradlew assembleRelease
```
### Tech Stack
- Jetpack Compose + Compose Navigation
- Hilt (dependency injection)
- Ktor Client with OkHttp engine
- Kotlinx Serialization
- Room (cache), DataStore (preferences), EncryptedSharedPreferences (tokens)
- Kotlin 2.1.0, compileSdk 35, minSdk 26
## License
This project is licensed under the [MIT License](LICENSE).

71
app/CLAUDE.md Normal file
View file

@ -0,0 +1,71 @@
# App Module
Single Activity architecture. `MainActivity` is the sole entry point, annotated `@AndroidEntryPoint`.
## Navigation
`LibreChatNavHost` is the root composable. It uses adaptive layout based on `WindowSizeClass`:
- **Phone**: `ModalNavigationDrawer` as primary navigation (sidebar-first pattern matching the web frontend). No bottom navigation bar. Drawer opens via hamburger button in chat header or swipe gesture.
- **Tablet** (600dp+ width): Persistent side panel with full conversation list. No `NavigationRail` or bottom bar.
## Sidebar / Drawer Content
`DrawerContent` is the rich sidebar matching the web's left panel:
- "New Chat" button at top
- Search bar with debounce filtering
- Conversation items grouped by date (Today, Yesterday, Previous 7 Days, etc.)
- Each item shows: endpoint icon, title, model name, relative time
- Active conversation highlighted with left border indicator and tinted background
- Footer: links to Agents, Files, Settings
- Sign out button at bottom
## Top-Level Destinations
Defined in `TopLevelDestination` enum: Chat, Conversations, Agents, Files, Settings.
Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes remain registered in the NavHost.
## Active Conversation Tracking
`NavHostViewModel.activeConversationId` tracks the currently viewed conversation. Updated automatically from the navigation back stack when the chat route changes. Used by `DrawerContent` to highlight the active conversation.
## Auth & Session
- `NavHostViewModel` observes auth state via `isLoggedIn` flow
- Start destination is `CHAT_GRAPH_ROUTE` if logged in, `AUTH_GRAPH_ROUTE` otherwise
- `sessionExpired` flow triggers nav to auth graph with full backstack clear
- Drawer gestures are hidden during auth flow
## Deep Linking
- Scheme: `librechat://conversation/{conversationId}`
- Handled in `MainActivity.handleDeepLink()` and forwarded to `LibreChatNavHost`
- `onNewIntent` handles deep links when app is already running
## Connectivity
- `ConnectivityObserver` injected into `MainActivity`
- Offline banner animates in/out at top of screen when connection is lost
## Dependencies
This module depends on all `:core:*` and all `:feature:*` modules.
It applies convention plugins: `librechat.android.application`, `librechat.android.compose`, `librechat.android.hilt`.
### Server-Synced Favorites
- `NavHostViewModel` exposes `favorites: StateFlow<Set<String>>` and `toggleFavorite()`
- Currently backed by `SettingsDataStore` (local) — server sync via `UserApi.getFavorites()/updateFavorites()` available but needs wiring
- `DrawerContent` shows star icon per conversation (filled = bookmarked)
- Favorites state passed through `LibreChatNavHost` and `TabletLayout`
### Server Banners
- `NavHostViewModel` fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()`
- Dismissed banner IDs tracked in-memory via `dismissedBannerIds: StateFlow<Set<String>>` (session-scoped, not persisted)
- `BannerDisplay` composable shown at top of content in both `LibreChatNavHost` (phone) and `TabletLayout`
- Banner types: "info" (blue), "warning" (amber), "error" (red) — defaults to "info" if type is null
- **Gotcha**: `displayFrom`/`displayTo` are ISO 8601 strings parsed with `Instant.parse()` — wrap in `runCatching` since the format from the server is not guaranteed
### Preset Navigation
- `PRESET_MANAGER_ROUTE` registered in `SettingsNavigation.kt`
- `PresetManagerScreen` accessible from Settings → Chat section
- Navigation wired in both `LibreChatNavHost` and `TabletLayout`

42
app/build.gradle.kts Normal file
View file

@ -0,0 +1,42 @@
plugins {
id("librechat.android.application")
id("librechat.android.compose")
id("librechat.android.hilt")
}
android {
namespace = "com.librechat.android"
defaultConfig {
applicationId = "com.librechat.android"
}
buildFeatures {
buildConfig = true
}
}
dependencies {
implementation(project(":core:ui"))
implementation(project(":core:data"))
implementation(project(":core:network"))
implementation(project(":core:model"))
implementation(project(":core:common"))
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.activity.compose)
implementation(libs.navigation.compose)
implementation(libs.hilt.navigation.compose)
implementation(libs.compose.material3.wsc)
implementation(libs.bundles.lifecycle)
implementation(libs.coil.compose)
implementation(libs.timber)
androidTestImplementation(libs.compose.ui.test)
debugImplementation(libs.compose.ui.test.manifest)
}

17
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,17 @@
# LibreChat Android ProGuard Rules
# Keep Kotlin serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
-keepclasseswithmembers class kotlinx.serialization.json.** { kotlinx.serialization.KSerializer serializer(...); }
-keep,includedescriptorclasses class com.librechat.android.**$$serializer { *; }
-keepclassmembers class com.librechat.android.** { *** Companion; }
-keepclasseswithmembers class com.librechat.android.** { kotlinx.serialization.KSerializer serializer(...); }
# Keep Ktor
-keep class io.ktor.** { *; }
-dontwarn io.ktor.**
# Keep Room entities
-keep class com.librechat.android.core.data.db.entity.** { *; }

View file

@ -0,0 +1,49 @@
package com.librechat.android
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import com.librechat.android.core.ui.theme.LibreChatTheme
import com.librechat.android.feature.auth.screen.ServerUrlScreen
import org.junit.Rule
import org.junit.Test
class AuthFlowTest {
@get:Rule
val composeTestRule = createComposeRule()
private fun setUpServerUrlScreen() {
composeTestRule.setContent {
LibreChatTheme {
ServerUrlScreen(
onServerValidated = {},
)
}
}
}
@Test
fun serverUrlScreen_displaysCorrectly() {
setUpServerUrlScreen()
composeTestRule.onNodeWithText("Connect to LibreChat").assertIsDisplayed()
composeTestRule.onNodeWithText("Server URL").assertIsDisplayed()
composeTestRule.onNodeWithText("Connect").assertIsDisplayed()
}
@Test
fun serverUrlScreen_connectButtonDisabledWhenEmpty() {
setUpServerUrlScreen()
composeTestRule.onNodeWithText("Connect").assertIsNotEnabled()
}
@Test
fun serverUrlScreen_enablesConnectOnInput() {
setUpServerUrlScreen()
composeTestRule.onNodeWithText("Server URL").performTextInput("https://example.com")
composeTestRule.onNodeWithText("Connect").assertIsEnabled()
}
}

View file

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:name=".LibreChatApplication"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="LibreChat"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LibreChat">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.LibreChat">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep links -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="librechat" />
</intent-filter>
<!-- Receive shared text -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- Receive shared images -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- Receive shared files -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<!-- Receive multiple shared items -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,86 @@
package com.librechat.android
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.librechat.android.core.network.client.TokenManager
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import timber.log.Timber
@HiltAndroidApp
class LibreChatApplication : Application(), ImageLoaderFactory {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ImageLoaderEntryPoint {
fun tokenManager(): TokenManager
}
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
}
override fun newImageLoader(): ImageLoader {
val entryPoint = EntryPointAccessors.fromApplication(
this, ImageLoaderEntryPoint::class.java
)
val tokenManager = entryPoint.tokenManager()
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
}
.build()
return ImageLoader.Builder(this)
.okHttpClient(okHttpClient)
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(cacheDir.resolve("image_cache"))
.maxSizeBytes(250L * 1024 * 1024) // 250 MB
.build()
}
.crossfade(true)
.build()
}
}

View file

@ -0,0 +1,192 @@
package com.librechat.android
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
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.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.SideEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import com.librechat.android.core.common.network.ConnectivityObserver
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.feature.chat.ShareIntentConsumer
import com.librechat.android.feature.chat.SharedContent
import com.librechat.android.navigation.LibreChatNavHost
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber
import javax.inject.Inject
import androidx.compose.ui.res.stringResource
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var connectivityObserver: ConnectivityObserver
@Inject lateinit var themeDataStore: ThemeDataStore
private var deepLinkUri by mutableStateOf<android.net.Uri?>(null)
/**
* Incremented each time a share intent arrives.
* The NavHost observes this and either:
* - Navigates to NEW_CHAT_ROUTE if the user is not on a chat screen, or
* - Lets the active ChatViewModel consume the shared content in-place if already on a chat screen.
*/
private var shareNavigationTrigger by mutableStateOf(0)
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
handleIntent(intent)
setContent {
val windowSizeClass = calculateWindowSizeClass(this)
val isConnected by connectivityObserver.isConnected.collectAsStateWithLifecycle(initialValue = true)
val themeMode by themeDataStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
val darkTheme = when (themeMode) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
ThemeMode.SYSTEM -> isSystemInDarkTheme()
}
// Update system bar icon colors to match the app's resolved theme.
// When light theme: dark icons on light background (isAppearanceLight = true).
// When dark theme: light icons on dark background (isAppearanceLight = false).
// This ensures correct visibility even when the user overrides the system theme.
SideEffect {
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
insetsController.isAppearanceLightStatusBars = !darkTheme
insetsController.isAppearanceLightNavigationBars = !darkTheme
}
LibreChatTheme(darkTheme = darkTheme) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
AnimatedVisibility(
visible = !isConnected,
enter = expandVertically(),
exit = shrinkVertically(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(vertical = 6.dp, horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.no_connection),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
LibreChatNavHost(
windowSizeClass = windowSizeClass,
deepLinkUri = deepLinkUri,
onDeepLinkConsumed = { deepLinkUri = null },
shareNavigationTrigger = shareNavigationTrigger,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent?) {
if (intent == null) return
when (intent.action) {
Intent.ACTION_SEND -> handleShareIntent(intent)
Intent.ACTION_SEND_MULTIPLE -> handleShareMultipleIntent(intent)
else -> handleDeepLink(intent)
}
}
private fun handleDeepLink(intent: Intent) {
intent.data?.let { uri ->
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)
}
}
}
companion object {
/** Hosts accepted by the librechat:// deep link scheme. */
private val KNOWN_DEEP_LINK_HOSTS = setOf("conversation", "oauth")
/** MongoDB ObjectID: exactly 24 lowercase hex characters. */
val CONVERSATION_ID_REGEX = Regex("^[a-f0-9]{24}$")
}
private fun handleShareIntent(intent: Intent) {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
@Suppress("DEPRECATION")
val sharedUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
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)
ShareIntentConsumer.setPendingShare(
SharedContent(text = sharedText, fileUris = fileUris),
)
shareNavigationTrigger++
}
}
private fun handleShareMultipleIntent(intent: Intent) {
@Suppress("DEPRECATION")
val sharedUris = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
if (!sharedUris.isNullOrEmpty()) {
Timber.d("Share multiple intent received: uris=%d", sharedUris.size)
ShareIntentConsumer.setPendingShare(
SharedContent(fileUris = sharedUris),
)
shareNavigationTrigger++
}
}
}

View file

@ -0,0 +1,520 @@
package com.librechat.android.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.Immutable
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.res.painterResource
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.model.EModelEndpoint
import com.librechat.android.core.ui.components.isMonochromeIcon
import com.librechat.android.core.ui.components.toIconRes
import com.librechat.android.R
import androidx.compose.ui.res.stringResource
/**
* 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,
)
// 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.
* State changes only recompose inside this composable not the parent
* PhoneLayout/TabletLayout, which avoids recomposing the NavHost/main content.
*/
@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,
onToggleFavorite: (String) -> Unit = {},
onRefresh: () -> Unit = {},
onLoadMore: () -> Unit = {},
modifier: Modifier = Modifier,
) {
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(R.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(R.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(R.string.cd_clear_search),
modifier = Modifier.size(20.dp),
)
}
}
},
placeholder = {
Text(
text = stringResource(R.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()
// Detect when scrolled near the end to trigger load-more
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(R.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(R.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) ->
// Date group header
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,
),
)
}
// Conversation items in this group
items(
items = displayItems,
key = { it.conversationId },
contentType = { "conversation" },
) { data ->
DrawerConversationItem(
data = data,
onClick = { onConversationClick(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data.conversationId) },
)
}
}
// Loading indicator at the bottom when loading more
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 + sign out
HorizontalDivider(
modifier = Modifier.padding(horizontal = 12.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
// Footer navigation links
DrawerFooterItem(
icon = Icons.Default.SmartToy,
label = stringResource(R.string.agents),
onClick = onAgentsClick,
)
DrawerFooterItem(
icon = Icons.Default.Folder,
label = stringResource(R.string.files),
onClick = onFilesClick,
)
DrawerFooterItem(
icon = Icons.Default.Settings,
label = stringResource(R.string.settings),
onClick = onSettingsClick,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
@Composable
private fun DrawerConversationItem(
data: DrawerConversationDisplayData,
onClick: () -> Unit,
onToggleFavorite: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val endpointIconRes = remember(data.endpoint) {
data.endpoint?.toIconRes()
}
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,
) {
// Active indicator (left border simulation)
if (data.isActive) {
Box(
modifier = Modifier
.width(3.dp)
.height(24.dp)
.background(MaterialTheme.colorScheme.primary, ActiveIndicatorShape),
)
Spacer(modifier = Modifier.width(8.dp))
}
// Endpoint icon or star for favorites
if (data.isFavorite) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
} else if (endpointIconRes != null) {
val isMonochrome = data.endpoint?.isMonochromeIcon() == true
Icon(
painter = painterResource(id = endpointIconRes),
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))
// Title and metadata
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,
)
// Model and time on second line
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,
)
}
}
// Bookmark toggle — lightweight clickable icon instead of IconButton
Icon(
imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
contentDescription = if (data.isFavorite) stringResource(R.string.remove_bookmark) else stringResource(R.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,
)
}
}

View file

@ -0,0 +1,428 @@
package com.librechat.android.navigation
import android.net.Uri
import com.librechat.android.MainActivity
import timber.log.Timber
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.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.unit.dp
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.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
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.core.ui.components.BannerDisplay
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 com.librechat.android.R
import androidx.compose.ui.res.stringResource
/** 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
}
@Composable
fun LibreChatNavHost(
modifier: Modifier = Modifier,
windowSizeClass: WindowSizeClass? = null,
deepLinkUri: Uri? = null,
onDeepLinkConsumed: () -> Unit = {},
shareNavigationTrigger: Int = 0,
navHostViewModel: NavHostViewModel = hiltViewModel(),
) {
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
LaunchedEffect(navBackStackEntry) {
val route = navBackStackEntry?.destination?.route
val conversationId = if (route == CHAT_ROUTE) {
navBackStackEntry?.arguments?.getString("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
val isTablet = windowSizeClass?.widthSizeClass?.let {
it >= WindowWidthSizeClass.Medium
} ?: false
if (isTablet) {
TabletLayout(
navController = navController,
navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = isInAuthFlow,
deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger,
modifier = modifier,
)
} else {
PhoneLayout(
navController = navController,
navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = isInAuthFlow,
deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger,
modifier = modifier,
)
}
// Version mismatch warning dialog (shown over any layout)
val versionMismatch by navHostViewModel.versionMismatch.collectAsStateWithLifecycle()
versionMismatch?.let { mismatch ->
VersionMismatchDialog(
supportedVersion = mismatch.supportedVersion,
backendVersion = mismatch.backendVersion,
onDismiss = navHostViewModel::dismissVersionWarning,
onDismissPermanently = navHostViewModel::dismissVersionWarningPermanently,
)
}
}
/**
* Dialog shown when the backend server version does not match the version
* this app was built for. Offers "Dismiss" (session-only) and
* "Don't warn again" (persisted per backend version) options.
*/
@Composable
private fun VersionMismatchDialog(
supportedVersion: String,
backendVersion: String,
onDismiss: () -> Unit,
onDismissPermanently: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.version_mismatch_title),
style = MaterialTheme.typography.headlineSmall,
)
},
text = {
Text(
text = stringResource(R.string.version_mismatch_message, supportedVersion, backendVersion),
style = MaterialTheme.typography.bodyMedium,
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dismiss))
}
},
dismissButton = {
TextButton(onClick = onDismissPermanently) {
Text(stringResource(R.string.dont_warn_again))
}
},
)
}
@Composable
private fun PhoneLayout(
navController: androidx.navigation.NavHostController,
navHostViewModel: NavHostViewModel,
startDestination: String,
isInAuthFlow: Boolean,
deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit,
shareNavigationTrigger: Int,
modifier: Modifier = Modifier,
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// Banner state only -- drawer state is collected inside DrawerContent itself
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
// Handle deep links
LaunchedEffect(deepLinkUri) {
deepLinkUri?.let { uri ->
if (uri.scheme == "librechat" && uri.host == "conversation") {
uri.lastPathSegment?.let { conversationId ->
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navController.navigateToChat(conversationId)
} else {
Timber.w("Ignoring deep link with invalid conversation ID: %s", conversationId)
}
}
}
onDeepLinkConsumed()
}
}
// Handle share intent: if already on a chat screen, let the active ChatViewModel
// consume the shared content reactively. Only navigate to new chat when on a
// non-chat screen (e.g. settings, agents, files).
LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) {
val currentRoute = navController.currentBackStackEntry?.destination?.route
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE
if (!isOnChatScreen) {
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
}
// If already on a chat screen, SharedIntentConsumer.shareAvailable
// will notify the active ChatViewModel to consume the content.
}
}
// 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() }
// Skip navigation if already on the new chat screen
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 ->
// Navigate to the settings sub-page but keep the drawer open
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
}
},
)
}
},
) {
// No bottom bar -- drawer is the primary navigation
Column(modifier = modifier.fillMaxSize()) {
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()
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()
},
)
}
}
}
}

View file

@ -0,0 +1,449 @@
package com.librechat.android.navigation
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.data.repository.VersionCheckResult
import com.librechat.android.core.common.result.Result
import com.librechat.android.core.common.extensions.toInstantOrNull
import com.librechat.android.core.common.extensions.toRelativeDateGroup
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 java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
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 kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject
/**
* Combined UI state for the drawer sidebar. A single emission replaces
* 10+ individual StateFlow collections in the parent composable, preventing
* unnecessary recomposition of the entire PhoneLayout/TabletLayout tree.
*/
@Immutable
data class DrawerUiState(
val groupedConversations: List<Pair<String, List<DrawerConversationDisplayData>>> = emptyList(),
val favoriteConversations: List<DrawerConversationDisplayData> = 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,
)
@HiltViewModel
class NavHostViewModel @Inject constructor(
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(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
/**
* Non-null when a version mismatch has been detected and should be shown to the user.
* Set to null after the user dismisses the dialog (for this session or permanently).
*/
private val _versionMismatch = MutableStateFlow<VersionMismatchState?>(null)
val versionMismatch: StateFlow<VersionMismatchState?> = _versionMismatch.asStateFlow()
private val _recentConversations = MutableStateFlow<List<Conversation>>(emptyList())
/** Conversations grouped by date (internal, feeds into drawerUiState). */
private val _groupedConversations = MutableStateFlow<List<Pair<String, List<Conversation>>>>(emptyList())
/** The ID of the currently viewed conversation (for active highlight). */
private val _activeConversationId = MutableStateFlow<String?>(null)
/** Drawer search query. */
private val _searchQuery = MutableStateFlow("")
/** Server-synced bookmarked conversation IDs. */
private val _favorites = MutableStateFlow<Set<String>>(emptySet())
/** Bookmarked/favorite conversations (internal, feeds into drawerUiState). */
private val _favoriteConversations = MutableStateFlow<List<Conversation>>(emptyList())
/** Pagination cursor for loading more conversations from the server. */
private var nextCursor: String? = null
/** Whether there are more conversations to load from the server. */
private val _hasMore = MutableStateFlow(true)
/** Whether a load-more request is in progress. */
private val _isLoadingMore = MutableStateFlow(false)
/** Whether a pull-to-refresh is in progress. */
private val _isRefreshing = MutableStateFlow(false)
/** Active banners from the server, filtered to non-expired. */
private val _banners = MutableStateFlow<List<Banner>>(emptyList())
val banners: StateFlow<List<Banner>> = _banners.asStateFlow()
/** Banner IDs dismissed by the user during this session. */
private val _dismissedBannerIds = MutableStateFlow<Set<String>>(emptySet())
val dismissedBannerIds: StateFlow<Set<String>> = _dismissedBannerIds.asStateFlow()
val sessionExpired: SharedFlow<Unit> = tokenManager.sessionExpiredFlow
/** Persisted sidebar open/close state for tablet mode.
* The initial value is read synchronously so that first composition
* sees the real persisted state instead of a hardcoded default,
* avoiding a visible open-then-close flicker on cold start. */
val tabletSidebarOpen: StateFlow<Boolean> = settingsDataStore.tabletSidebarOpen
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
runBlocking { settingsDataStore.tabletSidebarOpen.first() },
)
/** Whether the swipe gesture to open/close the tablet sidebar is enabled. */
val tabletSidebarGestureEnabled: StateFlow<Boolean> = settingsDataStore.tabletSidebarGestureEnabled
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
/** Current sidebar mode: Conversations (default) or Settings. */
private val _sidebarMode = MutableStateFlow<SidebarMode>(SidebarMode.Conversations)
val sidebarMode: StateFlow<SidebarMode> = _sidebarMode.asStateFlow()
/** Currently selected settings category in the sidebar nav menu. */
private val _selectedSettingsCategory = MutableStateFlow<SettingsCategory?>(null)
val selectedSettingsCategory: StateFlow<SettingsCategory?> = _selectedSettingsCategory.asStateFlow()
fun setSidebarMode(mode: SidebarMode) {
_sidebarMode.value = mode
}
fun selectSettingsCategory(category: SettingsCategory) {
_selectedSettingsCategory.value = category
}
private var searchJob: Job? = null
/**
* Single combined drawer UI state. All display-data mapping (Conversation
* DrawerConversationDisplayData) happens here in the ViewModel, not in composition.
* Collected inside DrawerContent so state changes only recompose the drawer,
* not the entire PhoneLayout/TabletLayout.
*/
val drawerUiState: StateFlow<DrawerUiState> = 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 {
val loggedIn = authRepository.isLoggedIn()
_isLoggedIn.value = loggedIn
// Check backend version on cold start for returning users
if (loggedIn) {
checkBackendVersion()
}
}
observeConversations()
observeBookmarks()
fetchBanners()
loadInitialConversations()
}
private fun observeConversations() {
viewModelScope.launch {
conversationRepository.observeConversations().collect { result ->
if (result is Result.Success) {
_recentConversations.value = result.data
// Only regroup if not in search mode
if (_searchQuery.value.isBlank()) {
_groupedConversations.value = groupConversationsByDate(result.data)
}
updateFavoriteConversations()
}
}
}
}
private fun loadInitialConversations() {
viewModelScope.launch {
val result = conversationRepository.loadNextPage(cursor = null)
if (result is Result.Success) {
nextCursor = result.data
_hasMore.value = result.data != null
}
}
}
fun loadMoreConversations() {
if (_isLoadingMore.value || !_hasMore.value) return
_isLoadingMore.value = true
viewModelScope.launch {
val result = conversationRepository.loadNextPage(cursor = nextCursor)
if (result is Result.Success) {
nextCursor = result.data
_hasMore.value = result.data != null
} else {
Timber.w("Failed to load more conversations")
}
_isLoadingMore.value = false
}
}
fun onAuthComplete() {
_isLoggedIn.value = true
loadInitialConversations()
fetchBanners()
checkBackendVersion()
}
fun refreshConversations() {
_isRefreshing.value = true
viewModelScope.launch {
nextCursor = null
_hasMore.value = true
val result = conversationRepository.loadNextPage(cursor = null)
if (result is Result.Success) {
nextCursor = result.data
_hasMore.value = result.data != null
}
_isRefreshing.value = false
}
}
fun setActiveConversation(conversationId: String?) {
_activeConversationId.value = conversationId
}
fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
searchJob?.cancel()
if (query.isBlank()) {
// Reset to full grouped list from cached conversations
_groupedConversations.value = groupConversationsByDate(_recentConversations.value)
return
}
searchJob = viewModelScope.launch {
delay(300) // debounce
val filtered = _recentConversations.value.filter { conversation ->
conversation.title?.contains(query, ignoreCase = true) == true
}
_groupedConversations.value = groupConversationsByDate(filtered)
}
}
private fun observeBookmarks() {
viewModelScope.launch {
settingsDataStore.bookmarkedConversationIds.collect { bookmarkedIds ->
_favorites.value = bookmarkedIds
updateFavoriteConversations()
}
}
}
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 {
val result = bannerRepository.getBanners()
if (result is Result.Success) {
val now = Instant.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)
afterStart && beforeEnd
}
}
}
}
fun setTabletSidebarOpen(open: Boolean) {
viewModelScope.launch {
settingsDataStore.setTabletSidebarOpen(open)
}
}
fun dismissBanner(bannerId: String) {
_dismissedBannerIds.value = _dismissedBannerIds.value + bannerId
}
/**
* Performs an async, non-blocking version check against the backend.
* If the versions are incompatible and the user hasn't dismissed the warning
* for this specific backend version, sets [versionMismatch] to trigger the dialog.
*/
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) {
// Check if the user has already dismissed the warning for this version
val dismissedVersion = settingsDataStore.dismissedVersionWarning.first()
if (dismissedVersion != detectedVersion) {
_versionMismatch.value = VersionMismatchState(
supportedVersion = checkResult.supportedVersion,
backendVersion = detectedVersion,
)
} else {
Timber.d(
"Version mismatch (backend=%s, supported=%s) but user dismissed for this version",
detectedVersion,
checkResult.supportedVersion,
)
}
}
}
// On error, silently ignore -- the version check is non-critical
}
}
/** Dismiss the version mismatch dialog for this session only. */
fun dismissVersionWarning() {
_versionMismatch.value = null
}
/** Dismiss the version mismatch dialog and suppress it for this backend version permanently. */
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<Conversation>,
): List<Pair<String, List<Conversation>>> {
if (conversations.isEmpty()) return emptyList()
return conversations
.groupBy { conversation ->
conversation.updatedAt
?.toInstantOrNull()
?.toRelativeDateGroup()
?: "Unknown"
}
.toList()
}
}
/** Intermediate holder for the 5-argument combine. */
private data class DrawerDataSnapshot(
val grouped: List<Pair<String, List<Conversation>>>,
val activeId: String?,
val favIds: Set<String>,
val favConvos: List<Conversation>,
val query: String,
)
private fun Conversation.toDrawerDisplayData(
activeConversationId: String?,
favoriteIds: Set<String>,
): 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 = Instant.now()
val minutes = ChronoUnit.MINUTES.between(this, now)
val hours = ChronoUnit.HOURS.between(this, now)
val days = ChronoUnit.DAYS.between(this, now)
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"))
}
}

View file

@ -0,0 +1,151 @@
package com.librechat.android.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.draw.clip
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 com.librechat.android.R
import androidx.compose.ui.res.stringResource
/**
* Settings sidebar content shown when the sidebar mode is [SidebarMode.Settings].
* Displays a simple vertical list of settings categories. Tapping a category
* navigates to its sub-page in the main content area.
*/
@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),
) {
// Header with back arrow
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(R.string.cd_back_to_conversations),
)
}
Text(
text = stringResource(R.string.settings),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
.semantics { heading() },
)
}
Spacer(modifier = Modifier.padding(top = 8.dp))
// Category list
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)
.clip(RoundedCornerShape(12.dp))
.background(backgroundColor, RoundedCornerShape(12.dp))
.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,
)
}
}

View file

@ -0,0 +1,17 @@
package com.librechat.android.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"),
}

View file

@ -0,0 +1,75 @@
package com.librechat.android.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.
*
* The Settings footer button now navigates directly to the tabbed settings
* screen via [onSettingsClick], rather than switching sidebar mode.
* The mode-switching infrastructure is preserved for potential future use.
*/
@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) {
// Slide in from right when switching to Settings
slideInHorizontally { fullWidth -> fullWidth } togetherWith
slideOutHorizontally { fullWidth -> -fullWidth }
} else {
// Slide in from left when switching back to Conversations
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)
},
)
}
}
}
}

View file

@ -0,0 +1,442 @@
package com.librechat.android.navigation
import android.net.Uri
import com.librechat.android.MainActivity
import timber.log.Timber
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
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.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.ui.layout.Layout
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
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_TABBED_ROUTE
import com.librechat.android.feature.settings.navigation.SHARED_LINKS_ROUTE
import com.librechat.android.feature.settings.navigation.settingsGraph
import kotlinx.coroutines.launch
private val SidebarWidth = 320.dp
/** Velocity (px/s) above which a fling commits the gesture regardless of position. */
private const val FLING_VELOCITY_THRESHOLD = 800f
@Composable
fun TabletLayout(
navController: NavHostController,
navHostViewModel: NavHostViewModel,
startDestination: String,
isInAuthFlow: Boolean,
deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit,
shareNavigationTrigger: Int = 0,
modifier: Modifier = Modifier,
) {
// Banner state only -- drawer state is collected inside DrawerContent itself
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
// Persisted sidebar state from DataStore -- single source of truth in the ViewModel.
// The ViewModel's StateFlow initial value is read synchronously from DataStore,
// so the first composition already has the correct persisted state (no flicker).
val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle()
// Whether swipe gesture is enabled (from settings)
val gestureEnabled by navHostViewModel.tabletSidebarGestureEnabled.collectAsStateWithLifecycle()
val density = LocalDensity.current
val sidebarWidthPx = with(density) { SidebarWidth.toPx() }
// Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open.
// During a drag it follows the finger; on release it animates to 0 or sidebarWidthPx.
val sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) }
val scope = rememberCoroutineScope()
// Back press closes sidebar before navigating away
BackHandler(enabled = isSidebarOpen) {
navHostViewModel.setTabletSidebarOpen(false)
}
// Handle deep links
LaunchedEffect(deepLinkUri) {
deepLinkUri?.let { uri ->
if (uri.scheme == "librechat" && uri.host == "conversation") {
uri.lastPathSegment?.let { conversationId ->
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navController.navigateToChat(conversationId)
} else {
Timber.w("Ignoring deep link with invalid conversation ID: %s", conversationId)
}
}
}
onDeepLinkConsumed()
}
}
// Handle share intent: if already on a chat screen, let the active ChatViewModel
// consume the shared content reactively. Only navigate to new chat when on a
// non-chat screen (e.g. settings, agents, files).
LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) {
val currentRoute = navController.currentBackStackEntry?.destination?.route
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE
if (!isOnChatScreen) {
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
}
}
}
// Sync: when ViewModel state changes (e.g. hamburger button, back press),
// animate the sidebar to match.
LaunchedEffect(isSidebarOpen) {
val target = if (isSidebarOpen) sidebarWidthPx else 0f
if (sidebarOffset.value != target) {
sidebarOffset.animateTo(
targetValue = target,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
),
)
}
}
if (!isInAuthFlow) {
val swipeModifier = if (gestureEnabled) {
Modifier.pointerInput(Unit) {
val velocityTracker = VelocityTracker()
detectHorizontalDragGestures(
onDragStart = {
velocityTracker.resetTracking()
},
onDragEnd = {
val velocity = velocityTracker.calculateVelocity().x
val currentOffset = sidebarOffset.value
// Decide target: fling velocity wins, otherwise use 50% threshold
val shouldOpen = when {
velocity > FLING_VELOCITY_THRESHOLD -> true
velocity < -FLING_VELOCITY_THRESHOLD -> false
else -> currentOffset > sidebarWidthPx * 0.5f
}
scope.launch {
sidebarOffset.animateTo(
targetValue = if (shouldOpen) sidebarWidthPx else 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
),
)
navHostViewModel.setTabletSidebarOpen(shouldOpen)
}
},
onDragCancel = {
// Snap back to the current committed state
val target = if (isSidebarOpen) sidebarWidthPx else 0f
scope.launch {
sidebarOffset.animateTo(
targetValue = target,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
),
)
}
},
onHorizontalDrag = { change, dragAmount ->
change.consume()
velocityTracker.addPosition(
change.uptimeMillis,
change.position,
)
scope.launch {
val newOffset = (sidebarOffset.value + dragAmount)
.coerceIn(0f, sidebarWidthPx)
sidebarOffset.snapTo(newOffset)
}
},
)
}
} else {
Modifier
}
Layout(
content = {
// Slot 0: Sidebar -- always 320dp, slides from off-screen left to x=0
Row(modifier = Modifier.fillMaxHeight()) {
SidebarScaffold(
viewModel = navHostViewModel,
onNewChat = {
// Skip navigation if already on the new chat screen
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 ->
navController.navigateToChat(conversationId)
},
onSettingsClick = {
navController.navigate(SETTINGS_TABBED_ROUTE) {
launchSingleTop = true
}
},
onSettingsCategorySelected = { category ->
navController.navigate(category.toRoute()) {
launchSingleTop = true
}
},
onAgentsClick = {
navController.navigate(TopLevelDestination.AGENTS.route) {
launchSingleTop = true
}
},
onFilesClick = {
navController.navigate(TopLevelDestination.FILES.route) {
launchSingleTop = true
}
},
modifier = Modifier
.width(SidebarWidth)
.fillMaxHeight(),
)
VerticalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
)
}
// Slot 1: Main content -- resizes to fill remaining space
MainContent(
navController = navController,
navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = false,
banners = banners,
dismissedBannerIds = dismissedBannerIds,
onToggleDrawer = {
navHostViewModel.setTabletSidebarOpen(!isSidebarOpen)
},
)
},
modifier = modifier.fillMaxSize().then(swipeModifier).clipToBounds(),
) { measurables, constraints ->
val sidebarPx = SidebarWidth.roundToPx()
val animatedPx = sidebarOffset.value.toInt()
// Sidebar: always measured at full 320dp width
val sidebarPlaceable = measurables[0].measure(
constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx),
)
// Main content: resizes to fill screen minus the visible sidebar portion
val mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0)
val mainPlaceable = measurables[1].measure(
constraints.copy(minWidth = mainWidth, maxWidth = mainWidth),
)
layout(constraints.maxWidth, constraints.maxHeight) {
// Sidebar slides: -320px (off-screen) -> 0px (fully visible)
sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0)
// Main content starts right after the visible sidebar portion
mainPlaceable.placeRelative(animatedPx, 0)
}
}
} else {
// Auth flow -- no sidebar, just main content
MainContent(
navController = navController,
navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = true,
banners = banners,
dismissedBannerIds = dismissedBannerIds,
onToggleDrawer = {},
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun MainContent(
navController: NavHostController,
navHostViewModel: NavHostViewModel,
startDestination: String,
isInAuthFlow: Boolean,
banners: List<com.librechat.android.core.model.Banner>,
dismissedBannerIds: Set<String>,
onToggleDrawer: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
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 = onToggleDrawer,
)
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()
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()
},
)
}
}
}

View file

@ -0,0 +1,28 @@
package com.librechat.android.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.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 now sidebar-first (drawer with conversation list),
* matching the web frontend pattern. Bottom nav has been removed entirely.
*
* Conversations are integrated into the drawer body (not a separate destination).
* Agents and Files are accessible via drawer footer links.
* Settings are accessed through the sidebar settings nav menu (see [SettingsCategory]).
*/
enum class TopLevelDestination(
val route: String,
val icon: ImageVector,
val label: String,
) {
CHAT("chat_graph", Icons.Default.Chat, "Chat"),
CONVERSATIONS("conversations", Icons.Default.Forum, "Conversations"),
AGENTS("agents", Icons.Default.SmartToy, "Agents"),
FILES("files", Icons.Default.Folder, "Files"),
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LibreChat" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<color name="splash_background">#0D0D0D</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1A0A3E</color>
</resources>

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LibreChat</string>
<!-- Connectivity -->
<string name="no_connection">No internet connection</string>
<!-- Drawer -->
<string name="new_chat">New chat</string>
<string name="favorites">Favorites</string>
<string name="no_conversations_found">No conversations found</string>
<string name="remove_bookmark">Remove bookmark</string>
<string name="bookmark">Bookmark</string>
<string name="agents">Agents</string>
<string name="files">Files</string>
<string name="settings">Settings</string>
<!-- Content descriptions -->
<string name="cd_search">Search</string>
<string name="cd_clear_search">Clear search</string>
<string name="cd_back_to_conversations">Back to conversations</string>
<!-- Search -->
<string name="search_conversations_placeholder">Search conversations\u2026</string>
<!-- Version mismatch dialog -->
<string name="version_mismatch_title">Backend Version Mismatch</string>
<string name="version_mismatch_message">This app was built for LibreChat v%1$s, but your server is running v%2$s. Some features may not work correctly.</string>
<string name="dismiss">Dismiss</string>
<string name="dont_warn_again">Don\'t warn again</string>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LibreChat" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<color name="splash_background">#FFFFFF</color>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="artifacts" path="artifacts/" />
<cache-path name="shared_images" path="shared_images/" />
<cache-path name="camera_photos" path="camera_photos/" />
</paths>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!--
Cleartext (HTTP) traffic is intentionally permitted because LibreChat is a
self-hosted server that users may run on their local network over plain HTTP
(e.g. http://192.168.x.x:3080 or http://localhost:3080).
Android's network_security_config is static and cannot be toggled at runtime,
so we cannot restrict cleartext to specific domains since the server URL is
user-configured. Instead, the app displays an HTTP warning dialog at server
setup time (ServerUrlScreen) when the user enters an HTTP URL, informing them
that their connection will not be encrypted. The user must explicitly
acknowledge this risk before proceeding.
HTTPS is always preferred and no warning is shown for HTTPS URLs.
-->
<base-config cleartextTrafficPermitted="true" />
</network-security-config>

37
build-logic/CLAUDE.md Normal file
View file

@ -0,0 +1,37 @@
# Build Logic
Convention plugins that apply consistent Gradle configuration across all modules.
## Convention Plugins (7 total)
| Plugin ID | Class | What it does |
|-----------|-------|-------------|
| `librechat.android.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 35, minSdk 26, JDK 17 |
| `librechat.android.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config |
| `librechat.android.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 |
| `librechat.android.hilt` | `AndroidHiltConventionPlugin` | Hilt + KSP annotation processing |
| `librechat.android.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + hilt + serialization |
| `librechat.android.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config |
| `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency |
## Critical: apply() Signature
Convention plugin `apply()` MUST use:
```kotlin
override fun apply(target: Project) {
with(target) { ... }
}
```
Do NOT use `override fun apply(target: Project) = with(target) { ... }` -- that returns
the `with` result instead of `Unit`, which causes a Gradle error.
## Version Catalog
All dependency versions live in `gradle/libs.versions.toml`. Never hardcode versions in
build.gradle.kts files. Use `libs.` accessors (e.g., `libs.hilt.android`, `libs.ktor.client.core`).
## Feature Module Convention
Feature modules use `id("librechat.android.feature")` which auto-applies library, compose,
hilt, and serialization. They only need to add feature-specific dependencies.
Feature modules depend on `:core:*` only, never on other feature modules.

View file

@ -0,0 +1,44 @@
plugins {
`kotlin-dsl`
}
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.compose.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.room.gradlePlugin)
}
gradlePlugin {
plugins {
register("androidApplication") {
id = "librechat.android.application"
implementationClass = "AndroidApplicationConventionPlugin"
}
register("androidLibrary") {
id = "librechat.android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidCompose") {
id = "librechat.android.compose"
implementationClass = "AndroidComposeConventionPlugin"
}
register("androidHilt") {
id = "librechat.android.hilt"
implementationClass = "AndroidHiltConventionPlugin"
}
register("androidFeature") {
id = "librechat.android.feature"
implementationClass = "AndroidFeatureConventionPlugin"
}
register("androidRoom") {
id = "librechat.android.room"
implementationClass = "AndroidRoomConventionPlugin"
}
register("kotlinSerialization") {
id = "librechat.kotlin.serialization"
implementationClass = "KotlinSerializationConventionPlugin"
}
}
}

View file

@ -0,0 +1,3 @@
# build-logic is a standalone project; mirror root settings
org.gradle.parallel=true
org.gradle.caching=true

View file

@ -0,0 +1,46 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.application")
pluginManager.apply("org.jetbrains.kotlin.android")
extensions.configure<ApplicationExtension> {
compileSdk = 35
defaultConfig {
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17
targetCompatibility = org.gradle.api.JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
buildTypes {
release {
isMinifyEnabled = true
signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
}
dependencies.add("coreLibraryDesugaring", libs.findLibrary("desugar-jdk").get())
dependencies.add("testImplementation", libs.findBundle("testing").get())
}
}
}
private val Project.libs
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
.named("libs")

View file

@ -0,0 +1,59 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
class AndroidComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
// Stability configuration — marks core model classes as @Stable so Compose
// can skip recomposition when parameters haven't changed.
val stabilityConfig = rootProject.file("compose-stability.conf")
if (stabilityConfig.exists()) {
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.addAll(
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=${stabilityConfig.absolutePath}",
)
}
}
}
// Compose compiler metrics/reports (opt-in via -PenableComposeCompilerReports=true)
if (providers.gradleProperty("enableComposeCompilerReports").orNull == "true") {
val reportsDir = layout.buildDirectory.dir("compose_metrics").get().asFile.absolutePath
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.addAll(
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=$reportsDir",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=$reportsDir",
)
}
}
}
extensions.findByType(com.android.build.gradle.LibraryExtension::class.java)?.apply {
buildFeatures.compose = true
}
extensions.findByType(com.android.build.api.dsl.ApplicationExtension::class.java)?.apply {
buildFeatures.compose = true
}
dependencies {
val bom = libs.findLibrary("compose-bom").get()
add("implementation", platform(bom))
add("implementation", libs.findBundle("compose").get())
add("debugImplementation", libs.findLibrary("compose-ui-tooling").get())
add("androidTestImplementation", platform(bom))
add("androidTestImplementation", libs.findLibrary("compose-ui-test").get())
}
}
}
}
private val Project.libs
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
.named("libs")

View file

@ -0,0 +1,27 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("librechat.android.library")
pluginManager.apply("librechat.android.compose")
pluginManager.apply("librechat.android.hilt")
dependencies {
add("implementation", project(":core:ui"))
add("implementation", project(":core:data"))
add("implementation", project(":core:model"))
add("implementation", project(":core:common"))
add("implementation", libs.findLibrary("hilt-navigation-compose").get())
add("implementation", libs.findLibrary("navigation-compose").get())
add("implementation", libs.findBundle("lifecycle").get())
}
}
}
}
private val Project.libs
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
.named("libs")

View file

@ -0,0 +1,21 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class AndroidHiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.google.dagger.hilt.android")
pluginManager.apply("com.google.devtools.ksp")
dependencies {
add("implementation", libs.findLibrary("hilt-android").get())
add("ksp", libs.findLibrary("hilt-compiler").get())
}
}
}
}
private val Project.libs
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
.named("libs")

View file

@ -0,0 +1,36 @@
import com.android.build.gradle.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.android")
extensions.configure<LibraryExtension> {
compileSdk = 35
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
}
testOptions {
unitTests.isReturnDefaultValues = true
}
}
dependencies.add("coreLibraryDesugaring", libs.findLibrary("desugar-jdk").get())
dependencies.add("testImplementation", libs.findBundle("testing").get())
}
}
}
private val Project.libs
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
.named("libs")

View file

@ -0,0 +1,27 @@
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 AndroidRoomConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("androidx.room")
pluginManager.apply("com.google.devtools.ksp")
extensions.configure<RoomExtension> {
schemaDirectory("$projectDir/schemas")
}
dependencies {
add("implementation", libs.findBundle("room").get())
add("ksp", libs.findLibrary("room-compiler").get())
}
}
}
}
private val Project.libs
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
.named("libs")

View file

@ -0,0 +1,19 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class KotlinSerializationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("org.jetbrains.kotlin.plugin.serialization")
dependencies {
add("implementation", libs.findLibrary("kotlinx-serialization-json").get())
}
}
}
}
private val Project.libs
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
.named("libs")

View file

@ -0,0 +1,2 @@
org.gradle.parallel=true
org.gradle.caching=true

View file

@ -0,0 +1,15 @@
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
include(":convention")

10
build.gradle.kts Normal file
View file

@ -0,0 +1,10 @@
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.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.room) apply false
}

44
compose-stability.conf Normal file
View file

@ -0,0 +1,44 @@
// Compose Compiler Stability Configuration
// Classes listed here are treated as @Stable by the Compose compiler,
// 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
// Kotlin stdlib collections (we only use immutable instances in practice)
kotlin.collections.List
kotlin.collections.Map
kotlin.collections.Set

41
core/common/CLAUDE.md Normal file
View file

@ -0,0 +1,41 @@
# core:common
Pure Kotlin utilities shared by all modules. This is the lowest layer -- no other `:core:*` module is a dependency.
## What This Module Provides
- **Result sealed class** (`result/Result.kt`): `Success<T>`, `Error(exception, message)`, `Loading`. Used by repositories and ViewModels to propagate outcomes.
- **Dispatcher DI** (`di/DispatcherModule.kt`): `@Dispatcher(IO)`, `@Dispatcher(Default)`, `@Dispatcher(Main)` qualifiers for Hilt injection. Always inject dispatchers -- never hardcode `Dispatchers.IO`.
- **CoroutineScope DI** (`di/CoroutineScopeModule.kt`): `@ApplicationScope` for work that outlives ViewModels.
- **Extensions** (`extensions/`): `StringExt`, `DateExt`, `FlowExt` (includes `retryWithBackoff`, `throttleFirst`).
- **ConnectivityObserver**: Wraps Android `ConnectivityManager.NetworkCallback` to detect network changes. Used by SSE reconnection logic.
## safeApiCall Pattern
All repository implementations use this to wrap network calls:
```kotlin
suspend fun <T> safeApiCall(block: suspend () -> T): Result<T> =
try {
Result.Success(block())
} catch (e: ClientRequestException) { // 4xx
Result.Error(e, parseErrorMessage(e.response.bodyAsText()))
} catch (e: ServerResponseException) { // 5xx
Result.Error(e, "Server error. Please try again.")
} catch (e: HttpRequestTimeoutException) {
Result.Error(e, "Request timed out.")
} catch (e: IOException) {
Result.Error(e, "Network unavailable. Check your connection.")
} catch (e: SerializationException) {
Result.Error(e, "Unexpected response format.")
}
```
This lives here (not in `:core:network`) because repositories in `:core:data` call it.
## Rules
- **Pure Kotlin preferred.** Minimal Android dependencies (only what ConnectivityObserver and Hilt require).
- **No network or data dependencies.** This module must not depend on `:core:network`, `:core:data`, or `:core:model`.
- Dependencies: `coroutines-core`, `coroutines-android`, Hilt.
- Convention plugin: `librechat.android.library` + `librechat.android.hilt`.

View file

@ -0,0 +1,13 @@
plugins {
id("librechat.android.library")
id("librechat.android.hilt")
}
android {
namespace = "com.librechat.android.core.common"
}
dependencies {
implementation(libs.coroutines.core)
implementation(libs.coroutines.android)
}

View file

@ -0,0 +1,75 @@
package com.librechat.android.core.common
/**
* Backend version compatibility constants and comparison utilities.
*
* The LibreChat backend does not currently expose a version endpoint.
* The version is determined by:
* 1. A `version` field in the `/api/config` response (if the backend adds one in the future)
* 2. Parsing the `customFooter` field for a `LibreChat vX.Y.Z` pattern
* (when customFooter is null, the web frontend displays the default version from Constants.VERSION)
*
* If the version cannot be determined, the check is silently skipped.
*/
object BackendVersion {
/**
* The LibreChat backend version this app was built and tested against.
* Matches the VERSION constant from the official LibreChat repo's
* `packages/data-provider/src/config.ts` and `package.json`.
*/
const val SUPPORTED_BACKEND_VERSION = "0.8.2"
/**
* Represents a parsed semantic version (major.minor.patch).
*/
data class SemanticVersion(
val major: Int,
val minor: Int,
val patch: Int,
) {
override fun toString(): String = "$major.$minor.$patch"
}
/**
* Parses a version string like "0.8.2", "v0.8.2", "0.8", or "v0.8" into a [SemanticVersion].
* Returns null if the string cannot be parsed.
*/
fun parse(version: String): SemanticVersion? {
val cleaned = version.trimStart('v', 'V').trim()
val parts = cleaned.split('.')
if (parts.size < 2) return null
val major = parts[0].toIntOrNull() ?: return null
val minor = parts[1].toIntOrNull() ?: return null
val patch = if (parts.size >= 3) parts[2].toIntOrNull() ?: 0 else 0
return SemanticVersion(major, minor, patch)
}
/**
* Checks whether two versions are compatible.
* A mismatch in major or minor version is considered incompatible.
* Patch differences are ignored (considered compatible).
*
* @return true if the versions are compatible (same major and minor), false otherwise.
* Returns true if either version cannot be parsed (fail-open).
*/
fun isCompatible(supported: String, actual: String): Boolean {
val supportedVersion = parse(supported) ?: return true
val actualVersion = parse(actual) ?: return true
return supportedVersion.major == actualVersion.major &&
supportedVersion.minor == actualVersion.minor
}
/**
* Extracts a version string from a LibreChat customFooter value.
* The default footer format is: `[LibreChat vX.Y.Z](https://librechat.ai) - ...`
* Also handles variations like `LibreChat v0.8.2` without markdown links.
*
* @return The extracted version string (without 'v' prefix), or null if not found.
*/
fun extractVersionFromFooter(footer: String?): String? {
if (footer == null) return null
val regex = Regex("""LibreChat\s+v?(\d+\.\d+(?:\.\d+)?)""", RegexOption.IGNORE_CASE)
return regex.find(footer)?.groupValues?.get(1)
}
}

View file

@ -0,0 +1,17 @@
package com.librechat.android.core.common
object EndpointConstants {
const val AGENTS = "agents"
}
object ToolConstants {
const val WEB_SEARCH = "web_search"
const val CODE_INTERPRETER = "code_interpreter"
const val FILE_SEARCH = "file_search"
const val EXECUTE_CODE = "execute_code"
}
object ChatLayoutConstants {
const val THREAD = "thread"
const val TWO_SIDED = "two_sided"
}

View file

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

View file

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

View file

@ -0,0 +1,26 @@
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()

View file

@ -0,0 +1,31 @@
package com.librechat.android.core.common.extensions
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.retryWhen
import kotlin.math.pow
fun <T> Flow<T>.retryWithBackoff(
maxRetries: Int = 3,
initialDelay: Long = 1000L,
maxDelay: Long = 30000L,
factor: Double = 2.0,
): Flow<T> = retryWhen { cause, attempt ->
if (attempt >= maxRetries) return@retryWhen false
val delayMs = (initialDelay * factor.pow(attempt.toDouble())).toLong().coerceAtMost(maxDelay)
delay(delayMs)
true
}
fun <T> Flow<T>.throttleFirst(periodMillis: Long): Flow<T> = flow {
var lastTime = 0L
collect { value ->
val currentTime = System.currentTimeMillis()
if (currentTime - lastTime >= periodMillis) {
lastTime = currentTime
emit(value)
}
}
}

View file

@ -0,0 +1,16 @@
package com.librechat.android.core.common.extensions
fun String.trimTrailingSlash(): String = trimEnd('/')
fun String.isValidUrl(): Boolean =
startsWith("http://") || startsWith("https://")
fun String.ensureHttps(): String = when {
startsWith("https://") -> this
startsWith("http://") -> replaceFirst("http://", "https://")
else -> "https://$this"
}
fun String.truncate(maxLength: Int, ellipsis: String = "..."): String =
if (length <= maxLength) this
else take(maxLength - ellipsis.length) + ellipsis

View file

@ -0,0 +1,62 @@
package com.librechat.android.core.common.network
import android.annotation.SuppressLint
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConnectivityObserver @Inject constructor(
@ApplicationContext private val context: Context,
) {
// Permission is declared in app module's AndroidManifest.xml
@SuppressLint("MissingPermission")
val isConnected: Flow<Boolean> = callbackFlow {
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
// Emit current state
val currentNetwork = connectivityManager.activeNetwork
val currentCapabilities = connectivityManager.getNetworkCapabilities(currentNetwork)
trySend(currentCapabilities.hasInternet())
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(true)
}
override fun onLost(network: Network) {
trySend(false)
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
trySend(networkCapabilities.hasInternet())
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}.distinctUntilChanged()
private fun NetworkCapabilities?.hasInternet(): Boolean {
return this?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
}
}

View file

@ -0,0 +1,25 @@
package com.librechat.android.core.common.result
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable? = null, val message: String? = null) : Result<Nothing>
data object Loading : Result<Nothing>
}
fun <T> Result<T>.isSuccess(): Boolean = this is Result.Success
fun <T> Result<T>.isError(): Boolean = this is Result.Error
fun <T> Result<T>.isLoading(): Boolean = this is Result.Loading
fun <T> Result<T>.getOrNull(): T? = (this as? Result.Success)?.data
fun <T> Result<T>.getOrThrow(): T = when (this) {
is Result.Success -> data
is Result.Error -> throw exception ?: IllegalStateException(message ?: "Unknown error")
is Result.Loading -> throw IllegalStateException("Result is still loading")
}
suspend fun <T> safeApiCall(block: suspend () -> T): Result<T> =
try {
Result.Success(block())
} catch (e: Exception) {
Result.Error(e, e.message ?: "An unexpected error occurred")
}

View file

@ -0,0 +1,86 @@
package com.librechat.android.core.common.result
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SafeApiCallTest {
@Test
fun `safeApiCall returns Success on successful block`() = runTest {
val result = safeApiCall { "hello" }
assertThat(result).isInstanceOf(Result.Success::class.java)
assertThat((result as Result.Success).data).isEqualTo("hello")
}
@Test
fun `safeApiCall returns Error on exception`() = runTest {
val result = safeApiCall<String> { throw RuntimeException("boom") }
assertThat(result).isInstanceOf(Result.Error::class.java)
val error = result as Result.Error
assertThat(error.message).isEqualTo("boom")
assertThat(error.exception).isInstanceOf(RuntimeException::class.java)
}
@Test
fun `isSuccess returns true for Success`() {
val result: Result<String> = Result.Success("data")
assertThat(result.isSuccess()).isTrue()
assertThat(result.isError()).isFalse()
assertThat(result.isLoading()).isFalse()
}
@Test
fun `isError returns true for Error`() {
val result: Result<String> = Result.Error(message = "fail")
assertThat(result.isError()).isTrue()
assertThat(result.isSuccess()).isFalse()
}
@Test
fun `isLoading returns true for Loading`() {
val result: Result<String> = Result.Loading
assertThat(result.isLoading()).isTrue()
}
@Test
fun `getOrNull returns data for Success`() {
val result: Result<String> = Result.Success("data")
assertThat(result.getOrNull()).isEqualTo("data")
}
@Test
fun `getOrNull returns null for Error`() {
val result: Result<String> = Result.Error(message = "fail")
assertThat(result.getOrNull()).isNull()
}
@Test
fun `getOrThrow returns data for Success`() {
val result: Result<String> = Result.Success("data")
assertThat(result.getOrThrow()).isEqualTo("data")
}
@Test
fun `getOrThrow throws for Error with exception`() {
val original = IllegalArgumentException("bad arg")
val result: Result<String> = Result.Error(original, "bad arg")
try {
result.getOrThrow()
assertThat(false).isTrue() // Should not reach here
} catch (e: Exception) {
assertThat(e).isSameInstanceAs(original)
}
}
@Test
fun `getOrThrow throws for Loading`() {
val result: Result<String> = Result.Loading
try {
result.getOrThrow()
assertThat(false).isTrue() // Should not reach here
} catch (e: IllegalStateException) {
assertThat(e.message).isEqualTo("Result is still loading")
}
}
}

View file

@ -0,0 +1,23 @@
package com.librechat.android.core.common.test
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
@OptIn(ExperimentalCoroutinesApi::class)
class TestDispatcherRule(
val testDispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}

74
core/data/CLAUDE.md Normal file
View file

@ -0,0 +1,74 @@
# core:data
Room database, DataStore preferences, EncryptedSharedPreferences for tokens, and all repository implementations. This is the coordination layer between network and local storage.
## What This Module Provides
- **Room database** (`db/LibreChatDatabase.kt`): 6 entities, 6 DAOs, version 1, `exportSchema = true`.
- **Entities**: `ConversationEntity`, `MessageEntity`, `FileEntity`, `AgentEntity`, `PresetEntity`, `ConversationTagEntity`. Complex fields (lists, nested objects) stored as JSON strings via `Converters`.
- **DAOs**: `ConversationDao`, `MessageDao`, `FileDao`, `AgentDao`, `PresetDao`, `ConversationTagDao`. Read methods return `Flow<T>` for reactive observation.
- **DataStore**: `ServerDataStore` (server URL prefs), `SettingsDataStore` (user preferences).
- **Token storage** (`datastore/TokenDataStore.kt`): Implements `TokenManager` from `:core:network` using `EncryptedSharedPreferences` with AES-256. Uses `Mutex` to ensure only one refresh runs at a time when multiple 401s arrive concurrently.
- **Repositories** (`repository/`): Interface + Impl for each domain: `AuthRepository`, `ConversationRepository`, `MessageRepository`, `ChatRepository`, `FileRepository`, `AgentRepository`, `PresetRepository`, `PromptRepository`, `TagRepository`, `ShareRepository`, `ConfigRepository`, `UserRepository`, `SettingsRepository`.
- **Entity mappers** (`db/mapper/EntityMapper.kt`): Convert between Room entities and domain models.
- **Sync manager** (`sync/ConversationSyncManager.kt`): Orchestrates cache invalidation.
## Key Patterns
### Repository: Interface + Impl
```kotlin
interface ConversationRepository {
fun observeConversations(...): Flow<Result<List<Conversation>>>
suspend fun updateTitle(id: String, title: String): Result<Conversation>
}
```
All impls are `@Inject constructor(api, dao, mapper, @Dispatcher(IO) dispatcher)`.
### Read-Through Cache
1. Emit cached data from Room immediately (first page).
2. Fetch fresh data from network via API service.
3. Upsert into Room. The Room `Flow` auto-emits the updated list.
4. On network error, the cached data remains visible; error is surfaced separately.
### Token Refresh with Mutex
```kotlin
private val refreshMutex = Mutex()
override suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock {
// Single refresh attempt, all concurrent callers wait on this lock
}
```
Wrap `EncryptedSharedPreferences` in try/catch -- some OEM devices have broken Keystore implementations. On `KeyStoreException`, clear tokens and force re-login.
### DataModule Bindings
`DataModule` is a Hilt `@Module` that `@Binds` repository interfaces to their implementations and `@Provides` the Room database, DAOs, and DataStore instances.
## Room TypeConverters
`Converters.kt` handles JSON serialization for complex Room fields: `List<String>`, `MessageContentPart` lists, `Feedback`, `FileReference` lists, etc. Uses the same `Json` instance from the Hilt graph.
## Rules
- Dependencies: `:core:network`, `:core:model`, `:core:common`, Room, DataStore, security-crypto, Ktor, kotlinx-serialization, Timber, Hilt.
- Convention plugins: `librechat.android.library` + `librechat.android.hilt` + `librechat.android.room` + `librechat.kotlin.serialization`.
- Repositories must use `safeApiCall` from `:core:common` for all network calls.
- Entities are internal to this module -- feature modules work with domain models from `:core:model`.
- All DAO read methods that the UI observes should return `Flow`, not suspend functions.
### New Repositories (Round 2)
- `MemoryRepository` / `MemoryRepositoryImpl` — wraps `MemoriesApi` for memory CRUD + preferences
- `McpRepository` / `McpRepositoryImpl` — wraps `McpApi` for server CRUD, tools, connection status, reinitialize
- Both bound in `DataModule.kt` via `@Binds`
- Both use `safeApiCall` — no local caching (server is sole source of truth for memories and MCP)
- **Gotcha**: Memory delete/update use `key` as identifier (not a separate ID field)
- **Gotcha**: MCP server operations use `serverName` as identifier
### New Repositories (Round 3)
- `BannerRepository` / `BannerRepositoryImpl` — wraps `BannerApi` for fetching server banners
- `AgentRepository.getAgentsPaginated()` — server-side paginated agent fetch, maps response to `PaginatedAgents` domain model
- Both bound in `DataModule.kt` via `@Binds`, both use `safeApiCall`, no local caching

View file

@ -0,0 +1,22 @@
plugins {
id("librechat.android.library")
id("librechat.android.hilt")
id("librechat.android.room")
id("librechat.kotlin.serialization")
}
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)
}

View file

@ -0,0 +1,643 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "8b1491672b4ea5147356685f2beb6908",
"entities": [
{
"tableName": "conversations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `title` TEXT NOT NULL, `user` TEXT NOT NULL, `endpoint` TEXT, `endpointType` TEXT, `model` TEXT, `agentId` TEXT, `isArchived` INTEGER NOT NULL, `tags` TEXT NOT NULL, `iconURL` TEXT, `greeting` TEXT, `modelParams` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`))",
"fields": [
{
"fieldPath": "conversationId",
"columnName": "conversationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endpoint",
"columnName": "endpoint",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "endpointType",
"columnName": "endpointType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "greeting",
"columnName": "greeting",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "modelParams",
"columnName": "modelParams",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"conversationId"
]
},
"indices": [
{
"name": "index_conversations_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_conversations_isArchived",
"unique": false,
"columnNames": [
"isArchived"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_isArchived` ON `${TABLE_NAME}` (`isArchived`)"
}
],
"foreignKeys": []
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `parentMessageId` TEXT, `sender` TEXT, `text` TEXT, `content` TEXT, `isCreatedByUser` INTEGER NOT NULL, `model` TEXT, `endpoint` TEXT, `iconURL` TEXT, `unfinished` INTEGER NOT NULL, `error` INTEGER NOT NULL, `finishReason` TEXT, `tokenCount` INTEGER, `feedback` TEXT, `files` TEXT, `attachments` TEXT, `metadata` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`messageId`))",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "conversationId",
"columnName": "conversationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parentMessageId",
"columnName": "parentMessageId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isCreatedByUser",
"columnName": "isCreatedByUser",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "endpoint",
"columnName": "endpoint",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unfinished",
"columnName": "unfinished",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "error",
"columnName": "error",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "finishReason",
"columnName": "finishReason",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tokenCount",
"columnName": "tokenCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "feedback",
"columnName": "feedback",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "files",
"columnName": "files",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "metadata",
"columnName": "metadata",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId"
]
},
"indices": [
{
"name": "index_messages_conversationId",
"unique": false,
"columnNames": [
"conversationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
},
{
"name": "index_messages_parentMessageId",
"unique": false,
"columnNames": [
"parentMessageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_parentMessageId` ON `${TABLE_NAME}` (`parentMessageId`)"
},
{
"name": "index_messages_createdAt",
"unique": false,
"columnNames": [
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_createdAt` ON `${TABLE_NAME}` (`createdAt`)"
}
],
"foreignKeys": []
},
{
"tableName": "files",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `user` TEXT NOT NULL, `conversationId` TEXT, `messageId` TEXT, `filename` TEXT NOT NULL, `filepath` TEXT NOT NULL, `type` TEXT NOT NULL, `bytes` INTEGER NOT NULL, `source` TEXT NOT NULL, `width` INTEGER, `height` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `localPath` TEXT, PRIMARY KEY(`fileId`))",
"fields": [
{
"fieldPath": "fileId",
"columnName": "fileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "conversationId",
"columnName": "conversationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filepath",
"columnName": "filepath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bytes",
"columnName": "bytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "width",
"columnName": "width",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "height",
"columnName": "height",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localPath",
"columnName": "localPath",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"fileId"
]
},
"indices": [
{
"name": "index_files_conversationId",
"unique": false,
"columnNames": [
"conversationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
},
{
"name": "index_files_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": []
},
{
"tableName": "agents",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `description` TEXT, `avatar` TEXT, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `category` TEXT, `authorName` TEXT, `isPromoted` INTEGER NOT NULL, `conversationStarters` TEXT, `tools` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "provider",
"columnName": "provider",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "authorName",
"columnName": "authorName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isPromoted",
"columnName": "isPromoted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "conversationStarters",
"columnName": "conversationStarters",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tools",
"columnName": "tools",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "presets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`presetId` TEXT NOT NULL, `title` TEXT NOT NULL, `endpoint` TEXT, `model` TEXT, `isDefault` INTEGER NOT NULL, `order` INTEGER, `params` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`presetId`))",
"fields": [
{
"fieldPath": "presetId",
"columnName": "presetId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endpoint",
"columnName": "endpoint",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "params",
"columnName": "params",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"presetId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "conversation_tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tag` TEXT NOT NULL, `user` TEXT NOT NULL, `description` TEXT, `count` INTEGER NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "count",
"columnName": "count",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_conversation_tags_tag_user",
"unique": true,
"columnNames": [
"tag",
"user"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conversation_tags_tag_user` ON `${TABLE_NAME}` (`tag`, `user`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8b1491672b4ea5147356685f2beb6908')"
]
}
}

View file

@ -0,0 +1,675 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "8da545f3037e030769e5a13adc88efaf",
"entities": [
{
"tableName": "conversations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `title` TEXT NOT NULL, `user` TEXT NOT NULL, `endpoint` TEXT, `endpointType` TEXT, `model` TEXT, `agentId` TEXT, `isArchived` INTEGER NOT NULL, `tags` TEXT NOT NULL, `iconURL` TEXT, `greeting` TEXT, `modelParams` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`))",
"fields": [
{
"fieldPath": "conversationId",
"columnName": "conversationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endpoint",
"columnName": "endpoint",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "endpointType",
"columnName": "endpointType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "greeting",
"columnName": "greeting",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "modelParams",
"columnName": "modelParams",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"conversationId"
]
},
"indices": [
{
"name": "index_conversations_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_conversations_isArchived",
"unique": false,
"columnNames": [
"isArchived"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_isArchived` ON `${TABLE_NAME}` (`isArchived`)"
}
],
"foreignKeys": []
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `parentMessageId` TEXT, `sender` TEXT, `text` TEXT, `content` TEXT, `isCreatedByUser` INTEGER NOT NULL, `model` TEXT, `endpoint` TEXT, `iconURL` TEXT, `unfinished` INTEGER NOT NULL, `error` INTEGER NOT NULL, `finishReason` TEXT, `tokenCount` INTEGER, `feedback` TEXT, `files` TEXT, `attachments` TEXT, `metadata` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`messageId`))",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "conversationId",
"columnName": "conversationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parentMessageId",
"columnName": "parentMessageId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isCreatedByUser",
"columnName": "isCreatedByUser",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "endpoint",
"columnName": "endpoint",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unfinished",
"columnName": "unfinished",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "error",
"columnName": "error",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "finishReason",
"columnName": "finishReason",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tokenCount",
"columnName": "tokenCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "feedback",
"columnName": "feedback",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "files",
"columnName": "files",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "metadata",
"columnName": "metadata",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId"
]
},
"indices": [
{
"name": "index_messages_conversationId",
"unique": false,
"columnNames": [
"conversationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
},
{
"name": "index_messages_parentMessageId",
"unique": false,
"columnNames": [
"parentMessageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_parentMessageId` ON `${TABLE_NAME}` (`parentMessageId`)"
},
{
"name": "index_messages_createdAt",
"unique": false,
"columnNames": [
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_createdAt` ON `${TABLE_NAME}` (`createdAt`)"
}
],
"foreignKeys": []
},
{
"tableName": "files",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `user` TEXT NOT NULL, `conversationId` TEXT, `messageId` TEXT, `filename` TEXT NOT NULL, `filepath` TEXT NOT NULL, `type` TEXT NOT NULL, `bytes` INTEGER NOT NULL, `source` TEXT NOT NULL, `width` INTEGER, `height` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `localPath` TEXT, PRIMARY KEY(`fileId`))",
"fields": [
{
"fieldPath": "fileId",
"columnName": "fileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "conversationId",
"columnName": "conversationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filepath",
"columnName": "filepath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bytes",
"columnName": "bytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "width",
"columnName": "width",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "height",
"columnName": "height",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localPath",
"columnName": "localPath",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"fileId"
]
},
"indices": [
{
"name": "index_files_conversationId",
"unique": false,
"columnNames": [
"conversationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
},
{
"name": "index_files_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": []
},
{
"tableName": "agents",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `description` TEXT, `avatar` TEXT, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `category` TEXT, `authorName` TEXT, `isPromoted` INTEGER NOT NULL, `conversationStarters` TEXT, `tools` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "provider",
"columnName": "provider",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "authorName",
"columnName": "authorName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isPromoted",
"columnName": "isPromoted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "conversationStarters",
"columnName": "conversationStarters",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tools",
"columnName": "tools",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "presets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`presetId` TEXT NOT NULL, `title` TEXT NOT NULL, `endpoint` TEXT, `model` TEXT, `isDefault` INTEGER NOT NULL, `order` INTEGER, `params` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`presetId`))",
"fields": [
{
"fieldPath": "presetId",
"columnName": "presetId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endpoint",
"columnName": "endpoint",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "params",
"columnName": "params",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"presetId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "conversation_tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tag` TEXT NOT NULL, `user` TEXT NOT NULL, `description` TEXT, `count` INTEGER NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "count",
"columnName": "count",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_conversation_tags_tag_user",
"unique": true,
"columnNames": [
"tag",
"user"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conversation_tags_tag_user` ON `${TABLE_NAME}` (`tag`, `user`)"
}
],
"foreignKeys": []
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversation_id` TEXT NOT NULL, `text` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`conversation_id`))",
"fields": [
{
"fieldPath": "conversationId",
"columnName": "conversation_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"conversation_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8da545f3037e030769e5a13adc88efaf')"
]
}
}

View file

@ -0,0 +1,668 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "b4117797915eafc13b5bd956677f83bf",
"entities": [
{
"tableName": "conversations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `title` TEXT NOT NULL, `user` TEXT NOT NULL, `endpoint` TEXT, `endpointType` TEXT, `model` TEXT, `agentId` TEXT, `isArchived` INTEGER NOT NULL, `tags` TEXT NOT NULL, `iconURL` TEXT, `greeting` TEXT, `modelParams` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`))",
"fields": [
{
"fieldPath": "conversationId",
"columnName": "conversationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endpoint",
"columnName": "endpoint",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "endpointType",
"columnName": "endpointType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "greeting",
"columnName": "greeting",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "modelParams",
"columnName": "modelParams",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"conversationId"
]
},
"indices": [
{
"name": "index_conversations_isArchived_updatedAt",
"unique": false,
"columnNames": [
"isArchived",
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_isArchived_updatedAt` ON `${TABLE_NAME}` (`isArchived`, `updatedAt`)"
}
],
"foreignKeys": []
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `parentMessageId` TEXT, `sender` TEXT, `text` TEXT, `content` TEXT, `isCreatedByUser` INTEGER NOT NULL, `model` TEXT, `endpoint` TEXT, `iconURL` TEXT, `unfinished` INTEGER NOT NULL, `error` INTEGER NOT NULL, `finishReason` TEXT, `tokenCount` INTEGER, `feedback` TEXT, `files` TEXT, `attachments` TEXT, `metadata` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`messageId`))",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "conversationId",
"columnName": "conversationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parentMessageId",
"columnName": "parentMessageId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isCreatedByUser",
"columnName": "isCreatedByUser",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "endpoint",
"columnName": "endpoint",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unfinished",
"columnName": "unfinished",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "error",
"columnName": "error",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "finishReason",
"columnName": "finishReason",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tokenCount",
"columnName": "tokenCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "feedback",
"columnName": "feedback",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "files",
"columnName": "files",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "metadata",
"columnName": "metadata",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId"
]
},
"indices": [
{
"name": "index_messages_conversationId",
"unique": false,
"columnNames": [
"conversationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
},
{
"name": "index_messages_parentMessageId",
"unique": false,
"columnNames": [
"parentMessageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_parentMessageId` ON `${TABLE_NAME}` (`parentMessageId`)"
},
{
"name": "index_messages_conversationId_createdAt",
"unique": false,
"columnNames": [
"conversationId",
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId_createdAt` ON `${TABLE_NAME}` (`conversationId`, `createdAt`)"
}
],
"foreignKeys": []
},
{
"tableName": "files",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `user` TEXT NOT NULL, `conversationId` TEXT, `messageId` TEXT, `filename` TEXT NOT NULL, `filepath` TEXT NOT NULL, `type` TEXT NOT NULL, `bytes` INTEGER NOT NULL, `source` TEXT NOT NULL, `width` INTEGER, `height` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `localPath` TEXT, PRIMARY KEY(`fileId`))",
"fields": [
{
"fieldPath": "fileId",
"columnName": "fileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "conversationId",
"columnName": "conversationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filepath",
"columnName": "filepath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bytes",
"columnName": "bytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "width",
"columnName": "width",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "height",
"columnName": "height",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localPath",
"columnName": "localPath",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"fileId"
]
},
"indices": [
{
"name": "index_files_conversationId",
"unique": false,
"columnNames": [
"conversationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
},
{
"name": "index_files_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": []
},
{
"tableName": "agents",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `description` TEXT, `avatar` TEXT, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `category` TEXT, `authorName` TEXT, `isPromoted` INTEGER NOT NULL, `conversationStarters` TEXT, `tools` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "provider",
"columnName": "provider",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "authorName",
"columnName": "authorName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isPromoted",
"columnName": "isPromoted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "conversationStarters",
"columnName": "conversationStarters",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tools",
"columnName": "tools",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "presets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`presetId` TEXT NOT NULL, `title` TEXT NOT NULL, `endpoint` TEXT, `model` TEXT, `isDefault` INTEGER NOT NULL, `order` INTEGER, `params` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`presetId`))",
"fields": [
{
"fieldPath": "presetId",
"columnName": "presetId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endpoint",
"columnName": "endpoint",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "params",
"columnName": "params",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"presetId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "conversation_tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tag` TEXT NOT NULL, `user` TEXT NOT NULL, `description` TEXT, `count` INTEGER NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "count",
"columnName": "count",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_conversation_tags_tag_user",
"unique": true,
"columnNames": [
"tag",
"user"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conversation_tags_tag_user` ON `${TABLE_NAME}` (`tag`, `user`)"
}
],
"foreignKeys": []
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversation_id` TEXT NOT NULL, `text` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`conversation_id`))",
"fields": [
{
"fieldPath": "conversationId",
"columnName": "conversation_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"conversation_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b4117797915eafc13b5bd956677f83bf')"
]
}
}

View file

@ -0,0 +1,99 @@
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.model.EndpointConfig
import com.librechat.android.core.model.StartupConfig
import kotlinx.coroutines.flow.first
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 javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConfigCacheDataStore @Inject constructor(
private val dataStore: DataStore<Preferences>,
private val json: Json,
) {
suspend fun saveStartupConfig(config: StartupConfig) {
try {
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")
}
}
suspend fun loadStartupConfig(): StartupConfig? {
return try {
val prefs = dataStore.data.first()
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")
null
}
}
suspend fun saveEndpointConfigs(endpoints: Map<String, EndpointConfig>) {
try {
val serializer = MapSerializer(String.serializer(), EndpointConfig.serializer())
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")
}
}
suspend fun loadEndpointConfigs(): Map<String, EndpointConfig>? {
return try {
val prefs = dataStore.data.first()
val serialized = prefs[KEY_ENDPOINT_CONFIGS] ?: return null
val serializer = MapSerializer(String.serializer(), EndpointConfig.serializer())
json.decodeFromString(serializer, serialized)
} catch (e: Exception) {
Timber.w(e, "Failed to load cached endpoint configs")
null
}
}
suspend fun saveAvailableModels(models: Map<String, List<String>>) {
try {
val serializer = MapSerializer(
String.serializer(),
ListSerializer(String.serializer()),
)
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")
}
}
suspend fun loadAvailableModels(): Map<String, List<String>>? {
return try {
val prefs = dataStore.data.first()
val serialized = prefs[KEY_AVAILABLE_MODELS] ?: return null
val serializer = MapSerializer(
String.serializer(),
ListSerializer(String.serializer()),
)
json.decodeFromString(serializer, serialized)
} catch (e: Exception) {
Timber.w(e, "Failed to load cached available models")
null
}
}
companion object {
private val KEY_STARTUP_CONFIG = stringPreferencesKey("cached_startup_config")
private val KEY_ENDPOINT_CONFIGS = stringPreferencesKey("cached_endpoint_configs")
private val KEY_AVAILABLE_MODELS = stringPreferencesKey("cached_available_models")
}
}

View file

@ -0,0 +1,54 @@
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
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ServerDataStore @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : ServerUrlProvider {
private val _currentUrl = MutableStateFlow("")
val currentUrlFlow: Flow<String> = _currentUrl
init {
// Load the persisted URL synchronously so getBaseUrl() is ready
// immediately after construction. This is a singleton created once
// at app startup, so a brief block 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<Boolean> =
dataStore.data.map { prefs ->
!prefs[KEY_SERVER_URL].isNullOrBlank()
}
suspend fun setServerUrl(url: String) {
val trimmed = url.trimEnd('/')
_currentUrl.value = trimmed
dataStore.edit { prefs ->
prefs[KEY_SERVER_URL] = trimmed
}
}
companion object {
private val KEY_SERVER_URL = stringPreferencesKey("server_url")
}
}

View file

@ -0,0 +1,390 @@
package com.librechat.android.core.data.datastore
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class LatexRenderer {
NATIVE, KATEX;
companion object {
fun fromString(value: String?): LatexRenderer = when (value) {
"native" -> NATIVE
else -> KATEX
}
}
fun toStorageString(): String = when (this) {
NATIVE -> "native"
KATEX -> "katex"
}
}
enum class ChatFontSize {
SMALL, MEDIUM, LARGE;
companion object {
fun fromString(value: String?): ChatFontSize = when (value) {
"small" -> SMALL
"large" -> LARGE
else -> MEDIUM
}
}
fun toStorageString(): String = when (this) {
SMALL -> "small"
MEDIUM -> "medium"
LARGE -> "large"
}
}
@Singleton
class SettingsDataStore @Inject constructor(
private val dataStore: DataStore<Preferences>,
) {
val latexRenderer: Flow<LatexRenderer> = dataStore.data.map { prefs ->
LatexRenderer.fromString(prefs[KEY_LATEX_RENDERER])
}
val chatFontSize: Flow<ChatFontSize> = dataStore.data.map { prefs ->
ChatFontSize.fromString(prefs[KEY_CHAT_FONT_SIZE])
}
val autoScrollEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_AUTO_SCROLL_ENABLED] ?: true
}
val showThinkingBlocks: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_SHOW_THINKING_BLOCKS] ?: true
}
val autoReadEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_AUTO_READ_ENABLED] ?: false
}
val showImageDescriptions: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_SHOW_IMAGE_DESCRIPTIONS] ?: false
}
val selectedVoiceId: Flow<String?> = dataStore.data.map { prefs ->
prefs[KEY_SELECTED_VOICE_ID]
}
val lastUsedEndpoint: Flow<String?> = dataStore.data.map { prefs ->
prefs[KEY_LAST_USED_ENDPOINT]
}
val lastUsedModel: Flow<String?> = dataStore.data.map { prefs ->
prefs[KEY_LAST_USED_MODEL]
}
val dismissKeyboardOnSend: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_DISMISS_KEYBOARD_ON_SEND] ?: true
}
val ttsSource: Flow<String> = dataStore.data.map { prefs ->
prefs[KEY_TTS_SOURCE] ?: "device"
}
val ttsSpeechRate: Flow<Float> = dataStore.data.map { prefs ->
prefs[KEY_TTS_SPEECH_RATE] ?: 1.0f
}
val ttsPitch: Flow<Float> = dataStore.data.map { prefs ->
prefs[KEY_TTS_PITCH] ?: 1.0f
}
val ttsVoiceName: Flow<String> = dataStore.data.map { prefs ->
prefs[KEY_TTS_VOICE_NAME] ?: ""
}
val bookmarkedConversationIds: Flow<Set<String>> = dataStore.data.map { prefs ->
prefs[KEY_BOOKMARKED_CONVERSATIONS] ?: emptySet()
}
val tabletSidebarOpen: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_TABLET_SIDEBAR_OPEN] ?: true
}
val tabletSidebarGestureEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_TABLET_SIDEBAR_GESTURE_ENABLED] ?: true
}
val autoSendAfterStt: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_AUTO_SEND_AFTER_STT] ?: false
}
val sttEngine: Flow<String> = dataStore.data.map { prefs ->
prefs[KEY_STT_ENGINE] ?: ""
}
val sttLanguage: Flow<String> = dataStore.data.map { prefs ->
prefs[KEY_STT_LANGUAGE] ?: ""
}
val ttsEngine: Flow<String> = dataStore.data.map { prefs ->
prefs[KEY_TTS_ENGINE] ?: ""
}
val ttsVoice: Flow<String> = dataStore.data.map { prefs ->
prefs[KEY_TTS_VOICE] ?: ""
}
val ttsCaching: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_TTS_CACHING] ?: true
}
val chatLayoutStyle: Flow<String> = dataStore.data.map { prefs ->
prefs[KEY_CHAT_LAYOUT_STYLE] ?: "thread"
}
val showAvatars: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_SHOW_AVATARS] ?: true
}
val showBubbles: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_SHOW_BUBBLES] ?: false
}
val selectedMcpServers: Flow<Set<String>> = dataStore.data.map { prefs ->
prefs[KEY_SELECTED_MCP_SERVERS]?.split(",")?.filter { it.isNotBlank() }?.toSet() ?: emptySet()
}
val enabledTools: Flow<Set<String>> = dataStore.data.map { prefs ->
prefs[KEY_ENABLED_TOOLS]?.split(",")?.filter { it.isNotBlank() }?.toSet() ?: emptySet()
}
/**
* The backend version for which the user dismissed the version mismatch warning.
* When the backend updates to a new version, the dialog will appear again.
*/
val dismissedVersionWarning: Flow<String?> = dataStore.data.map { prefs ->
prefs[KEY_DISMISSED_VERSION_WARNING]
}
suspend fun setLatexRenderer(renderer: LatexRenderer) {
dataStore.edit { prefs ->
prefs[KEY_LATEX_RENDERER] = renderer.toStorageString()
}
}
suspend fun setChatFontSize(size: ChatFontSize) {
dataStore.edit { prefs ->
prefs[KEY_CHAT_FONT_SIZE] = size.toStorageString()
}
}
suspend fun setAutoScrollEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_AUTO_SCROLL_ENABLED] = enabled
}
}
suspend fun setShowThinkingBlocks(show: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_SHOW_THINKING_BLOCKS] = show
}
}
suspend fun setAutoReadEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_AUTO_READ_ENABLED] = enabled
}
}
suspend fun setShowImageDescriptions(show: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_SHOW_IMAGE_DESCRIPTIONS] = show
}
}
suspend fun setDismissKeyboardOnSend(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_DISMISS_KEYBOARD_ON_SEND] = enabled
}
}
suspend fun setSelectedVoiceId(voiceId: String) {
dataStore.edit { prefs ->
prefs[KEY_SELECTED_VOICE_ID] = voiceId
}
}
suspend fun setLastUsedModel(endpoint: String, model: String) {
dataStore.edit { prefs ->
prefs[KEY_LAST_USED_ENDPOINT] = endpoint
prefs[KEY_LAST_USED_MODEL] = model
}
}
suspend fun setTtsSource(source: String) {
dataStore.edit { prefs ->
prefs[KEY_TTS_SOURCE] = source
}
}
suspend fun setTtsSpeechRate(rate: Float) {
dataStore.edit { prefs ->
prefs[KEY_TTS_SPEECH_RATE] = rate
}
}
suspend fun setTtsPitch(pitch: Float) {
dataStore.edit { prefs ->
prefs[KEY_TTS_PITCH] = pitch
}
}
suspend fun setTtsVoiceName(voiceName: String) {
dataStore.edit { prefs ->
prefs[KEY_TTS_VOICE_NAME] = voiceName
}
}
suspend fun setTabletSidebarOpen(open: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_TABLET_SIDEBAR_OPEN] = open
}
}
suspend fun setTabletSidebarGestureEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_TABLET_SIDEBAR_GESTURE_ENABLED] = enabled
}
}
suspend fun setAutoSendAfterStt(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_AUTO_SEND_AFTER_STT] = enabled
}
}
suspend fun setSttEngine(engine: String) {
dataStore.edit { prefs ->
prefs[KEY_STT_ENGINE] = engine
}
}
suspend fun setSttLanguage(language: String) {
dataStore.edit { prefs ->
prefs[KEY_STT_LANGUAGE] = language
}
}
suspend fun setTtsEngine(engine: String) {
dataStore.edit { prefs ->
prefs[KEY_TTS_ENGINE] = engine
}
}
suspend fun setTtsVoice(voice: String) {
dataStore.edit { prefs ->
prefs[KEY_TTS_VOICE] = voice
}
}
suspend fun setTtsCaching(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_TTS_CACHING] = enabled
}
}
suspend fun setChatLayoutStyle(style: String) {
dataStore.edit { prefs ->
prefs[KEY_CHAT_LAYOUT_STYLE] = style
}
}
suspend fun setShowAvatars(show: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_SHOW_AVATARS] = show
}
}
suspend fun setShowBubbles(show: Boolean) {
dataStore.edit { prefs ->
prefs[KEY_SHOW_BUBBLES] = show
}
}
/**
* Saves the backend version for which the user chose "Don't warn again".
* If the backend later updates to a different version, the warning will reappear.
*/
suspend fun setDismissedVersionWarning(version: String) {
dataStore.edit { prefs ->
prefs[KEY_DISMISSED_VERSION_WARNING] = version
}
}
suspend fun setSelectedMcpServers(servers: Set<String>) {
dataStore.edit { prefs ->
if (servers.isEmpty()) {
prefs.remove(KEY_SELECTED_MCP_SERVERS)
} else {
prefs[KEY_SELECTED_MCP_SERVERS] = servers.joinToString(",")
}
}
}
suspend fun setEnabledTools(tools: Set<String>) {
dataStore.edit { prefs ->
if (tools.isEmpty()) {
prefs.remove(KEY_ENABLED_TOOLS)
} else {
prefs[KEY_ENABLED_TOOLS] = tools.joinToString(",")
}
}
}
suspend fun toggleBookmark(conversationId: String) {
dataStore.edit { prefs ->
val current = prefs[KEY_BOOKMARKED_CONVERSATIONS] ?: emptySet()
prefs[KEY_BOOKMARKED_CONVERSATIONS] = if (conversationId in current) {
current - conversationId
} else {
current + conversationId
}
}
}
companion object {
private val KEY_LATEX_RENDERER = stringPreferencesKey("latex_renderer")
private val KEY_CHAT_FONT_SIZE = stringPreferencesKey("chat_font_size")
private val KEY_AUTO_SCROLL_ENABLED = booleanPreferencesKey("auto_scroll_enabled")
private val KEY_SHOW_THINKING_BLOCKS = booleanPreferencesKey("show_thinking_blocks")
private val KEY_AUTO_READ_ENABLED = booleanPreferencesKey("auto_read_enabled")
private val KEY_SHOW_IMAGE_DESCRIPTIONS = booleanPreferencesKey("show_image_descriptions")
private val KEY_SELECTED_VOICE_ID = stringPreferencesKey("selected_voice_id")
private val KEY_LAST_USED_ENDPOINT = stringPreferencesKey("last_used_endpoint")
private val KEY_LAST_USED_MODEL = stringPreferencesKey("last_used_model")
private val KEY_DISMISS_KEYBOARD_ON_SEND = booleanPreferencesKey("dismiss_keyboard_on_send")
private val KEY_TTS_SOURCE = stringPreferencesKey("tts_source")
private val KEY_TTS_SPEECH_RATE = floatPreferencesKey("tts_speech_rate")
private val KEY_TTS_PITCH = floatPreferencesKey("tts_pitch")
private val KEY_TTS_VOICE_NAME = stringPreferencesKey("tts_voice_name")
private val KEY_BOOKMARKED_CONVERSATIONS = stringSetPreferencesKey("bookmarked_conversations")
private val KEY_TABLET_SIDEBAR_OPEN = booleanPreferencesKey("tablet_sidebar_open")
private val KEY_TABLET_SIDEBAR_GESTURE_ENABLED = booleanPreferencesKey("tablet_sidebar_gesture_enabled")
private val KEY_AUTO_SEND_AFTER_STT = booleanPreferencesKey("auto_send_after_stt")
private val KEY_STT_ENGINE = stringPreferencesKey("stt_engine")
private val KEY_STT_LANGUAGE = stringPreferencesKey("stt_language")
private val KEY_TTS_ENGINE = stringPreferencesKey("tts_engine")
private val KEY_TTS_VOICE = stringPreferencesKey("tts_voice")
private val KEY_TTS_CACHING = booleanPreferencesKey("tts_caching")
private val KEY_CHAT_LAYOUT_STYLE = stringPreferencesKey("chat_layout_style")
private val KEY_SHOW_AVATARS = booleanPreferencesKey("show_avatars")
private val KEY_SHOW_BUBBLES = booleanPreferencesKey("show_bubbles")
private val KEY_DISMISSED_VERSION_WARNING = stringPreferencesKey("dismissed_version_warning")
private val KEY_SELECTED_MCP_SERVERS = stringPreferencesKey("selected_mcp_servers")
private val KEY_ENABLED_TOOLS = stringPreferencesKey("enabled_tools")
}
}

View file

@ -0,0 +1,42 @@
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class ThemeMode {
SYSTEM, LIGHT, DARK
}
@Singleton
class ThemeDataStore @Inject constructor(
private val dataStore: DataStore<Preferences>,
) {
val themeMode: Flow<ThemeMode> = dataStore.data.map { prefs ->
when (prefs[KEY_THEME]) {
"light" -> ThemeMode.LIGHT
"dark" -> ThemeMode.DARK
else -> ThemeMode.SYSTEM
}
}
suspend fun setThemeMode(mode: ThemeMode) {
dataStore.edit { prefs ->
prefs[KEY_THEME] = when (mode) {
ThemeMode.SYSTEM -> "system"
ThemeMode.LIGHT -> "light"
ThemeMode.DARK -> "dark"
}
}
}
companion object {
private val KEY_THEME = stringPreferencesKey("theme_mode")
}
}

View file

@ -0,0 +1,148 @@
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 com.librechat.android.core.network.di.RefreshClient
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
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 java.io.IOException
import java.security.KeyStoreException
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 javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TokenDataStore @Inject constructor(
@ApplicationContext context: Context,
@RefreshClient private val refreshClient: dagger.Lazy<HttpClient>,
) : 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<Unit>(extraBufferCapacity = 1)
override val sessionExpiredFlow: SharedFlow<Unit> = _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.get()
.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"
}
}

View file

@ -0,0 +1,49 @@
package com.librechat.android.core.data.db
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.librechat.android.core.data.db.converter.Converters
import com.librechat.android.core.data.db.dao.AgentDao
import com.librechat.android.core.data.db.dao.ConversationDao
import com.librechat.android.core.data.db.dao.ConversationTagDao
import com.librechat.android.core.data.db.dao.DraftDao
import com.librechat.android.core.data.db.dao.FileDao
import com.librechat.android.core.data.db.dao.MessageDao
import com.librechat.android.core.data.db.dao.PresetDao
import com.librechat.android.core.data.db.entity.AgentEntity
import com.librechat.android.core.data.db.entity.ConversationEntity
import com.librechat.android.core.data.db.entity.ConversationTagEntity
import com.librechat.android.core.data.db.entity.DraftEntity
import com.librechat.android.core.data.db.entity.FileEntity
import com.librechat.android.core.data.db.entity.MessageEntity
import com.librechat.android.core.data.db.entity.PresetEntity
@Database(
entities = [
ConversationEntity::class,
MessageEntity::class,
FileEntity::class,
AgentEntity::class,
PresetEntity::class,
ConversationTagEntity::class,
DraftEntity::class,
],
version = 3,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3),
],
)
@TypeConverters(Converters::class)
abstract class LibreChatDatabase : RoomDatabase() {
abstract fun conversationDao(): ConversationDao
abstract fun messageDao(): MessageDao
abstract fun fileDao(): FileDao
abstract fun agentDao(): AgentDao
abstract fun presetDao(): PresetDao
abstract fun conversationTagDao(): ConversationTagDao
abstract fun draftDao(): DraftDao
}

View file

@ -0,0 +1,16 @@
package com.librechat.android.core.data.db.converter
import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class Converters {
private val json = Json { ignoreUnknownKeys = true }
@TypeConverter
fun fromStringList(value: List<String>): String = json.encodeToString(value)
@TypeConverter
fun toStringList(value: String): List<String> =
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
}

View file

@ -0,0 +1,25 @@
package com.librechat.android.core.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.librechat.android.core.data.db.entity.AgentEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AgentDao {
@Query("SELECT * FROM agents ORDER BY isPromoted DESC, name ASC")
fun getAll(): Flow<List<AgentEntity>>
@Query("SELECT * FROM agents WHERE id = :id")
suspend fun getById(id: String): AgentEntity?
@Upsert
suspend fun upsert(agent: AgentEntity)
@Upsert
suspend fun upsertAll(agents: List<AgentEntity>)
@Query("DELETE FROM agents")
suspend fun deleteAll()
}

View file

@ -0,0 +1,43 @@
package com.librechat.android.core.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.librechat.android.core.data.db.entity.ConversationEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ConversationDao {
@Query("SELECT * FROM conversations WHERE user = :userId AND isArchived = :isArchived ORDER BY updatedAt DESC LIMIT :limit")
fun getConversations(userId: String, isArchived: Boolean = false, limit: Int = 25): Flow<List<ConversationEntity>>
@Query("SELECT * FROM conversations WHERE isArchived = :isArchived ORDER BY updatedAt DESC")
fun getAllConversations(isArchived: Boolean = false): Flow<List<ConversationEntity>>
@Query("SELECT * FROM conversations WHERE conversationId = :id")
suspend fun getById(id: String): ConversationEntity?
@Query("SELECT * FROM conversations WHERE conversationId = :id")
fun observeById(id: String): Flow<ConversationEntity?>
@Upsert
suspend fun upsert(conversation: ConversationEntity)
@Upsert
suspend fun upsertAll(conversations: List<ConversationEntity>)
@Query("DELETE FROM conversations WHERE conversationId = :id")
suspend fun deleteById(id: String)
@Query("DELETE FROM conversations WHERE user = :userId")
suspend fun deleteAllForUser(userId: String)
@Query("UPDATE conversations SET title = :title, updatedAt = :updatedAt WHERE conversationId = :id")
suspend fun updateTitle(id: String, title: String, updatedAt: Long)
@Query("UPDATE conversations SET isArchived = :isArchived, updatedAt = :updatedAt WHERE conversationId = :id")
suspend fun updateArchived(id: String, isArchived: Boolean, updatedAt: Long)
@Query("DELETE FROM conversations")
suspend fun deleteAll()
}

View file

@ -0,0 +1,22 @@
package com.librechat.android.core.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.librechat.android.core.data.db.entity.ConversationTagEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ConversationTagDao {
@Query("SELECT * FROM conversation_tags WHERE user = :userId ORDER BY position ASC")
fun getTagsForUser(userId: String): Flow<List<ConversationTagEntity>>
@Upsert
suspend fun upsert(tag: ConversationTagEntity)
@Upsert
suspend fun upsertAll(tags: List<ConversationTagEntity>)
@Query("DELETE FROM conversation_tags WHERE id = :id")
suspend fun deleteById(id: Long)
}

View file

@ -0,0 +1,22 @@
package com.librechat.android.core.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.librechat.android.core.data.db.entity.DraftEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface DraftDao {
@Query("SELECT * FROM drafts WHERE conversation_id = :conversationId")
suspend fun getDraft(conversationId: String): DraftEntity?
@Upsert
suspend fun upsertDraft(draft: DraftEntity)
@Query("DELETE FROM drafts WHERE conversation_id = :conversationId")
suspend fun deleteDraft(conversationId: String)
@Query("SELECT * FROM drafts ORDER BY updated_at DESC")
fun observeAllDrafts(): Flow<List<DraftEntity>>
}

View file

@ -0,0 +1,25 @@
package com.librechat.android.core.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.librechat.android.core.data.db.entity.FileEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface FileDao {
@Query("SELECT * FROM files WHERE user = :userId ORDER BY createdAt DESC")
fun getFilesForUser(userId: String): Flow<List<FileEntity>>
@Query("SELECT * FROM files WHERE fileId = :fileId")
suspend fun getById(fileId: String): FileEntity?
@Upsert
suspend fun upsert(file: FileEntity)
@Upsert
suspend fun upsertAll(files: List<FileEntity>)
@Query("DELETE FROM files WHERE fileId = :fileId")
suspend fun deleteById(fileId: String)
}

View file

@ -0,0 +1,43 @@
package com.librechat.android.core.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.librechat.android.core.data.db.entity.MessageEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY createdAt ASC")
fun getMessagesForConversation(conversationId: String): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE messageId = :messageId")
suspend fun getById(messageId: String): MessageEntity?
@Query("SELECT * FROM messages WHERE conversationId = :conversationId AND parentMessageId = :parentMessageId ORDER BY createdAt ASC")
suspend fun getSiblings(conversationId: String, parentMessageId: String): List<MessageEntity>
@Upsert
suspend fun upsert(message: MessageEntity)
@Upsert
suspend fun upsertAll(messages: List<MessageEntity>)
@Query("DELETE FROM messages WHERE messageId = :messageId")
suspend fun deleteById(messageId: String)
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
suspend fun deleteAllForConversation(conversationId: String)
@androidx.room.Transaction
suspend fun replaceAllForConversation(conversationId: String, messages: List<MessageEntity>) {
deleteAllForConversation(conversationId)
upsertAll(messages)
}
@Query("UPDATE messages SET feedback = :feedback WHERE messageId = :messageId")
suspend fun updateFeedback(messageId: String, feedback: String?)
@Query("UPDATE messages SET text = :text, content = NULL WHERE messageId = :messageId")
suspend fun updateText(messageId: String, text: String)
}

View file

@ -0,0 +1,22 @@
package com.librechat.android.core.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.librechat.android.core.data.db.entity.PresetEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface PresetDao {
@Query("SELECT * FROM presets ORDER BY `order` ASC, title ASC")
fun getAll(): Flow<List<PresetEntity>>
@Query("SELECT * FROM presets WHERE presetId = :presetId")
suspend fun getById(presetId: String): PresetEntity?
@Upsert
suspend fun upsert(preset: PresetEntity)
@Query("DELETE FROM presets WHERE presetId = :presetId")
suspend fun deleteById(presetId: String)
}

View file

@ -0,0 +1,21 @@
package com.librechat.android.core.data.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "agents")
data class AgentEntity(
@PrimaryKey
val id: String,
val name: String?,
val description: String?,
val avatar: String?,
val provider: String,
val model: String,
val category: String?,
val authorName: String?,
val isPromoted: Boolean = false,
val conversationStarters: String?,
val tools: String?,
val updatedAt: Long,
)

View file

@ -0,0 +1,30 @@
package com.librechat.android.core.data.db.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "conversations",
indices = [
Index(value = ["isArchived", "updatedAt"]),
],
)
data class ConversationEntity(
@PrimaryKey
val conversationId: String,
val title: String,
val user: String,
val endpoint: String?,
val endpointType: String?,
val model: String?,
val agentId: String?,
val isArchived: Boolean = false,
val tags: String,
val iconURL: String?,
val greeting: String?,
val modelParams: String?,
val createdAt: Long,
val updatedAt: Long,
val lastSyncedAt: Long = 0,
)

View file

@ -0,0 +1,21 @@
package com.librechat.android.core.data.db.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "conversation_tags",
indices = [Index(value = ["tag", "user"], unique = true)],
)
data class ConversationTagEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val tag: String,
val user: String,
val description: String?,
val count: Int = 0,
val position: Int = 0,
val createdAt: Long,
val updatedAt: Long,
)

View file

@ -0,0 +1,16 @@
package com.librechat.android.core.data.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "drafts")
data class DraftEntity(
@PrimaryKey
@ColumnInfo(name = "conversation_id")
val conversationId: String,
@ColumnInfo(name = "text")
val text: String,
@ColumnInfo(name = "updated_at")
val updatedAt: Long = System.currentTimeMillis(),
)

View file

@ -0,0 +1,30 @@
package com.librechat.android.core.data.db.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "files",
indices = [
Index("conversationId"),
Index("messageId"),
],
)
data class FileEntity(
@PrimaryKey
val fileId: String,
val user: String,
val conversationId: String?,
val messageId: String?,
val filename: String,
val filepath: String,
val type: String,
val bytes: Long,
val source: String,
val width: Int?,
val height: Int?,
val createdAt: Long,
val updatedAt: Long,
val localPath: String? = null,
)

View file

@ -0,0 +1,37 @@
package com.librechat.android.core.data.db.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "messages",
indices = [
Index("conversationId"),
Index("parentMessageId"),
Index(value = ["conversationId", "createdAt"]),
],
)
data class MessageEntity(
@PrimaryKey
val messageId: String,
val conversationId: String,
val parentMessageId: String?,
val sender: String?,
val text: String?,
val content: String?,
val isCreatedByUser: Boolean,
val model: String?,
val endpoint: String?,
val iconURL: String?,
val unfinished: Boolean = false,
val error: Boolean = false,
val finishReason: String?,
val tokenCount: Int?,
val feedback: String?,
val files: String?,
val attachments: String?,
val metadata: String?,
val createdAt: Long,
val updatedAt: Long,
)

View file

@ -0,0 +1,18 @@
package com.librechat.android.core.data.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "presets")
data class PresetEntity(
@PrimaryKey
val presetId: String,
val title: String,
val endpoint: String?,
val model: String?,
val isDefault: Boolean = false,
val order: Int?,
val params: String?,
val createdAt: Long,
val updatedAt: Long,
)

View file

@ -0,0 +1,173 @@
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 com.librechat.android.core.data.datastore.ServerDataStore
import com.librechat.android.core.data.datastore.TokenDataStore
import com.librechat.android.core.data.repository.AgentRepository
import com.librechat.android.core.data.repository.AgentRepositoryImpl
import com.librechat.android.core.data.repository.ApiKeyRepository
import com.librechat.android.core.data.repository.ApiKeyRepositoryImpl
import com.librechat.android.core.data.repository.AuthRepository
import com.librechat.android.core.data.repository.BalanceRepository
import com.librechat.android.core.data.repository.BalanceRepositoryImpl
import com.librechat.android.core.data.repository.BannerRepository
import com.librechat.android.core.data.repository.BannerRepositoryImpl
import com.librechat.android.core.data.repository.SearchRepository
import com.librechat.android.core.data.repository.SearchRepositoryImpl
import com.librechat.android.core.data.repository.TagRepository
import com.librechat.android.core.data.repository.TagRepositoryImpl
import com.librechat.android.core.data.repository.AuthRepositoryImpl
import com.librechat.android.core.data.repository.ChatRepository
import com.librechat.android.core.data.repository.ChatRepositoryImpl
import com.librechat.android.core.data.repository.ConfigRepository
import com.librechat.android.core.data.repository.ConfigRepositoryImpl
import com.librechat.android.core.data.repository.ConversationRepository
import com.librechat.android.core.data.repository.ConversationRepositoryImpl
import com.librechat.android.core.data.repository.DraftRepository
import com.librechat.android.core.data.repository.DraftRepositoryImpl
import com.librechat.android.core.data.repository.FileRepository
import com.librechat.android.core.data.repository.FileRepositoryImpl
import com.librechat.android.core.data.repository.KeyRepository
import com.librechat.android.core.data.repository.KeyRepositoryImpl
import com.librechat.android.core.data.repository.McpRepository
import com.librechat.android.core.data.repository.McpRepositoryImpl
import com.librechat.android.core.data.repository.MemoryRepository
import com.librechat.android.core.data.repository.MemoryRepositoryImpl
import com.librechat.android.core.data.repository.MessageRepository
import com.librechat.android.core.data.repository.MessageRepositoryImpl
import com.librechat.android.core.data.repository.PresetRepository
import com.librechat.android.core.data.repository.PresetRepositoryImpl
import com.librechat.android.core.data.repository.PromptRepository
import com.librechat.android.core.data.repository.PromptRepositoryImpl
import com.librechat.android.core.data.repository.ShareRepository
import com.librechat.android.core.data.repository.ShareRepositoryImpl
import com.librechat.android.core.data.repository.SpeechRepository
import com.librechat.android.core.data.repository.SpeechRepositoryImpl
import com.librechat.android.core.data.repository.UserRepository
import com.librechat.android.core.data.repository.UserRepositoryImpl
import com.librechat.android.core.network.client.SecureTokenStorage
import com.librechat.android.core.network.client.ServerUrlProvider
import com.librechat.android.core.network.client.TokenManager
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "librechat_settings",
)
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
@Singleton
abstract fun bindTokenManager(impl: TokenDataStore): TokenManager
@Binds
@Singleton
abstract fun bindSecureTokenStorage(impl: TokenDataStore): SecureTokenStorage
@Binds
@Singleton
abstract fun bindServerUrlProvider(impl: ServerDataStore): ServerUrlProvider
@Binds
@Singleton
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
@Binds
@Singleton
abstract fun bindBalanceRepository(impl: BalanceRepositoryImpl): BalanceRepository
@Binds
@Singleton
abstract fun bindConfigRepository(impl: ConfigRepositoryImpl): ConfigRepository
@Binds
@Singleton
abstract fun bindConversationRepository(impl: ConversationRepositoryImpl): ConversationRepository
@Binds
@Singleton
abstract fun bindDraftRepository(impl: DraftRepositoryImpl): DraftRepository
@Binds
@Singleton
abstract fun bindMessageRepository(impl: MessageRepositoryImpl): MessageRepository
@Binds
@Singleton
abstract fun bindChatRepository(impl: ChatRepositoryImpl): ChatRepository
@Binds
@Singleton
abstract fun bindAgentRepository(impl: AgentRepositoryImpl): AgentRepository
@Binds
@Singleton
abstract fun bindTagRepository(impl: TagRepositoryImpl): TagRepository
@Binds
@Singleton
abstract fun bindSearchRepository(impl: SearchRepositoryImpl): SearchRepository
@Binds
@Singleton
abstract fun bindFileRepository(impl: FileRepositoryImpl): FileRepository
@Binds
@Singleton
abstract fun bindKeyRepository(impl: KeyRepositoryImpl): KeyRepository
@Binds
@Singleton
abstract fun bindApiKeyRepository(impl: ApiKeyRepositoryImpl): ApiKeyRepository
@Binds
@Singleton
abstract fun bindMcpRepository(impl: McpRepositoryImpl): McpRepository
@Binds
@Singleton
abstract fun bindMemoryRepository(impl: MemoryRepositoryImpl): MemoryRepository
@Binds
@Singleton
abstract fun bindPresetRepository(impl: PresetRepositoryImpl): PresetRepository
@Binds
@Singleton
abstract fun bindPromptRepository(impl: PromptRepositoryImpl): PromptRepository
@Binds
@Singleton
abstract fun bindShareRepository(impl: ShareRepositoryImpl): ShareRepository
@Binds
@Singleton
abstract fun bindSpeechRepository(impl: SpeechRepositoryImpl): SpeechRepository
@Binds
@Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
@Binds
@Singleton
abstract fun bindBannerRepository(impl: BannerRepositoryImpl): BannerRepository
companion object {
@Provides
@Singleton
fun provideDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.settingsDataStore
}
}

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