Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 6 additions & 0 deletions .github/workflows/analyze.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ jobs:
# the unit-test compile path.
FIREBASE_CONSOLE_URL: ${{ secrets.FIREBASE_CONSOLE_URL }}
GLITCHTIP_DSN: ${{ secrets.GLITCHTIP_DSN }}
# The aapt2/d8/Compose regression tests (ADFA-4128 bugs 5/6/8) are
# assumption-guarded, so on a runner without an Android SDK they would skip
# green and take that coverage with them. This turns an absent toolchain
# into a hard failure instead. The runner does have an SDK - Assemble V8
# Debug above could not run otherwise.
REQUIRE_BUILD_TOOLCHAIN: "1"
run: flox activate -d flox/base -- ./gradlew :testing:tooling:assemble :testing:common:assemble sonarqube --info --no-build-cache -x lint --continue

- name: Upload JaCoCo report
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ tests/test-home
/composite-builds/build-deps/build/

/app/google-services.json
/app/keystore-debug.jks


# Kotlin build files
.kotlin/
Expand Down
53 changes: 28 additions & 25 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,21 @@ Feature code layers as **UI → ViewModel → Repository → data source**, with

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](docs/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. |
| 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. |
| Quick Build (experimental, ADFA-4128) | `quickbuild:core`, `quickbuild:daemon`, `quickbuild:protocol`, `quickbuild:runtime` | Live-reloads the user's app on every save in seconds, by running it as a generated proxy app instead of doing a full Gradle rebuild. |
| 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](docs/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):**

- **`app` depends inward; libraries never depend on `app`.** Subsystems are consumed by `app`, not vice versa.
- **Vendored forks are substituted, not imported ad hoc.** `composite-builds/build-deps` and `build-deps-common` provide forked `javac`/`jdt`/`layoutlib`/etc.; `settings.gradle.kts` substitutes them in for `com.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 the `v7`/`v8` ABI flavors centrally (`AndroidModuleConf.kt`) — there is no flavorless `assembleDebug`. `:plugin-api` is intentionally excluded from flavors.
Expand All @@ -87,16 +89,16 @@ These structural facts shape every module. Day-to-day build *commands* live in `

## Technology Stack

| Concern | Library / Approach |
|---|---|
| UI | **Jetpack Compose for all new UI** ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)). 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. |
| Concern | Library / Approach |
| ---------------------- | ------------------------------------------------------------ |
| UI | **Jetpack Compose for all new UI** ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)). 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`. |
| 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 + `RoomDatabase` with explicit migrations, provided via Koin). Non-relational settings use the **filesystem/preferences (DataStore)**. **Raw SQLite is the exception, not the default** — see [ADR 0001](docs/adr/0001-prefer-room-for-persistence.md).
>
Expand Down Expand Up @@ -182,13 +184,14 @@ fun onEvent(event: PluginManagerUiEvent) = viewModelScope.launch(Dispatchers.IO)

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). |
| 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 emitted `UiState` sequence (collect the `StateFlow`); assert effects by collecting the effect `SharedFlow`.
Expand Down
65 changes: 65 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.itsaky.androidide.build.config.BuildConfig
import com.itsaky.androidide.desugaring.utils.JavaIOReplacements.applyJavaIOReplacements
import com.itsaky.androidide.plugins.AndroidIDEAssetsPlugin
import com.itsaky.androidide.plugins.tasks.AddFileToAssetsTask
import org.json.JSONObject
import java.io.BufferedOutputStream
import java.io.ByteArrayInputStream
Expand Down Expand Up @@ -318,6 +319,7 @@ dependencies {
implementation(projects.floatingWindow)
implementation(projects.gitCore)
implementation(projects.profiler)
implementation(projects.quickbuild.core)

// This is to build the tooling-api-impl project before the app is built
// So we always copy the latest JAR file to assets
Expand Down Expand Up @@ -363,6 +365,69 @@ dependencies {
implementation("io.pebbletemplates:pebble:4.1.1")
}

// Quick Build (ADFA-4128): stage the runtime AAR + daemon (jar + runtime classpath)
// into APK assets, mirroring the LogSender AAR flow in AndroidIDEAssetsPlugin. The
// artifacts are extracted to <ANDROIDIDE_HOME>/quickbuild/ at session start
// (QuickBuildArtifactStager).
evaluationDependsOn(":quickbuild:runtime")
evaluationDependsOn(":quickbuild:daemon")

val quickBuildDaemonZip =
tasks.register<Zip>("quickBuildDaemonZip") {
archiveFileName.set("quickbuild-daemon.zip")
destinationDirectory.set(layout.buildDirectory.dir("intermediates/quickbuild"))
val daemonProject = rootProject.project(":quickbuild:daemon")
dependsOn(daemonProject.tasks.named("daemonJar"))
from(daemonProject.tasks.named("daemonJar"))
// The daemon jar's manifest Class-Path names these by file name; they must sit
// next to the jar after extraction.
from(daemonProject.configurations.named("runtimeClasspath"))
// Compose compiler plugin, version-matched to the daemon's compiler; the stable
// name is the contract EnvironmentQuickBuildPaths.composeCompilerPlugin reads.
from(daemonProject.configurations.named("composeCompilerPlugin")) {
rename { "compose-compiler-plugin.jar" }
}
}

androidComponents.onVariants { variant ->
val variantName = variant.name.replaceFirstChar(Char::uppercaseChar)
val flavorName = variant.flavorName!!

val copyRuntimeAar =
tasks.register<AddFileToAssetsTask>("copy${variantName}QuickBuildRuntimeAar") {
val runtimeProject = rootProject.project(":quickbuild:runtime")
dependsOn(
runtimeProject.tasks.named(
"assemble${flavorName.replaceFirstChar(Char::uppercaseChar)}Release",
),
)
inputFile.set(
runtimeProject.layout.buildDirectory.file(
"outputs/aar/quickbuild-runtime-$flavorName-release.aar",
),
)
baseAssetsPath.set("data/common")
// Flavor-agnostic asset name: the runtime AAR is pure Java, both flavors
// produce identical bits, and the stager doesn't need to care.
fileName.set("quickbuild-runtime.aar")
}
variant.sources.assets?.addGeneratedSourceDirectory(
copyRuntimeAar,
AddFileToAssetsTask::outputDirectory,
)

val copyDaemonZip =
tasks.register<AddFileToAssetsTask>("copy${variantName}QuickBuildDaemonZip") {
dependsOn(quickBuildDaemonZip)
inputFile.set(quickBuildDaemonZip.flatMap { it.archiveFile })
baseAssetsPath.set("data/common")
}
variant.sources.assets?.addGeneratedSourceDirectory(
copyDaemonZip,
AddFileToAssetsTask::outputDirectory,
)
}

tasks.register("downloadDocDb") {
doLast {
val githubRepo = "appdevforall/OfflineDocumentationTools"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import org.junit.runners.Suite

@RunWith(Suite::class)
@Suite.SuiteClasses(
CleanupTest::class,
EndToEndTest::class,
CleanupTest::class,
EndToEndTest::class,
QuickBuildSmokeTest::class,
QuickBuildFlagOffTest::class,
)
class OrderedTestSuite
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.itsaky.androidide

import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.itsaky.androidide.activities.SplashActivity
import com.itsaky.androidide.activities.editor.EditorHandlerActivity
import com.itsaky.androidide.app.configuration.IJdkDistributionProvider
import com.itsaky.androidide.helper.isExperimentsFlagSet
import com.itsaky.androidide.helper.setExperimentsFlagForTest
import com.itsaky.androidide.helper.waitForMainHomeOrEditorUi
import com.itsaky.androidide.screens.QuickBuildScreen.assertQuickBuildButtonAbsent
import com.itsaky.androidide.screens.QuickBuildScreen.assertQuickBuildButtonShown
import com.itsaky.androidide.utils.EditorActivityActions
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import org.junit.Test
import org.junit.runner.RunWith

private const val TOOLBAR_TIMEOUT_MS = 15_000L

/**
* The shipping-state gate for Quick Build (ADFA-4128, manual test T13): with no
* `CodeOnTheGo.exp` flag file on the device, the feature must be invisible.
*
* The gate is a single read of [com.itsaky.androidide.utils.FeatureFlags.isExperimentsEnabled]
* at [EditorActivityActions.register], so this drives that decision directly instead of
* restarting the process: flip the flag, re-register, rebuild the toolbar, look. That also
* makes the test honest about what it covers - the registration site, not the process-start
* caching around it.
*
* Both directions run in one test on purpose. An absence assertion alone passes when the
* accessibility selector rots or the toolbar simply never rendered, so the flag-on step
* ahead of it is load-bearing, not decoration.
*
* Runs after [QuickBuildSmokeTest] in [OrderedTestSuite], which leaves the editor open on a
* synced project - this test needs a populated editor toolbar and creates no project of its
* own.
*/
@RunWith(AndroidJUnit4::class)
class QuickBuildFlagOffTest : TestCase() {
private var hadExperimentsFlag = false

@Test
fun test_noExperimentsFlagHidesQuickBuild() =
before {
// A dev device may legitimately have experiments enabled; restore whatever
// state this test found.
hadExperimentsFlag = isExperimentsFlagSet()
IJdkDistributionProvider.getInstance().loadDistributions()
}.after {
setExperimentsFlagForTest(hadExperimentsFlag)
// Leave the toolbar matching the restored flag so a later test does not
// inherit this one's registry.
runCatching { rebuildEditorToolbar() }
}.run {
step("Launch app") {
ActivityScenario.launch(SplashActivity::class.java)
waitForMainHomeOrEditorUi(device.uiDevice)
}

step("Experiments on: the toolbar carries Quick Build") {
setExperimentsFlagForTest(true)
rebuildEditorToolbar()
assertQuickBuildButtonShown(TOOLBAR_TIMEOUT_MS)
}

step("Experiments off: the toolbar drops Quick Build") {
setExperimentsFlagForTest(false)
rebuildEditorToolbar()
assertQuickBuildButtonAbsent(TOOLBAR_TIMEOUT_MS)
}
}

/**
* Re-runs action registration and repopulates the toolbar, which is what an editor
* launch does. On the main thread: both touch the actions registry and the toolbar
* views.
*/
private fun rebuildEditorToolbar() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val activity = resumedEditorActivity()
instrumentation.runOnMainSync {
EditorActivityActions.register(activity)
activity.prepareOptionsMenu()
}
instrumentation.waitForIdleSync()
}

private fun resumedEditorActivity(): EditorHandlerActivity =
device.activities.getResumed() as? EditorHandlerActivity
?: error("Resumed activity is not the editor; this test needs an open project")
}
Loading
Loading