Skip to content

Commit 0f2943d

Browse files
fryanpanclaude
andcommitted
ADFA-4128: qb 02/12 plumbing — Host-side groundwork: feature flag, asset staging, build-service hooks, shared utilities
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
1 parent 828a18b commit 0f2943d

28 files changed

Lines changed: 818 additions & 410 deletions

File tree

‎.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

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ tests/test-home
111111
/composite-builds/build-deps/build/
112112

113113
/app/google-services.json
114+
/app/keystore-debug.jks
115+
114116

115117
# Kotlin build files
116118
.kotlin/

‎ARCHITECTURE.md‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle buil
5959
|---|---|---|
6060
| Application | `app` | The IDE itself — activities, fragments, services, DI, agent, web server. Wires everything together. |
6161
| 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. |
62+
| 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. |
6263
| Language tooling | `lsp:{api,java,kotlin,xml,indexing,…}`, `lexers`, `editor*`, `editor-treesitter` | Language servers, indexing, the Sora-based editor and highlighting. |
6364
| UI design tooling | `layouteditor`, `uidesigner`, `xml-inflater`, `vectormaster`, `compose-preview` | Visual/XML design surfaces for the *user's* app. |
6465
| Shell | `termux:{termux-app,termux-shared,termux-view,termux-emulator}` | Embedded Termux shell and terminal. |
@@ -68,6 +69,7 @@ Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle buil
6869
| Testing | `testing:{android,unit,lsp,tooling,common}` | Shared test harnesses, split by what's under test. |
6970

7071
**Dependency rules (enforced):**
72+
7173
- **`app` depends inward; libraries never depend on `app`.** Subsystems are consumed by `app`, not vice versa.
7274
- **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.
7375
- **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.
@@ -87,16 +89,16 @@ These structural facts shape every module. Day-to-day build *commands* live in `
8789

8890
## Technology Stack
8991

90-
| Concern | Library / Approach |
91-
|---|---|
92-
| 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. |
93-
| Dependency Injection | **Koin** (`org.koin`) — `coreModule`/`pluginModule`, `startKoin` in `IDEApplication`, plus a `ServiceLocator : KoinComponent` for lazy post-startup access. No Hilt/Dagger. |
94-
| Asynchronous work | **Kotlin Coroutines + Flow** (`StateFlow`/`SharedFlow`, `viewModelScope`, app-scoped `CoroutineScope(SupervisorJob() + Dispatchers.IO)`); **GreenRobot EventBus** for cross-subsystem events. |
95-
| 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. |
92+
| Concern | Library / Approach |
93+
| ---------------------- | ------------------------------------------------------------ |
94+
| 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. |
95+
| Dependency Injection | **Koin** (`org.koin`) — `coreModule`/`pluginModule`, `startKoin` in `IDEApplication`, plus a `ServiceLocator : KoinComponent` for lazy post-startup access. No Hilt/Dagger. |
96+
| Asynchronous work | **Kotlin Coroutines + Flow** (`StateFlow`/`SharedFlow`, `viewModelScope`, app-scoped `CoroutineScope(SupervisorJob() + Dispatchers.IO)`); **GreenRobot EventBus** for cross-subsystem events. |
97+
| 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. |
9698
| 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). |
97-
| Serialization | `kotlinx.serialization` and Gson. |
98-
| 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). |
99-
| AI agent | Google GenAI (cloud) + llama (local), behind `GeminiRepository` / `SwitchableGeminiRepository`, with planner/critic/executor agents in `agent/repository`. |
99+
| Serialization | `kotlinx.serialization` and Gson. |
100+
| 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). |
101+
| AI agent | Google GenAI (cloud) + llama (local), behind `GeminiRepository` / `SwitchableGeminiRepository`, with planner/critic/executor agents in `agent/repository`. |
100102

101103
> **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).
102104
>
@@ -184,13 +186,14 @@ fun onEvent(event: PluginManagerUiEvent) = viewModelScope.launch(Dispatchers.IO)
184186

185187
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 "…"`.
186188

187-
| Layer | Runner / Tools | What to test |
188-
|---|---|---|
189-
| 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. |
190-
| JVM + Android framework | **Robolectric** | Code needing `Context`/resources/`SQLiteOpenHelper` without an emulator. |
191-
| 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). |
189+
| Layer | Runner / Tools | What to test |
190+
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
191+
| 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. |
192+
| JVM + Android framework | **Robolectric** | Code needing `Context`/resources/`SQLiteOpenHelper` without an emulator. |
193+
| 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). |
192194

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

‎build-info/build.gradle.kts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ tasks.create("generateBuildInfo") {
7575
"AGP_VERSION_LATEST" to
7676
libs.versions.agp.tooling
7777
.get(),
78-
"AGP_VERSION_GRADLE_LATEST" to "8.6", // From SdkConstants.GRADLE_LATEST_VERSION
78+
// The Gradle version AGP_VERSION_LATEST gets exercised against: the
79+
// distribution the IDE bundles. 8.6 was stale - AGP 8.11 refuses to
80+
// configure on anything older than 8.13.
81+
"AGP_VERSION_GRADLE_LATEST" to "8.14.3",
7982
"SNAPSHOTS_REPOSITORY" to VersionUtils.SONATYPE_SNAPSHOTS_REPO,
8083
"PUBLIC_REPOSITORY" to VersionUtils.SONATYPE_PUBLIC_REPO,
8184
),

‎common/src/main/java/com/itsaky/androidide/managers/ToolsManager.java‎

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import static org.adfa.constants.ConstantsKt.V7_KEY;
2121
import static org.adfa.constants.ConstantsKt.V8_KEY;
2222

23-
import android.content.res.AssetManager;
2423
import android.os.Build;
2524
import androidx.annotation.NonNull;
2625
import androidx.annotation.WorkerThread;
@@ -107,7 +106,7 @@ public static void init(@NonNull BaseApplication app, Runnable onFinish) {
107106
// Load installed JDK distributions
108107
IJdkDistributionProvider.getInstance().loadDistributions();
109108

110-
updateToolingJar(app.getAssets());
109+
updateToolingJar(app);
111110
extractLogSender(app);
112111

113112
writeNoMediaFile();
@@ -244,11 +243,32 @@ private static String generateRandomPassword(int length) {
244243
return sb.toString();
245244
}
246245

246+
/**
247+
* Identity of the installed APK for the extraction stamp: versionName plus the package's lastUpdateTime, which changes on every (re)install - exactly when the bundled jar can change. Null (extract unconditionally) if the lookup fails.
248+
*/
249+
private static String installedApkStamp(BaseApplication app) {
250+
try {
251+
final var info = app.getPackageManager().getPackageInfo(app.getPackageName(), 0);
252+
return info.versionName + ":" + info.lastUpdateTime;
253+
} catch (Throwable err) {
254+
LOG.warn("Could not read package info for tooling jar stamp", err);
255+
return null;
256+
}
257+
}
258+
247259
@NonNull
248260
private static String readInitScript() {
249261
return ResourceUtils.readAssets2String(getCommonAsset("androidide.init.gradle"));
250262
}
251263

264+
private static String readStampFile(File stampFile) {
265+
try {
266+
return stampFile.isFile() ? FileIOUtils.readFile2String(stampFile) : null;
267+
} catch (Throwable err) {
268+
return null;
269+
}
270+
}
271+
252272
private static boolean shouldExtractScheme(final BaseApplication app, final File dir,
253273
final String path) throws IOException {
254274

@@ -293,10 +313,21 @@ private static boolean shouldExtractScheme(final BaseApplication app, final File
293313
}
294314

295315
@WorkerThread
296-
private static void updateToolingJar(AssetManager assets) {
316+
private static void updateToolingJar(BaseApplication app) {
297317
// Ensure relevant shared libraries are loaded
298318
Brotli4jLoader.ensureAvailability();
299319

320+
final var toolingJarFile = Environment.TOOLING_API_JAR;
321+
final var stampFile = new File(toolingJarFile.getParentFile(), toolingJarFile.getName() + ".stamp");
322+
final var stamp = installedApkStamp(app);
323+
if (toolingJarFile.isFile() && stamp != null && stamp.equals(readStampFile(stampFile))) {
324+
// The jar from this exact APK install is already extracted; skip the copy.
325+
// The stamp is written only after a complete extraction, so a partial
326+
// copy from a killed process can never satisfy this check.
327+
return;
328+
}
329+
330+
final var assets = app.getAssets();
300331
final var toolingJarName = "tooling-api-all.jar";
301332
InputStream toolingJarStream;
302333
try {
@@ -311,15 +342,24 @@ private static void updateToolingJar(AssetManager assets) {
311342
}
312343

313344
try {
314-
final var toolingJarFile = Environment.TOOLING_API_JAR;
315-
if (toolingJarFile.exists()) {
316-
FileUtils.delete(toolingJarFile);
317-
}
318-
345+
// Extract to a temp sibling, then rename into place. The tooling server
346+
// starts concurrently with this extraction (both run at app init), and
347+
// launching `java -jar` against a half-written jar kills project init
348+
// ("An unexpected error occurred while trying to open file ..."), so a
349+
// partial jar must never be visible at the final path. rename(2) within
350+
// one directory atomically replaces the target on Linux.
351+
final var tempFile = new File(toolingJarFile.getParentFile(), toolingJarFile.getName() + ".part");
319352
Objects.requireNonNull(toolingJarFile.getParentFile()).mkdirs();
320-
try (final var fos = new FileOutputStream(toolingJarFile)) {
353+
try (final var fos = new FileOutputStream(tempFile)) {
321354
IoUtilsKt.transferToStream(toolingJarStream, fos);
322355
}
356+
if (!tempFile.renameTo(toolingJarFile)) {
357+
LOG.error("Failed to move extracted tooling API jar into place");
358+
return;
359+
}
360+
if (stamp != null) {
361+
FileIOUtils.writeFileFromString(stampFile, stamp);
362+
}
323363
} catch (Throwable err) {
324364
LOG.error("Failed to copy tooling API jar", err);
325365
} finally {

‎common/src/main/java/com/itsaky/androidide/models/SaveResult.java‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,24 @@
2020
/** Result obtained when files are saved */
2121
public final class SaveResult {
2222

23-
/** Were any Gradle files saved? */
24-
public boolean gradleSaved = false;
23+
/** Were any Gradle files saved? */
24+
public boolean gradleSaved = false;
2525

26-
/** Were any XML files saved? */
27-
public boolean xmlSaved = false;
26+
/** Were any XML files saved? */
27+
public boolean xmlSaved = false;
2828

29-
public SaveResult() {}
29+
/**
30+
* Were any Android resource XML files (files under a module's {@code res/} directory) saved?
31+
*
32+
* <p>
33+
* Narrower than {@link #xmlSaved} on purpose: only a resource save can change {@code R}, and the Gradle {@code generateSources()} run that follows a save is load-bearing exactly there. Java resolves {@code R.string.*} from the regenerated {@code R.jar} on the compile classpath (the run posts {@code ProjectInitializedEvent}, which makes {@code JavaLanguageServer} drop its stale jar-FS cache), and with view binding on, only {@code dataBindingGenBaseClasses} writes the accessor for an id just added to a layout. Manifest edits and other non-resource XML cannot change {@code R}, so they skip that run.
34+
*/
35+
public boolean resourceXmlSaved = false;
3036

31-
public SaveResult(boolean gradleSaved, boolean xmlSaved) {
32-
this.gradleSaved = gradleSaved;
33-
this.xmlSaved = xmlSaved;
34-
}
37+
public SaveResult() {}
38+
39+
public SaveResult(boolean gradleSaved, boolean xmlSaved) {
40+
this.gradleSaved = gradleSaved;
41+
this.xmlSaved = xmlSaved;
42+
}
3543
}

0 commit comments

Comments
 (0)