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. The first production example is the Manager screen (PluginManagerActivity) — merged Plugins/Templates tabs built with Scaffold/TabRow/HorizontalPager (ADFA-4928).
Feature code layers as UI → ViewModel → Repository → data source, with state flowing up and events/intents flowing down. Koin provides dependencies (coreModule, pluginModule, templateModule), constructor-injected into ViewModels.
- Data sources — Room (
RecentProjectRoomDatabase+ DAO,suspendfunctions), raw SQLite (SQLiteOpenHelper, e.g.common/.../documentation/DocumentationContentSource), 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/TemplateRepository,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) — first used in the Manager screen (ui/compose/ManagerScreen.kt, ADFA-4928). (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.
App Links enter through a UI-less trampoline, not MainActivity directly. DeepLinkActivity (app/src/main/java/com/itsaky/androidide/activities/DeepLinkActivity.kt) is the sole <intent-filter> holder for https://appdevforall.org/device/open/project/... (and the identical www subdomain). It never renders anything — it parses the URI into a DeepLinkRequest (project name plus an optional file/line/column), checks whether an editor is already on screen (ActionContextProvider.getActivity(), the live EditorHandlerActivity tracker -- not IProjectManager's workspace, which stays null for the whole duration of a Gradle sync even while the editor is already open), and routes to MainActivity (nothing open) or the live, singleTask EditorActivityKt/EditorHandlerActivity (a project is open — reused via onNewIntent), then finishes itself. This avoids a visible flash of MainActivity's real UI when the actual destination is the already-running editor.
EditorHandlerActivity.onNewIntent then branches on projectDirPath (set as soon as a project starts opening) rather than workspace so the mid-sync case still matches correctly: same project already open — no project-wise work, just navigate to the requested file (applyDeepLinkFileRequest); a different project is open — the existing, unmodified confirmProjectClose() dialog runs (it also guards against a second confirm-close request overlapping a manual close or an in-flight save, and a third overlapping request supersedes the second's pending callback rather than being dropped), and only once the user actually confirms does an onDestroy()-triggered hand-off (PendingDeepLinkOpen, Koin-provided) start the new project — deliberately deferred to onDestroy(), not fired synchronously after finish(), so the new PROJECT_PATH can't race a singleTask re-delivery to the dying instance; projectDirPath is still blank — this instance never actually finished initializing a project (e.g. recreated after process death with no PROJECT_PATH extra), so confirmProjectClose() would silently no-op (contentOrNull is null); this case reuses the same onDestroy()-deferred hand-off instead of showing a close dialog for a project that was never really open; nothing was open — MainActivity.openProject/EditorHandlerActivity.postProjectInit apply the pending file request once the cold-opened project's sync succeeds.
DeepLinkActivity's "is a live editor already on screen" check (ActionContextProvider.getActivity()) is itself a heuristic, not a guarantee: Android can still spin up a genuinely new EditorActivityKt instance instead of delivering to the live one via onNewIntent. BaseEditorActivity.onCreate is EXTRA_KEY's only other reader on the editor side for exactly this case — it compares the deep link's requested project name against whatever project the new instance actually ends up holding (explicit PROJECT_PATH extra, restored savedInstanceState, or the process-wide ProjectManagerImpl singleton's last-loaded project) and, on a mismatch, bounces back to MainActivity with the deep link forwarded rather than silently continuing to build editor UI for the wrong project.
The optional file path is attacker-controllable (a URL segment), so it's resolved through PathTraversal.resolveWithinDirectory's traversal/symlink guard rather than a bare File join, both when opening a file in the already-open project and when matching the requested project name to a directory under Environment.PROJECTS_DIR (findValidProjectByName).
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, common-compose, logger, resources, preferences, shared |
Shared infra and the event bus. common-compose holds the Compose theming any module can opt into (see ADR 0009); it is a leaf so modules that aren't Compose pay nothing. |
| 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(Compose screens live underui/compose),templates/manager(the Manager screen's.cgt-parsing data layer, with direct filesystem access toEnvironment.TEMPLATES_DIR— distinct from the plugin-facingIdeTemplateServiceinplugin-api/plugin-manager),utils, ….
| Concern | Library / Approach |
|---|---|
| UI | Jetpack Compose for all new UI (ADR 0009); first production screen is the Manager screen (Plugins/Templates tabs, app/.../ui/compose/, ADFA-4928). 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/templateModule, 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 byRecentProjectsViewModel,ProjectInfoBottomSheet, andProjectCreationManagerdirectly, and byMainActivity/EditorHandlerActivityindirectly throughRecentProjectRepository(repositories/RecentProjectRepository.kt) -- kept behind that interface, rather than injecting the DAO into those two Activities directly, per this section's own UI -> ViewModel -> Repository -> data source layering.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 documentation serving (common/.../documentation/DocumentationContentSource.kt, the one pipeline behind both the in-process WebView transport andapp/.../localWebServer/WebServer.kt). Theandroidx.room:*strings ineditor'sGroovyAutoCompleteare autocomplete suggestions for the user's code, not CoGo persistence.The tooltip, in-app/plugin-help, and local-web-server exceptions all read
documentation.db, the prebuilt Tier 1/2/3 help database — see docs/documentation-database.md for its schema and how each consumer queries it.
- 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 source: PluginInstallSource, 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).