This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment.
update { current -> current.copy(...) }.*SettingsUpdate dispatch types or updateAll on settings gateways.copy(...) transform so the SSOT can apply them atomically.ReadStyleMutation, ThemePackageSettingsGateway.applyAndAwait,
ThemeStateTransaction, and AppUiConfigurationGateway in their dedicated shapes.Transform tasks into verifiable goals:
# Quick compile check (Kotlin only, no dex/package — fastest for verifying code compiles)
.\gradlew.bat :app:compileAppDebugKotlin
# Assemble all variants
./gradlew assembleAppRelease
# Assemble without R8 (for crash debugging — no minification/shrinking)
./gradlew assembleAppNoR8
# Debug build
./gradlew assembleAppDebug
# Run unit tests (JVM, local)
./gradlew test
# Run a single test class
./gradlew test --tests "io.legado.app.model.cache.CacheDownloadQueueTest"
# Run connected Android tests
./gradlew connectedAndroidTest
# Lint
./gradlew lint
# Update Cronet (after changing CronetVersion in gradle.properties)
./gradlew app:downloadCronet
The project uses JDK 21 for development (set in build.gradle.kts via jvmToolchain). CI uses JDK 17 for building.
Gradle properties: 8 GB heap, configuration cache disabled (gradle.properties:31), non-transitive R classes, precise resource shrinking enabled.
This is a Material Design 3 fork of Legado. app/src/main/java/io/legado/app/ uses Clean Architecture with three layers:
| Layer | Package | Role |
|---|---|---|
| Data | data/ |
Room DB (AppDatabase, version 85, ~22 DAOs, ~25 entities), repository implementations |
| Domain | domain/ |
Gateway interfaces, use cases (14), domain models — no framework dependencies |
| UI | ui/ |
Jetpack Compose screens, Navigation 3 routes, ViewModels |
Additional top-level packages:
help/ — Infrastructure "glue": HTTP (OkHttp + Cronet), book content processing, backup/WebDAV, JS engine, configmodel/ — Runtime state coordinators (not entities): ReadBook, AudioPlay, CacheBook, BookCover, etc.service/ — Android foreground/background services (audio playback, TTS, download, web server)web/ — Embedded HTTP server (Ktor) for remote bookshelf/source editinglib/ — Third-party library wrappers (MOBI parser, WebDAV client, legacy View theme system, cronet)base/ — Abstract Activity/Fragment/ViewModel base classesutils/ — Extension functions and utility classes (~70 files)Modules: :app, :modules:book (epub/TXT parsing, namespace me.ag2s), :modules:rhino (Rhino JS wrapper, namespace com.script). There is also a Vue 3 web frontend in modules/web/ (pnpm, separate from the Android build).
Two modules loaded in App.onCreate():
startKoin {
modules(appDatabaseModule, appModule)
}
di/appDatabaseModule.kt — Singleton AppDatabase + factory bindings for all 22 DAOsdi/appModule.kt — Singletons (repositories, use cases, gateways, Coil ImageLoader), viewModelOf / viewModel { } for all ViewModels, some parameterized definitionsGateways are bound to their repository implementations explicitly (e.g., single<LocalBookGateway> { LocalBookRepository(get()) }), not through singleOf.
Uses Jetpack Navigation 3 (androidx.navigation3) with type-safe @Serializable sealed interfaces for route keys:
@Serializable
private sealed interface MainRoute : NavKey
@Serializable
private data object MainRouteHome : MainRoute
@Serializable
private data class MainRouteCache(val groupId: Long) : MainRoute
MainActivity holds a single NavDisplay with entryProvider { ... } defining all composable entries. Launcher0 through LauncherW extend MainActivity to provide multiple launcher icon alias entries. Separate activities handle the reader (ReadBookActivity — still View-based), book info, source management, replace rules, file manager, QR scanner, etc.
A multi-engine theming system in ui/theme/:
MaterialExpressiveTheme with MotionScheme.expressive()top.yukonga.miuix.kmp theming engine14 theme modes (AppThemeMode enum) — Dynamic (Monet), 12 named presets, Custom (MaterialKolor seed-color generation), Transparent. CustomColorScheme wraps com.materialkolor with configurable PaletteStyle (TonalSpot, Neutral, Vibrant, Expressive, Rainbow, etc.) and ColorSpec (2021 vs 2025).
Legacy View-based theme still exists in lib/theme/ (used by non-migrated screens like ReadBookActivity).
The app is mid-migration from Views to Compose. View-based screens (reader, book info, source management) coexist with Compose screens (main tabs, settings, search, RSS, cache management). XML layouts, viewBinding, and traditional Activities are still heavily used. The viewBinding build feature is enabled but Compose screens are the target.
All new UI screens must be implemented in Jetpack Compose following the patterns below. Do **not ** create new View-based Activities/Fragments/XML layouts. Existing View-based screens can remain until migrated.
Every Compose screen follows a strict Model-View-Intent pattern with three artifacts defined in
a *Contract.kt file:
ui/{feature}/
├── XxxContract.kt // UiState, Intent, Effect (and optionally Sheet/Dialog)
├── XxxViewModel.kt // ViewModel
├── XxxScreen.kt // Screen composable
└── XxxRouteScreen.kt // (optional) outer wrapper for activity results / lifecycle
Contract definitions:
// @Stable data class — all screen state in one place
@Stable
data class XxxUiState(
val loading: Boolean = false,
val items: ImmutableList<ItemUi> = persistentListOf(),
val activeSheet: XxxSheet? = null,
val activeDialog: XxxDialog? = null,
)
// sealed interface — every user action is an Intent
sealed interface XxxIntent {
data class LoadData(val id: Long) : XxxIntent
data object Refresh : XxxIntent
}
// sealed interface — one-shot side effects (navigation, toast, etc.)
sealed interface XxxEffect {
data class ShowToast(val message: String) : XxxEffect
data class NavigateTo(val route: MainRoute) : XxxEffect
}
// (optional) sealed interface for multi-sheet/dialog scenarios
sealed interface XxxSheet { data object Filter : XxxSheet }
sealed interface XxxDialog { data class Confirm(val msg: String) : XxxDialog }
Naming rules:
{Feature}UiState — @Stable data class{Feature}Intent — sealed interface with data class / data object members{Feature}Effect — sealed interface{Feature}Sheet, {Feature}Dialog — sealed interfaces stored in UiStateclass XxxViewModel(/* injected dependencies */) : ViewModel() {
private val _uiState = MutableStateFlow(XxxUiState())
val uiState = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<XxxEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
fun onIntent(intent: XxxIntent) {
when (intent) {
is XxxIntent.LoadData -> loadData(intent.id)
is XxxIntent.Refresh -> refresh()
}
}
private fun loadData(id: Long) {
// Use viewModelScope, update _uiState via update { it.copy(...) }
}
}
Key rules:
ViewModel() directly (not BaseViewModel)._uiState is MutableStateFlow, exposed as StateFlow via .asStateFlow()._effects is MutableSharedFlow(extraBufferCapacity = 16), exposed via .asSharedFlow()._effects.tryEmit(...).onIntent() entry point, dispatched via when.// Stateless screen — ViewModel wired in entry provider or RouteScreen
@Composable
fun XxxScreen(
state: XxxUiState,
onIntent: (XxxIntent) -> Unit,
effects: Flow<XxxEffect>, // one-shot effects from ViewModel
onBack: () -> Unit,
onNavigateToYyy: (YyyRoute) -> Unit,
) {
// Collect effects
LaunchedEffect(Unit) {
effects.collectLatest { effect ->
when (effect) {
is XxxEffect.ShowToast -> { /* ... */ }
is XxxEffect.NavigateTo -> onNavigateToYyy(effect.route)
}
}
}
AppScaffold(
topBar = {
GlassMediumFlexibleTopAppBar(
title = { Text("Title") },
scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior(),
navigationButton = { TopBarNavigationButton(onBack) },
)
},
) { contentPadding ->
// UI content, no business logic here
}
}
Key rules:
state, onIntent, effects, never accesses ViewModel
directly.LaunchedEffect(Unit) { ... } using collectLatest.RouteScreen or entry provider if the screen
doesn't need them directly.AppScaffold, AppText, AppIcon, AppIcons, AppAlertDialog,
AppModalBottomSheet, NormalCard, GlassMediumFlexibleTopAppBar, TopBarNavigationButton,
TopBarActionButton, etc.Two input patterns are acceptable:
state: XxxUiState + onIntent: (XxxIntent) -> Unit —
ViewModel wired in entry provider or RouteScreen.viewModel: XxxViewModel = koinViewModel() — simpler for
standalone screens.UiState and UI item data classes must be annotated with @Stable.ImmutableList (from kotlinx.collections.immutable) for list properties in state classes,
not List or MutableList.persistentListOf() / toImmutableList() for default values.Uses Navigation 3 (androidx.navigation3). Routes are @Serializable sealed interfaces:
// In MainNavKey.kt
@Serializable
data class MainRouteXxx(val id: Long) : MainRoute
Entry registered in MainNavGraph.kt:
entry<MainRouteXxx> { route ->
val viewModel = koinViewModel<XxxViewModel>()
XxxScreen(
state = viewModel.uiState.collectAsStateWithLifecycle().value,
onIntent = viewModel::onIntent,
onBack = { onNavigateBack() },
onNavigateToYyy = { onNavigateToRoute(it) },
)
}
Key rules:
onBack, onNavigateToXxx lambdas.MainRoute sealed interface in MainNavKey.kt.di/appModule.kt with viewModelOf(::XxxViewModel).koinViewModel() (default param or explicit in entry provider).koinViewModel<XxxViewModel>(key = route.bookUrl).singleOf(::...).New standalone Compose activities extend BaseComposeActivity:
class XxxActivity : BaseComposeActivity() {
@Composable
override fun Content() {
// Screen content — AppTheme is already applied by the base class
}
}
For screens needing activity result handling, lifecycle observation, or permission requests, use a two-layer pattern:
XxxRouteScreen: handles ActivityResultLauncher, lifecycle callbacks, file pickers,
permission requests. Wires ViewModel.XxxScreen: pure UI, stateless with state + onIntent.The project supports two Compose theme engines. If a screen needs engine-specific UI, branch on:
if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
// Miuix implementation
} else {
// Material 3 implementation
}
For detailed Compose review conventions and migration patterns, see
.Codex/skills/legado-compose-review/.
Book sources, RSS sources, and HTTP TTS use JavaScript rules. initRhino() in App.kt registers NativeBaseSource wrappers for BookSource, RssSource, HttpTTS (writable JS objects) and ReadOnlyJavaObject wrappers for rule entities. Rule parsing logic lives in help/source/ and model/analyzeRule/.
AnalyzeByJSoup.kt and the JsoupXpath librarylibs.versions.toml:42io.legado.app but applicationId is io.legato.kazusanoR8 variant disables both for crash debuggingarmeabi-v7a, arm64-v8a, plus universal)google-services plugin appliedLocated in modules/web/ — a Vue 3 + TypeScript + Vite project for remote bookshelf and source editing. Must connect to the app's built-in HTTP server (started via WebService in the main activity settings). Commands:
cd modules/web
pnpm install
pnpm dev # dev server
pnpm build # production build
Set VITE_API in .env.development to the app's web service IP.