Skip to content
Open
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
Jump to file
Failed to load files.
Loading
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
31 changes: 17 additions & 14 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle buil
|---|---|---|
| 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. |
Expand All @@ -68,6 +69,7 @@ Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle buil
| 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 @@ -184,13 +186,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
5 changes: 4 additions & 1 deletion build-info/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ tasks.create("generateBuildInfo") {
"AGP_VERSION_LATEST" to
libs.versions.agp.tooling
.get(),
"AGP_VERSION_GRADLE_LATEST" to "8.6", // From SdkConstants.GRADLE_LATEST_VERSION
// The Gradle version AGP_VERSION_LATEST gets exercised against: the
// distribution the IDE bundles. 8.6 was stale - AGP 8.11 refuses to
// configure on anything older than 8.13.
"AGP_VERSION_GRADLE_LATEST" to "8.14.3",

@itsaky-adfa itsaky-adfa Aug 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're in the process of upgrading this to AGP 9+. Given this PR stack of 11 PRs, I guess that change would land first before this stack. Since it would be a major version upgrade, what changes would we need for Quick Build?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — we're already working on a branch that merges this stack with Daniel's stack. If the AGP 9 upgrade lands first, we can add one more PR to the end of this stack to pick it up.

One data point, offered as a data point rather than a guarantee: Quick Build has executed against AGP 9.3.1 — a full benchmark pass ran from a branch pinned to it. That tells us it runs there; it is not a compatibility audit, and we have not enumerated what AGP 9 changes about the specific APIs the Quick Build Gradle plugin depends on. Happy to do that properly once the upgrade path is settled.

"SNAPSHOTS_REPOSITORY" to VersionUtils.SONATYPE_SNAPSHOTS_REPO,
"PUBLIC_REPOSITORY" to VersionUtils.SONATYPE_PUBLIC_REPO,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
import static org.adfa.constants.ConstantsKt.V7_KEY;
import static org.adfa.constants.ConstantsKt.V8_KEY;

import android.content.res.AssetManager;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import com.aayushatharva.brotli4j.Brotli4jLoader;
import com.aayushatharva.brotli4j.decoder.BrotliInputStream;
Expand Down Expand Up @@ -107,7 +107,7 @@ public static void init(@NonNull BaseApplication app, Runnable onFinish) {
// Load installed JDK distributions
IJdkDistributionProvider.getInstance().loadDistributions();

updateToolingJar(app.getAssets());
updateToolingJar(app);
extractLogSender(app);

writeNoMediaFile();
Expand All @@ -129,6 +129,44 @@ public static void init(@NonNull BaseApplication app, Runnable onFinish) {
});
}

/**
* Copies the stream to a temp sibling of toolingJarFile, renames it into place, then writes the stamp. The tooling server starts concurrently with this extraction (both run at app init), and launching `java -jar` against a half-written jar kills project init ("An unexpected error occurred while trying to open file ..."), so a partial jar must never be visible at the final path. rename(2) within one directory atomically replaces the target on Linux. The stamp is written only after a successful rename, so a failure at any step leaves the stamp absent and the next launch retries. Always closes the stream.
*/
@VisibleForTesting
static void extractToolingJar(InputStream toolingJarStream, File toolingJarFile, File stampFile, String stamp) {
try {
final var tempFile = new File(toolingJarFile.getParentFile(), toolingJarFile.getName() + ".part");
Objects.requireNonNull(toolingJarFile.getParentFile()).mkdirs();
try (final var fos = new FileOutputStream(tempFile)) {
IoUtilsKt.transferToStream(toolingJarStream, fos);
}
if (!tempFile.renameTo(toolingJarFile)) {
LOG.error("Failed to move extracted tooling API jar into place");
return;
}
if (stamp != null && !FileIOUtils.writeFileFromString(stampFile, stamp)) {
// Fail-safe: a lost stamp just re-extracts next launch, but say so.
LOG.warn("Failed to write tooling jar stamp file {}", stampFile);
}
} catch (Throwable err) {
LOG.error("Failed to copy tooling API jar", err);
} finally {
try {
toolingJarStream.close();
} catch (IOException e) {
LOG.error("Failed to close tooling API jar stream", e);
}
}
}

/**
* Whether the jar at the final path was extracted from this exact APK install. True only when the jar exists AND the stamp file holds this install's stamp. The stamp is written only after a complete extraction, so a partial copy from a killed process can never satisfy this check. A null stamp (package lookup failed) always re-extracts.
*/
@VisibleForTesting
static boolean isToolingJarCurrent(File toolingJarFile, File stampFile, String stamp) {
return toolingJarFile.isFile() && stamp != null && stamp.equals(readStampFile(stampFile));
}

private static void deleteIdeenv() {
final var file = new File(Environment.BIN_DIR, "ideenv");
if (file.exists() && !file.delete()) {
Expand Down Expand Up @@ -244,11 +282,32 @@ private static String generateRandomPassword(int length) {
return sb.toString();
}

/**
* 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.
*/
private static String installedApkStamp(BaseApplication app) {
try {
final var info = app.getPackageManager().getPackageInfo(app.getPackageName(), 0);
return info.versionName + ":" + info.lastUpdateTime;
} catch (Throwable err) {
LOG.warn("Could not read package info for tooling jar stamp", err);
return null;
}
}

@NonNull
private static String readInitScript() {
return ResourceUtils.readAssets2String(getCommonAsset("androidide.init.gradle"));
}

private static String readStampFile(File stampFile) {
try {
return stampFile.isFile() ? FileIOUtils.readFile2String(stampFile) : null;
} catch (Throwable err) {
return null;
}
}

private static boolean shouldExtractScheme(final BaseApplication app, final File dir,
final String path) throws IOException {

Expand Down Expand Up @@ -293,10 +352,21 @@ private static boolean shouldExtractScheme(final BaseApplication app, final File
}

@WorkerThread
private static void updateToolingJar(AssetManager assets) {
private static void updateToolingJar(BaseApplication app) {
// Ensure relevant shared libraries are loaded
Brotli4jLoader.ensureAvailability();

// Deliberately NOT gated on FeatureFlags.isExperimentsEnabled: a torn jar kills
// project init for every user, Quick Build or not, so gating would leave flag-off users exposed.
final var toolingJarFile = Environment.TOOLING_API_JAR;
final var stampFile = new File(toolingJarFile.getParentFile(), toolingJarFile.getName() + ".stamp");
final var stamp = installedApkStamp(app);
if (isToolingJarCurrent(toolingJarFile, stampFile, stamp)) {
// The jar from this exact APK install is already extracted; skip the copy.
return;
}

final var assets = app.getAssets();
final var toolingJarName = "tooling-api-all.jar";
InputStream toolingJarStream;
try {
Expand All @@ -310,25 +380,7 @@ private static void updateToolingJar(AssetManager assets) {
}
}

try {
final var toolingJarFile = Environment.TOOLING_API_JAR;
if (toolingJarFile.exists()) {
FileUtils.delete(toolingJarFile);
}

Objects.requireNonNull(toolingJarFile.getParentFile()).mkdirs();
try (final var fos = new FileOutputStream(toolingJarFile)) {
IoUtilsKt.transferToStream(toolingJarStream, fos);
}
} catch (Throwable err) {
LOG.error("Failed to copy tooling API jar", err);
} finally {
try {
toolingJarStream.close();
} catch (IOException e) {
LOG.error("Failed to close tooling API jar stream", e);
}
}
extractToolingJar(toolingJarStream, toolingJarFile, stampFile, stamp);
}

private static void writeInitScript() {
Expand Down
26 changes: 17 additions & 9 deletions common/src/main/java/com/itsaky/androidide/models/SaveResult.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,24 @@
/** Result obtained when files are saved */
public final class SaveResult {

/** Were any Gradle files saved? */
public boolean gradleSaved = false;
/** Were any Gradle files saved? */
public boolean gradleSaved = false;

/** Were any XML files saved? */
public boolean xmlSaved = false;
/** Were any XML files saved? */
public boolean xmlSaved = false;

public SaveResult() {}
/**
* Were any Android resource XML files (files under a module's {@code res/} directory) saved?
*
* <p>
* 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.
*/
public boolean resourceXmlSaved = false;

public SaveResult(boolean gradleSaved, boolean xmlSaved) {
this.gradleSaved = gradleSaved;
this.xmlSaved = xmlSaved;
}
public SaveResult() {}

public SaveResult(boolean gradleSaved, boolean xmlSaved) {
this.gradleSaved = gradleSaved;
this.xmlSaved = xmlSaved;
}
}
Loading
Loading