Skip to content

Commit 5d22249

Browse files
committed
Merge remote-tracking branch 'origin/stage' into feat/ADFA-5069-replace-in-files
# Conflicts: # app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt # app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt
2 parents e8af87f + 785b7af commit 5d22249

574 files changed

Lines changed: 98748 additions & 2649 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/analyze.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ jobs:
105105
# the unit-test compile path.
106106
FIREBASE_CONSOLE_URL: ${{ secrets.FIREBASE_CONSOLE_URL }}
107107
GLITCHTIP_DSN: ${{ secrets.GLITCHTIP_DSN }}
108+
# The aapt2/d8/Compose regression tests (ADFA-4128 bugs 5/6/8) are
109+
# assumption-guarded, so on a runner without an Android SDK they would skip
110+
# green and take that coverage with them. This turns an absent toolchain
111+
# into a hard failure instead. The runner does have an SDK - Assemble V8
112+
# Debug above could not run otherwise.
113+
REQUIRE_BUILD_TOOLCHAIN: "1"
108114
run: flox activate -d flox/base -- ./gradlew :testing:tooling:assemble :testing:common:assemble sonarqube --info --no-build-cache -x lint --continue
109115

110116
- name: Upload JaCoCo report
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: Spotless apply
2+
3+
# For contributors without a shell (e.g. editing in the GitHub web UI): runs spotlessApply on a
4+
# branch and appends the result as a standalone commit. The push uses GITHUB_TOKEN, so it does not
5+
# start other workflows.
6+
7+
permissions:
8+
contents: write
9+
10+
on:
11+
workflow_dispatch:
12+
inputs:
13+
branch:
14+
description: 'Branch to format (not stage or main)'
15+
required: true
16+
type: string
17+
18+
concurrency:
19+
group: spotless-apply-${{ inputs.branch }}
20+
cancel-in-progress: false
21+
22+
jobs:
23+
spotless_apply:
24+
name: Run spotlessApply and commit
25+
runs-on: self-hosted
26+
timeout-minutes: 30
27+
env:
28+
BRANCH: ${{ inputs.branch }}
29+
30+
steps:
31+
- name: Refuse protected branches
32+
run: |
33+
case "$BRANCH" in
34+
stage|main)
35+
echo "::error::Refusing to push to '$BRANCH'."
36+
exit 1
37+
;;
38+
esac
39+
40+
# fetch-depth 0: the Spotless ratchet compares against origin/stage.
41+
- name: Checkout branch
42+
uses: actions/checkout@v4
43+
with:
44+
ref: ${{ inputs.branch }}
45+
fetch-depth: 0
46+
47+
- name: Check if Nix is installed
48+
run: |
49+
if command -v nix >/dev/null 2>&1; then
50+
echo "nix_installed=true" >> $GITHUB_ENV
51+
else
52+
echo "nix_installed=false" >> $GITHUB_ENV
53+
fi
54+
55+
- name: Install Flox
56+
if: env.nix_installed == 'false'
57+
uses: flox/install-flox-action@v2
58+
59+
- name: Run spotlessApply
60+
run: |
61+
flox activate -d flox/base -- ./gradlew spotlessApply --no-daemon
62+
63+
- name: Commit and push
64+
run: |
65+
if git diff --quiet; then
66+
echo "::notice::Already formatted. Nothing to commit."
67+
exit 0
68+
fi
69+
git config user.name "ADFA"
70+
git config user.email "dev-team@appdevforall.org"
71+
git commit -am "style: spotless reformat, no functional change"
72+
git push origin "HEAD:refs/heads/$BRANCH"
73+
echo "::notice::Pushed $(git rev-parse --short HEAD) to $BRANCH."

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ sentry.properties
109109
/composite-builds/build-deps/build/
110110

111111
/app/google-services.json
112+
/app/keystore-debug.jks
113+
112114

113115
# Written by the gradle-plugin tests, not by a Gradle task: writeInitScript() in
114116
# gradle-plugin/src/test/.../utils.kt resolves FileProvider.testHomeDir() and creates

‎ARCHITECTURE.md‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle buil
7171
|---|---|---|
7272
| Application | `app` | The IDE itself — activities, fragments, services, DI, agent, web server. Wires everything together. |
7373
| 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. |
74+
| 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. |
7475
| Language tooling | `lsp:{api,java,kotlin,xml,indexing,refactor-core,ui,…}`, `lexers`, `editor*`, `editor-treesitter` | Language servers, indexing, the Sora-based editor and highlighting, and the tree-sitter document outline (`editor/.../language/outline`, rendered by `app`'s sidebar `OutlineFragment`). `lsp:refactor-core` holds the language-agnostic half of the refactorings (offset spans, block geometry, rewrite composition, name primitives) so `lsp:java` and `lsp:kotlin` share one copy; `lsp:ui` holds the Compose sheets they share. Neither depends on a language server. |
7576
| UI design tooling | `layouteditor`, `uidesigner`, `xml-inflater`, `vectormaster`, `compose-preview` | Visual/XML design surfaces for the *user's* app. |
7677
| Shell | `termux:{termux-app,termux-shared,termux-view,termux-emulator}` | Embedded Termux shell and terminal. |
@@ -80,6 +81,7 @@ Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle buil
8081
| Testing | `testing:{android,unit,lsp,tooling,common}` | Shared test harnesses, split by what's under test. |
8182

8283
**Dependency rules (enforced):**
84+
8385
- **`app` depends inward; libraries never depend on `app`.** Subsystems are consumed by `app`, not vice versa.
8486
- **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.
8587
- **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.
@@ -106,9 +108,9 @@ These structural facts shape every module. Day-to-day build *commands* live in `
106108
| Asynchronous work | **Kotlin Coroutines + Flow** (`StateFlow`/`SharedFlow`, `viewModelScope`, app-scoped `CoroutineScope(SupervisorJob() + Dispatchers.IO)`); **GreenRobot EventBus** for cross-subsystem events. |
107109
| 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. |
108110
| 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). |
109-
| Serialization | `kotlinx.serialization` and Gson. |
110-
| 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). |
111-
| AI agent | Google GenAI (cloud) + llama (local), behind `GeminiRepository` / `SwitchableGeminiRepository`, with planner/critic/executor agents in `agent/repository`. |
111+
| Serialization | `kotlinx.serialization` and Gson. |
112+
| 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). |
113+
| AI agent | Google GenAI (cloud) + llama (local), behind `GeminiRepository` / `SwitchableGeminiRepository`, with planner/critic/executor agents in `agent/repository`. |
112114

113115
> **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).
114116
>
@@ -196,13 +198,14 @@ fun onEvent(event: PluginManagerUiEvent) = viewModelScope.launch(Dispatchers.IO)
196198

197199
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 "…"`.
198200

199-
| Layer | Runner / Tools | What to test |
200-
|---|---|---|
201-
| 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. |
202-
| JVM + Android framework | **Robolectric** | Code needing `Context`/resources/`SQLiteOpenHelper` without an emulator. |
203-
| 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). |
201+
| Layer | Runner / Tools | What to test |
202+
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
203+
| 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. |
204+
| JVM + Android framework | **Robolectric** | Code needing `Context`/resources/`SQLiteOpenHelper` without an emulator. |
205+
| 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). |
204206

205207
Preferences and conventions:
208+
206209
- **Assertions: Google Truth** (`assertThat(x).isEqualTo(...)`) over raw JUnit asserts.
207210
- **Mocking: MockK** for new code; relax it deliberately rather than over-stubbing.
208211
- 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`.

‎app/build.gradle.kts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.itsaky.androidide.build.config.BuildConfig
44
import com.itsaky.androidide.desugaring.utils.JavaIOReplacements.applyJavaIOReplacements
55
import com.itsaky.androidide.plugins.AndroidIDEAssetsPlugin
6+
import com.itsaky.androidide.plugins.tasks.AddFileToAssetsTask
67
import org.adfa.constants.GRADLE_API_NAME_JAR_BR
78
import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP
89
import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME
@@ -204,6 +205,46 @@ android {
204205
}
205206
}
206207

208+
// DoD coverage gate. Two path traps are baked in below; both fail silently rather
209+
// than erroring, so each is spelled out here to stop the next measurement repeating one.
210+
//
211+
// executionData: the root build attaches the jacoco agent to every Test task, but for an
212+
// Android module the exec lands under outputs/unit_test_code_coverage/<variant>UnitTest/,
213+
// not build/jacoco/. A report pointed at build/jacoco/ SKIPs and measures nothing.
214+
//
215+
// classDirectories: :app runs transformV8DebugClassesWithAsm, so the bytecode the tests
216+
// executed is NOT tmp/kotlin-classes/v8Debug. That path is right for the quickbuild
217+
// modules (they run no ASM transform) and wrong here: measured 2026-09-10 it makes 138
218+
// classes report "Execution data ... does not match" and count as fully uncovered, which
219+
// reads as a real coverage hole rather than a misconfiguration. The transformed dirs
220+
// below give 0 mismatches.
221+
tasks.register<JacocoReport>("jacocoTestReport") {
222+
group = "verification"
223+
description = "JaCoCo line+branch coverage for the v8Debug unit tests."
224+
dependsOn("testV8DebugUnitTest")
225+
226+
reports {
227+
xml.required.set(true)
228+
html.required.set(true)
229+
}
230+
231+
classDirectories.setFrom(
232+
fileTree(
233+
layout.buildDirectory.dir(
234+
"intermediates/classes/v8Debug/transformV8DebugClassesWithAsm/dirs",
235+
),
236+
) {
237+
exclude("**/BuildConfig*")
238+
},
239+
)
240+
sourceDirectories.setFrom(files("src/main/java"))
241+
executionData.setFrom(
242+
layout.buildDirectory.file(
243+
"outputs/unit_test_code_coverage/v8DebugUnitTest/testV8DebugUnitTest.exec",
244+
),
245+
)
246+
}
247+
207248
// Sentry gradle plugin config (crash reporting to GlitchTip).
208249
sentry {
209250
includeProguardMapping = false
@@ -404,6 +445,7 @@ dependencies {
404445
implementation(projects.floatingWindow)
405446
implementation(projects.gitCore)
406447
implementation(projects.profiler)
448+
implementation(projects.quickbuild.core)
407449

408450
// This is to build the tooling-api-impl project before the app is built
409451
// So we always copy the latest JAR file to assets
@@ -457,6 +499,69 @@ dependencies {
457499
coreLibraryDesugaring(libs.desugar.jdk.libs.v215)
458500
}
459501

502+
// Quick Build (ADFA-4128): stage the runtime AAR + daemon (jar + runtime classpath)
503+
// into APK assets, mirroring the LogSender AAR flow in AndroidIDEAssetsPlugin. The
504+
// artifacts are extracted to <ANDROIDIDE_HOME>/quickbuild/ at session start
505+
// (QuickBuildArtifactStager).
506+
evaluationDependsOn(":quickbuild:runtime")
507+
evaluationDependsOn(":quickbuild:daemon")
508+
509+
val quickBuildDaemonZip =
510+
tasks.register<Zip>("quickBuildDaemonZip") {
511+
archiveFileName.set("quickbuild-daemon.zip")
512+
destinationDirectory.set(layout.buildDirectory.dir("intermediates/quickbuild"))
513+
val daemonProject = rootProject.project(":quickbuild:daemon")
514+
dependsOn(daemonProject.tasks.named("daemonJar"))
515+
from(daemonProject.tasks.named("daemonJar"))
516+
// The daemon jar's manifest Class-Path names these by file name; they must sit
517+
// next to the jar after extraction.
518+
from(daemonProject.configurations.named("runtimeClasspath"))
519+
// Compose compiler plugin, version-matched to the daemon's compiler; the stable
520+
// name is the contract EnvironmentQuickBuildPaths.composeCompilerPlugin reads.
521+
from(daemonProject.configurations.named("composeCompilerPlugin")) {
522+
rename { "compose-compiler-plugin.jar" }
523+
}
524+
}
525+
526+
androidComponents.onVariants { variant ->
527+
val variantName = variant.name.replaceFirstChar(Char::uppercaseChar)
528+
val flavorName = variant.flavorName!!
529+
530+
val copyRuntimeAar =
531+
tasks.register<AddFileToAssetsTask>("copy${variantName}QuickBuildRuntimeAar") {
532+
val runtimeProject = rootProject.project(":quickbuild:runtime")
533+
dependsOn(
534+
runtimeProject.tasks.named(
535+
"assemble${flavorName.replaceFirstChar(Char::uppercaseChar)}Release",
536+
),
537+
)
538+
inputFile.set(
539+
runtimeProject.layout.buildDirectory.file(
540+
"outputs/aar/quickbuild-runtime-$flavorName-release.aar",
541+
),
542+
)
543+
baseAssetsPath.set("data/common")
544+
// Flavor-agnostic asset name: the runtime AAR is pure Java, both flavors
545+
// produce identical bits, and the stager doesn't need to care.
546+
fileName.set("quickbuild-runtime.aar")
547+
}
548+
variant.sources.assets?.addGeneratedSourceDirectory(
549+
copyRuntimeAar,
550+
AddFileToAssetsTask::outputDirectory,
551+
)
552+
553+
val copyDaemonZip =
554+
tasks.register<AddFileToAssetsTask>("copy${variantName}QuickBuildDaemonZip") {
555+
dependsOn(quickBuildDaemonZip)
556+
inputFile.set(quickBuildDaemonZip.flatMap { it.archiveFile })
557+
baseAssetsPath.set("data/common")
558+
}
559+
variant.sources.assets?.addGeneratedSourceDirectory(
560+
copyDaemonZip,
561+
AddFileToAssetsTask::outputDirectory,
562+
)
563+
}
564+
460565
tasks.register("downloadDocDb") {
461566
doLast {
462567
val githubRepo = "appdevforall/OfflineDocumentationTools"

‎app/proguard-rules.pro‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@
256256
-keep class com.google.android.material.** { *; }
257257
-keep class io.noties.markwon.** { *; }
258258
-keep class com.google.gson.** { *; }
259+
-keep class org.snakeyaml.engine.** { *; }
259260

260261
-keep class com.google.firebase.** { *; }
261262
-keep class com.google.android.gms.** { *; }

‎app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import org.junit.runners.Suite
55

66
@RunWith(Suite::class)
77
@Suite.SuiteClasses(
8-
CleanupTest::class,
9-
EndToEndTest::class,
8+
CleanupTest::class,
9+
EndToEndTest::class,
10+
QuickBuildPipelineTest::class,
11+
QuickBuildSmokeTest::class,
12+
QuickBuildFlagOffTest::class,
1013
)
1114
class OrderedTestSuite
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package com.itsaky.androidide
2+
3+
import androidx.test.core.app.ActivityScenario
4+
import androidx.test.ext.junit.runners.AndroidJUnit4
5+
import androidx.test.platform.app.InstrumentationRegistry
6+
import com.itsaky.androidide.activities.SplashActivity
7+
import com.itsaky.androidide.activities.editor.EditorHandlerActivity
8+
import com.itsaky.androidide.app.configuration.IJdkDistributionProvider
9+
import com.itsaky.androidide.helper.isExperimentsFlagSet
10+
import com.itsaky.androidide.helper.setExperimentsFlagForTest
11+
import com.itsaky.androidide.helper.waitForMainHomeOrEditorUi
12+
import com.itsaky.androidide.screens.QuickBuildScreen.assertQuickBuildButtonAbsent
13+
import com.itsaky.androidide.screens.QuickBuildScreen.assertQuickBuildButtonShown
14+
import com.itsaky.androidide.utils.EditorActivityActions
15+
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
19+
private const val TOOLBAR_TIMEOUT_MS = 15_000L
20+
21+
/**
22+
* The shipping-state gate for Quick Build (ADFA-4128, manual test T13): with no
23+
* `CodeOnTheGo.exp` flag file on the device, the feature must be invisible.
24+
*
25+
* The gate is a single read of [com.itsaky.androidide.utils.FeatureFlags.isExperimentsEnabled]
26+
* at [EditorActivityActions.register], so this drives that decision directly instead of
27+
* restarting the process: flip the flag, re-register, rebuild the toolbar, look. That also
28+
* makes the test honest about what it covers - the registration site, not the process-start
29+
* caching around it.
30+
*
31+
* Both directions run in one test on purpose. An absence assertion alone passes when the
32+
* accessibility selector rots or the toolbar simply never rendered, so the flag-on step
33+
* ahead of it is load-bearing, not decoration.
34+
*
35+
* Runs after [QuickBuildSmokeTest] in [OrderedTestSuite], which leaves the editor open on a
36+
* synced project - this test needs a populated editor toolbar and creates no project of its
37+
* own.
38+
*/
39+
@RunWith(AndroidJUnit4::class)
40+
class QuickBuildFlagOffTest : TestCase() {
41+
private var hadExperimentsFlag = false
42+
43+
@Test
44+
fun test_noExperimentsFlagHidesQuickBuild() =
45+
before {
46+
// A dev device may legitimately have experiments enabled; restore whatever
47+
// state this test found.
48+
hadExperimentsFlag = isExperimentsFlagSet()
49+
IJdkDistributionProvider.getInstance().loadDistributions()
50+
}.after {
51+
setExperimentsFlagForTest(hadExperimentsFlag)
52+
// Leave the toolbar matching the restored flag so a later test does not
53+
// inherit this one's registry.
54+
runCatching { rebuildEditorToolbar() }
55+
}.run {
56+
step("Launch app") {
57+
ActivityScenario.launch(SplashActivity::class.java)
58+
waitForMainHomeOrEditorUi(device.uiDevice)
59+
}
60+
61+
step("Experiments on: the toolbar carries Quick Build") {
62+
setExperimentsFlagForTest(true)
63+
rebuildEditorToolbar()
64+
assertQuickBuildButtonShown(TOOLBAR_TIMEOUT_MS)
65+
}
66+
67+
step("Experiments off: the toolbar drops Quick Build") {
68+
setExperimentsFlagForTest(false)
69+
rebuildEditorToolbar()
70+
assertQuickBuildButtonAbsent(TOOLBAR_TIMEOUT_MS)
71+
}
72+
}
73+
74+
/**
75+
* Re-runs action registration and repopulates the toolbar, which is what an editor
76+
* launch does. On the main thread: both touch the actions registry and the toolbar
77+
* views.
78+
*/
79+
private fun rebuildEditorToolbar() {
80+
val instrumentation = InstrumentationRegistry.getInstrumentation()
81+
val activity = resumedEditorActivity()
82+
instrumentation.runOnMainSync {
83+
EditorActivityActions.register(activity)
84+
activity.prepareOptionsMenu()
85+
}
86+
instrumentation.waitForIdleSync()
87+
}
88+
89+
private fun resumedEditorActivity(): EditorHandlerActivity =
90+
device.activities.getResumed() as? EditorHandlerActivity
91+
?: error("Resumed activity is not the editor; this test needs an open project")
92+
}

0 commit comments

Comments
 (0)