Audience: engineers working in this repository. This describes how the code is actually organized today, including where the codebase is mid-migration. Patterns that are the agreed target for new code are called out explicitly.
Code On The Go (CoGo) is a full Android IDE that runs on the device — it edits, builds, and deploys real Android apps offline, embedding a Termux toolchain and running an actual Gradle build in a separate process via the tooling-api. It is the maintained successor to AndroidIDE, so the codebase namespace is still com.itsaky.androidide.
There is no single architectural philosophy across the whole app. This large, layered application is still predominantly View-based: newer feature surfaces (plugin manager, AI agent, git, project list) follow a deliberate Unidirectional Data Flow (UDF) with Koin DI, ViewModel + StateFlow, sealed UI-state/effect types, and repositories, while older surfaces still use LiveData and talk to GreenRobot EventBus directly. New work follows the UDF pattern documented below, and new UI is built in Jetpack Compose (ADR 0009) — Compose replaces the view layer only; the UDF stack (ViewModel + StateFlow, Koin, repositories) is unchanged. Existing XML/View screens remain until substantially reworked.
Feature code layers as UI → ViewModel → Repository → data source, with state flowing up and events/intents flowing down. Koin provides dependencies (coreModule, pluginModule), constructor-injected into ViewModels.
- Data sources — Room (
RecentProjectRoomDatabase+ DAO,suspendfunctions), raw SQLite (SQLiteOpenHelper, e.g.localWebServer/WebServer), the filesystem/preferences, the embeddedtooling-api(on-device Gradle), and external clients (Gemini via the Google GenAI SDK, on-device llama.cpp, JGit). Most are exposed throughsuspendfunctions. - Repositories — e.g.
agent/repository/GeminiRepository,repositories/PluginRepository,repositories/BreakpointRepository. They wrap data sources and hide threading/IO from the ViewModel. - ViewModels — run work in
viewModelScopeonDispatchers.IO, hold a privateMutableStateFlow/MutableSharedFlow, and expose read-onlyStateFlow/SharedFlow. One-shot effects (toasts, navigation, dialogs) go through a separateSharedFlowof a sealed*UiEffecttype. - UI (Fragments / Activities / Views) — collect state in a lifecycle-aware coroutine and render it; user actions return to the ViewModel as method calls or sealed
*UiEventintents. The existing UI is Android Views + Fragments + RecyclerView adapters; new UI is Jetpack Compose (ADR 0009). (compose-previewpreviews the user's Compose code, not CoGo's own.)
┌─────────────────────────────────────────────┐
intents ↓ │ UI LAYER │ ↑ state
(UiEvent / │ Activity / Fragment / RecyclerView.Adapter │ (StateFlow)
method call) │ collects StateFlow, renders; emits intents │ ↑ effects
└───────────────┬─────────────────────────────┘ (UiEffect SharedFlow)
│
┌───────────────▼─────────────────────────────┐
│ VIEWMODEL LAYER │
│ viewModelScope + Dispatchers.IO │
│ private MutableStateFlow ──► StateFlow │
│ private MutableSharedFlow ─► UiEffect flow │
│ sealed UiState / UiEvent / UiEffect / State │
└───────────────┬─────────────────────────────┘
│ suspend calls
┌───────────────▼─────────────────────────────┐
│ REPOSITORY LAYER │
│ GeminiRepository / PluginRepository / ... │
└───────────────┬─────────────────────────────┘
│
┌───────────────────────┼────────────────────────────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────────┐ ┌──────────────────┐
│ Room / │ │ tooling-api │ │ External clients │
│ SQLite / │ │ (on-device │ │ Gemini · llama │
│ FS / prefs│ │ Gradle build)│ │ · JGit │
└───────────┘ └───────────────┘ └──────────────────┘
Cross-cutting: GreenRobot EventBus carries decoupled, app-wide events
(build progress, install results, editor signals) outside the UDF spine.
EventBus is a deliberate side-channel. Long-running, cross-module signals (build/install lifecycle, editor events) are broadcast via GreenRobot EventBus (@Subscribe(threadMode = ThreadMode.MAIN)) and the eventbus-events module's shared event types. Treat it as the integration bus between subsystems; don't use it to replace a ViewModel's own state inside a single screen.
Strategy: layer-and-subsystem based, not feature-by-feature. The Gradle build has ~80 modules (settings.gradle.kts) plus three included composite builds. app is the integration point; the rest are libraries it composes.
| Group | Modules | Responsibility |
|---|---|---|
| Application | app |
The IDE itself — activities, fragments, services, DI, agent, web server. Wires everything together. |
| Build engine | subprojects:tooling-api*, gradle-plugin*, subprojects:projects, subprojects:builder-model-impl |
Runs a real Gradle build of the user's project out-of-process and streams events back. |
| Language tooling | lsp:{api,java,kotlin,xml,indexing,…}, lexers, editor*, editor-treesitter |
Language servers, indexing, the Sora-based editor and highlighting. |
| UI design tooling | layouteditor, uidesigner, xml-inflater, vectormaster, compose-preview |
Visual/XML design surfaces for the user's app. |
| Shell | termux:{termux-app,termux-shared,termux-view,termux-emulator} |
Embedded Termux shell and terminal. |
| Plugin system | plugin-api, plugin-api:plugin-builder, plugin-manager |
In-app plugin SDK + manager — AndroidManifest.xml <meta-data> contract, permissions, extensions. See plugin-api.md for the API surface & compatibility policy. |
| On-device AI | llama-api, llama-impl |
llama.cpp integration, shipped as a per-flavor native AAR. |
| Cross-cutting | eventbus, eventbus-android, eventbus-events, common, common-ui, logger, resources, preferences, shared |
Shared infra and the event bus. |
| Testing | testing:{android,unit,lsp,tooling,common} |
Shared test harnesses, split by what's under test. |
Dependency rules (enforced):
appdepends inward; libraries never depend onapp. Subsystems are consumed byapp, not vice versa.- Vendored forks are substituted, not imported ad hoc.
composite-builds/build-depsandbuild-deps-commonprovide forkedjavac/jdt/layoutlib/etc.;settings.gradle.ktssubstitutes them in forcom.itsaky.androidide.build:*. Don't add a Maven coordinate for something already substituted. - All module config flows through
composite-builds/build-logic. Every Android module gets thev7/v8ABI flavors centrally (AndroidModuleConf.kt) — there is no flavorlessassembleDebug.:plugin-apiis intentionally excluded from flavors. RepositoriesMode.FAIL_ON_PROJECT_REPOS— repositories are declared once insettings.gradle.kts; modules must not declare their own.- Avoid new dependencies. Check
gradle/libs.versions.tomlfirst; the build almost certainly already has it.
These structural facts shape every module. Day-to-day build commands live in CLAUDE.md; the rules that produce them live here.
- Centralized convention logic. All Android module setup flows through
composite-builds/build-logic(conf/AndroidModuleConf.kt). Modules stay thin and share configuration, so understanding any module's setup starts here. - ABI product flavors. Every Android module except
:plugin-apigets two flavors on theabidimension —v7(armeabi-v7a) andv8(arm64-v8a) — defined centrally. There is no flavorless variant; tasks areassembleV8Debug,assembleV7Release, etc. - SDK levels (
build-logic/.../build/config/BuildConfig.kt):COMPILE_SDK=36,MIN_SDK=28,TARGET_SDK=28.TARGET_SDKis deliberately pinned at 28: higher targets enforce W^X (write-xor-execute), which blocks executing code from app-writable files. That is fatal for an on-device IDE that compiles and runs code (Gradle,javac, Termux binaries), so it is a hard requirement, not tech debt.MIN_SDK_FOR_APPS_BUILT_WITH_COGO=16is the floor for the apps a user builds with CoGo — distinct from CoGo's ownMIN_SDK. - Native asset bundling. The on-device LLM (
llama-impl) ships as a per-flavor native AAR, wired through the rootbuild.gradle.kts(bundleLlamaV8Assets/assembleV8Assets, …); prebuilt per-flavor assets live underassets/release/v7/andassets/release/v8/. - Native lib compression (ADFA-2306, ADFA-4729). The app manifest hard-codes
android:extractNativeLibs="true"(required: the installer must materialize libs innativeLibraryDir, e.g.libshizuku.sois an executable the adb shell runs from there). That attribute overrides thejniLibs.useLegacyPackagingDSL, so AGP packageslib/<abi>/*.sodeflate-compressed in every APK — ~5.9 MB smaller (libtree-sitter-kotlin.soalone is 4.18 MB → 339 kB). The trap is therecompressApkpost-step (release always, debug in CI only): its no-compress lists inapp/build.gradle.ktsmust NOT contain"so", or it silently re-stores the libs and undoes the saving — which is what ADFA-2306 fixed for release and ADFA-4729 for CI debug. Locally built debug APKs (including the e2e farm's) never run that step and were always fine. apppackage layout is by concern, not feature:activities,fragments,services,di,agent,viewmodel(s),repositories,roomData,localWebServer,preferences,ui,utils, ….
| Concern | Library / Approach |
|---|---|
| UI | Jetpack Compose for all new UI (ADR 0009). The existing majority is still Android Views + Fragments + RecyclerView (Material Components); those legacy screens stay until reworked, but new IDE UI is Compose-only. |
| Dependency Injection | Koin (org.koin) — coreModule/pluginModule, startKoin in IDEApplication, plus a ServiceLocator : KoinComponent for lazy post-startup access. No Hilt/Dagger. |
| Asynchronous work | Kotlin Coroutines + Flow (StateFlow/SharedFlow, viewModelScope, app-scoped CoroutineScope(SupervisorJob() + Dispatchers.IO)); GreenRobot EventBus for cross-subsystem events. |
| Networking | Offline-first; no general REST layer. External I/O is Google GenAI SDK (Gemini), on-device llama.cpp, and JGit (git). Retrofit is in the catalog but effectively unused in app code. |
| Database / Persistence | Room is the default for relational/queryable data; filesystem + preferences (DataStore) for non-relational settings. Raw SQLite (SQLiteDatabase / SupportSQLiteOpenHelper) only for justified exceptions (see policy below). |
| Serialization | kotlinx.serialization and Gson. |
| Parceling | Kotlin @Parcelize (kotlin-parcelize plugin) for Parcelable data classes — never hand-implement Parcelable. Do it manually only if @Parcelize genuinely can't express it (custom serialization logic, unsupported member types). |
| AI agent | Google GenAI (cloud) + llama (local), behind GeminiRepository / SwitchableGeminiRepository, with planner/critic/executor agents in agent/repository. |
Persistence policy (authoritative): new relational/queryable persistence uses Room (
@Entity+ DAO +RoomDatabasewith explicit migrations, provided via Koin). Non-relational settings use the filesystem/preferences (DataStore). Raw SQLite is the exception, not the default — see ADR 0001.Recent Projects is the reference example of the default:
app/src/main/java/com/itsaky/androidide/roomData/recentproject/(RecentProjectRoomDatabase,@Database version = 4with migrations 1→4;RecentProjectDao; theRecentProject@Entity→ tablerecent_project_table). It's provided via Koin indi/AppModule.ktand consumed byMainViewModel,RecentProjectsViewModel,MainActivity,ProjectInfoBottomSheet, andProjectCreationManager.Raw SQLite is allowed only when the database is prebuilt and opened read-only, the data is performance/allocation-critical and needs granular schema control, or the schema is shared across a process/component boundary. Current exceptions: symbol indexing (
lsp/indexing/SQLiteIndex.kt), tooltips (idetooltips/ToolTipManager.kt), in-app/plugin help (plugin-manager/.../documentation/PluginDocumentationManager.kt), and the local web server (app/.../localWebServer/WebServer.kt).idetooltipsalso declares unused Room Gradle deps (remove them), and theandroidx.room:*strings ineditor'sGroovyAutoCompleteare autocomplete suggestions for the user's code, not CoGo persistence.
- Make illegal states unrepresentable. When a screen's states are mutually exclusive (loading / content / success / error / installing …), model them as a sealed interface/class — not a
data classcarrying severalBooleans that can contradict each other ("boolean hell", state explosion). A single immutabledata classis right only when the fields are genuinely independent. - Either shape is exposed as a
StateFlow<…UiState>; the ViewModel mutates a privateMutableStateFlow—update { it.copy(...) }for a data class, or emit the next subtype for a sealed state. Derived flags live as computed properties (data class) or are implied by the subtype (sealed), so the UI stays dumb. - One-shot effects (errors, navigation, dialogs, restart prompts) are modeled as a sealed
…UiEffectemitted through a separateSharedFlow— never folded into the persistent state, so they don't replay on rotation. - Inbound intents are a sealed
…UiEvent(or direct ViewModel method calls on older screens). - Process/long-task state uses dedicated sealed hierarchies — e.g.
BuildState,TaskState,InstallationState,ApkInstallationViewModel.SessionState,agent/AgentState. - Legacy screens still expose
LiveData(~8 ViewModels) instead ofStateFlow(~20). When touching one substantially, prefer migrating it toStateFlow.
Two shapes, chosen by whether the states are mutually exclusive.
Mutually-exclusive states → sealed (real example, git-core/.../CloneRepoUiState.kt) — the default when a screen moves through phases:
sealed interface CloneRepoUiState {
data class Idle(val url: String = "", val localPath: String = "", val isCloneButtonEnabled: Boolean = false) : CloneRepoUiState
data class Cloning(val url: String, val localPath: String, val cloneProgress: String = "", val clonePercentage: Int = 0) : CloneRepoUiState
data class Success(val localPath: String) : CloneRepoUiState
data class Error(val url: String, val errorMessage: String? = null, val canRetry: Boolean = false) : CloneRepoUiState
}
// The UI `when`s over the state — Idle/Cloning/Success/Error can never be true at once.Independent fields → a single immutable data class (example, ui/models/PluginManagerUiState.kt). isLoading/isInstalling/isEmpty) only work because they're independent; the moment states become exclusive, switch to a sealed type rather than adding more flags:
// Persistent, immutable UI state — exposed as StateFlow<PluginManagerUiState>
data class PluginManagerUiState(
val isLoading: Boolean = false,
val plugins: List<PluginInfo> = emptyList(),
val isPluginManagerAvailable: Boolean = false,
val isInstalling: Boolean = false,
) {
val isEmpty: Boolean get() = plugins.isEmpty() && !isLoading // derived in state
val showEmptyState: Boolean get() = isEmpty && isPluginManagerAvailable
}
// Inbound intents from the UI
sealed class PluginManagerUiEvent {
object LoadPlugins : PluginManagerUiEvent()
data class EnablePlugin(val pluginId: String) : PluginManagerUiEvent()
data class InstallPlugin(val uri: Uri, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent()
// ...
}
// One-shot effects — emitted via a SharedFlow, not stored in UiState
sealed class PluginManagerUiEffect {
data class ShowError(@StringRes val messageResId: Int, val formatArgs: List<Any> = emptyList()) : PluginManagerUiEffect()
object ShowRestartPrompt : PluginManagerUiEffect()
// ...
}Typical ViewModel wiring:
private val _uiState = MutableStateFlow(PluginManagerUiState())
val uiState: StateFlow<PluginManagerUiState> = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<PluginManagerUiEffect>()
val effects: SharedFlow<PluginManagerUiEffect> = _effects.asSharedFlow()
fun onEvent(event: PluginManagerUiEvent) = viewModelScope.launch(Dispatchers.IO) {
when (event) {
PluginManagerUiEvent.LoadPlugins -> {
_uiState.update { it.copy(isLoading = true) }
val plugins = repository.loadPlugins()
_uiState.update { it.copy(isLoading = false, plugins = plugins) }
}
// ...
}
}Test code lives both alongside each module and in the shared testing:{unit,android,lsp,tooling,common} harnesses. Run with the flox wrapper, e.g. flox activate -d flox/local -- ./gradlew :testing:unit:test or a module's :module:test --tests "…".
| Layer | Runner / Tools | What to test |
|---|---|---|
| Unit (pure JVM) | JUnit Jupiter (5), some legacy JUnit 4; assertions via Google Truth; mocking via MockK (primary) and Mockito-Kotlin (legacy) | ViewModels (state transitions over a fake repository), repositories, parsers, builder/tooling logic. Keep these off the device. |
| JVM + Android framework | Robolectric | Code needing Context/resources/SQLiteOpenHelper without an emulator. |
| Instrumented / UI | Espresso + AndroidX Test + UiAutomator, run under Test Orchestrator; mockk-android for on-device mocks |
End-to-end IDE flows (create/build/deploy, editor, terminal). |
Preferences and conventions:
- Assertions: Google Truth (
assertThat(x).isEqualTo(...)) over raw JUnit asserts. - Mocking: MockK for new code; relax it deliberately rather than over-stubbing.
- For UDF ViewModels, drive
onEvent(...)/method calls against a fake or mocked repository and assert the emittedUiStatesequence (collect theStateFlow); assert effects by collecting the effectSharedFlow. - On the M2 emulator, prefer short
flakySafelytimeouts (2–3s) andAccessibilityNodeInfo.ACTION_CLICKover raw coordinate taps — the bottom system-bar region swallows coordinate clicks, and tests must never obstruct the two system bars (top status, bottom nav).