Skip to content

feat(android): org.nativescript:gradle-api, Gradle facts file and opt-in read-only node_modules - #6175

Draft
edusperoni wants to merge 2 commits into
mainfrom
feat/gradle-api
Draft

edusperoni wants to merge 2 commits into
mainfrom
feat/gradle-api

Conversation

@edusperoni

Copy link
Copy Markdown
Collaborator

PR Checklist

Draft for discussion. This is a proposal for how plugins' Android Gradle files should find
things from now on, and how the CLI stops writing into node_modules. Comments on the design are
more useful than comments on the code at this point. Supersedes #5937 and NativeScript/android#1897,
and the aarSuffix option from #6132.

What is the current behavior?

Plugins guess paths. A plugin's platforms/android/include.gradle finds what it needs by
building paths itself. From published plugins:

def widgetsDir = "$USER_PROJECT_ROOT/node_modules/@nativescript/core/platforms/android"   // 4 nativescript-community plugins
maven { url = project.file('../../../node_modules/detox/Detox-android') }                // detox
if (!project.hasProperty("tempBuild")) { apply plugin: 'com.google.gms.google-services' } // firebase, push, … (6+ repos)

Each one bakes in something the CLI should be free to change: where node_modules is, where the CLI
puts a plugin's built .aar, how many ../ reach the project root, what tempBuild means.
That's why #5937 (move built aars out of node_modules) broke plugins and stalled.

The CLI writes into node_modules. Exactly two places do (I audited every write):

  • the built .aar of each plugin with Android sources goes to node_modules/<plugin>/platforms/android/<name>.aar;
  • include.gradle is rewritten in place to strip productFlavors.

(iOS already writes its generated files to platforms/ios/.plugins.)

Plugin aar names clash. The aar name drops the npm scope, so @foo/plugin-x and @bar/plugin-x
both build plugin_x.aar, share platforms/tempPlugin/plugin_x, and get the same namespace
fallback org.nativescript.plugin_x. Which one the app ends up linking depends on build order.

Android Studio builds lose App_Resources settings. The app and App_Resources locations are
passed only as -PappPath/-PappResourcesPath on the CLI's own Gradle calls. Building
platforms/android from Android Studio falls back to app/App_Resources, so a project with another
layout silently skips App_Resources/Android/app.gradle, before-plugins.gradle, gradle.properties
and libs/.

What is the new behavior?

Four pieces. Nothing changes for an existing project unless it opts in, except where noted.

1. org.nativescript:gradle-api: a Gradle library plugins call instead of guessing

New Java library in gradle-api/, published to Maven Central with its own version.
Same model as androidx: the plugin carries the library, so an updated plugin keeps building for
someone on CLI 9.1.

// platforms/android/buildscript.gradle
repositories { mavenCentral() }
dependencies { classpath "org.nativescript:gradle-api:1.0.0" }

// platforms/android/include.gradle
apply plugin: "org.nativescript.api"
if (!nativescript.pluginBuild) {
    apply plugin: "com.google.gms.google-services"
}
  • API v1: apiLevel, has(capability), pluginBuild, self, isBuildingPlugin(name),
    projectDir, appDir, appResourcesDir, plugin(name) / findPlugin(name) → name,
    packageDir, nativeDir, artifacts, aarName, aar, options, and resolvePackage(name) /
    findPackage(name). Full reference and recipes: gradle-api/README.md.
  • Where answers come from:
    • On CLI 9.2+, the library reads the facts file the CLI writes (piece 2).
    • With no facts file (9.1 and older), it works things out exactly the way those CLIs always did:
      dependencies.json, USER_PROJECT_ROOT, -PappPath, tempBuild.
    • Plugins never see the difference.
  • Versions: every plugin in an app adds the library to the same classpath and Gradle keeps the
    highest version, so the library only ever grows. Plugins pin the lowest version they need.
  • Toolchain: compiled for Java 11 against the Gradle 7.5 API, the oldest toolchain a supported
    runtime uses. A plugin that adopts the library therefore needs runtime 8.4+ (Gradle 7.5 /
    AGP 7.3); plugins still supporting older runtimes keep their current Gradle code for those.
  • Inside the plugin's own .aar build. There, only the dependencies {} and repositories {}
    blocks of include.gradle run: the CLI copies them out of the file. On older CLIs the library
    isn't loaded there. The README shows the guarded pattern that works everywhere:
    dependencies {
        def ns = project.extensions.findByName("nativescript")
        if (ns != null && ns.pluginBuild) {
            compileOnly ns.plugin("@nativescript/core").artifacts
        } else if (project.hasProperty("tempBuild")) {
            compileOnly fileTree(dir: "$USER_PROJECT_ROOT/node_modules/@nativescript/core/platforms/android", include: "*.aar")
        }
    }

2. Facts file: platforms/android/nativescript-gradle.json (always written)

Written by ns prepare android right after dependencies.json, before any plugin is built. Schema:
gradle-api/schema/nativescript-gradle.schema.json.

  • Contents: project root, app dir, App_Resources, and for each native plugin its package dir,
    native dir, aar name and location, and native dependencies. Paths are relative to the file, with
    forward slashes. The file is only rewritten when its content changes.
  • Passed to Gradle: -Pnativescript.factsFile=<path> goes to app builds and plugin builds. The
    library also finds the file next to the app build, so Android Studio builds get the same answers
    without the CLI's flags.
  • Staleness guard: the file carries dependenciesJson.sha256. An older CLI (after a downgrade)
    rewrites dependencies.json but not this file. The library sees the mismatch, warns, and uses the
    fallback rules.
  • Versioning rules:
    • fields are only ever added and never change meaning;
    • readers ignore unknown fields;
    • capabilities (empty in v1) lets readers tell "not set" from "this CLI doesn't know about it";
    • options per plugin is reserved for per-plugin settings from nativescript.config later.

3. Unique aar names (always on, only affects projects with a clash)

A plugin whose short name is unique keeps it, so every project without a clash builds exactly as
before (dependencies.json byte-identical, asserted in tests). When names clash, scoped plugins get
<scope>_<name>, for example foo_plugin_x. That name is used for the aar, the temp build dir and
the namespace fallback.

A clash that can't be resolved, such as @foo/plugin-x vs @foo/plugin_x, now fails prepare with
a clear message instead of building one of them.

An old aar left behind under the previous name would add duplicate classes, so:

  • it is deleted if it's byte-identical to the CLI's own previous build output;
  • otherwise prepare stops with the exact path and "delete it or run ns clean".

Projects with a clash are linking only one of the two plugins today. Both plugins used to build into
the same temp folder, so on the first prepare after upgrading, a clashing project usually has to run
ns clean once.

4. Opt-in: keep node_modules read-only

// nativescript.config.ts
export default {
  android: { readOnlyNodeModules: true },
} satisfies NativeScriptConfig;
  • What it does:
    • The CLI copies each native plugin's platforms/android and its package.json into
      platforms/android/.ns-plugins/<name>/.
    • It builds the plugin's aar there and does the productFlavors rewrite on the copy.
    • dependencies.json points at the copy.
  • Why it works without a runtime release: every runtime template, from 6.5.3 to current, reads
    plugins only through dependencies.json's directory, and so does Android Studio. No runtime
    change is needed.
  • Keeping incremental builds fast:
    • the copy is incremental (content hashes), keeps upstream mtimes so Gradle's inputs stay stable,
      removes files deleted upstream, and never deletes the CLI's own outputs;
    • watchers, change detection and metadata filtering keep reading the real plugin folders.
  • Plugin .aar builds: the CLI also applies the plugin's own buildscript.gradle in its own
    .aar build, and applies the library there when that script puts it on the classpath.
    • That's a behaviour change for existing plugins, which is why it's behind the opt-in.
    • I checked the installed official plugins' buildscript.gradle files; they only add classpath
      entries and repositories.

Without the key, behaviour is today's plus pieces 2 and 3. Turning it off again deletes
.ns-plugins. After turning it on, reinstall node_modules once to get rid of .aar files earlier
builds left there.

Compatibility

CLI ≤ 9.1 CLI 9.2+ CLI 9.2+, readOnlyNodeModules
Existing plugin, app build unchanged unchanged reads its files from the copy
Existing plugin, own .aar build unchanged unchanged its buildscript.gradle also runs
Plugin using the library, app build fallback rules facts facts
Plugin using the library, own .aar build guarded fallback guarded fallback library loaded
Downgrading the CLI after 9.2 — — — (stale facts detected, fallback)

How Martin's proposals fit later

These become additions to the facts file and API, with no breaking changes:

Proposal Where it lands
Per-plugin options (#6135) plugins[].options → nativescript.plugin(name).options
Build only device ABIs (#6130, #6134) a build.abis fact, guarded by a capability
aarSuffix (#6132) not needed: names are unique automatically, and plugins ask the library instead of using file names
Custom build dir (#6131) already handled: paths are relative to the facts file, not counted with ../
Gradle files shipped with the CLI (#6129) any template reads the same facts
Per-platform ignored native deps (#6128) ignored plugins are left out of plugins
tempBuild (#5691) nativescript.pluginBuild; tempBuild keeps being set

Tests

  • CLI (npm test, 2144 passed):
    • naming: no clash unchanged, scoped clashes, scoped + unscoped, leading digit, unresolvable clash,
      order independence;
    • stale aar delete/fail;
    • mirror sync: incremental, keeps mtimes, deletions, keeps outputs;
    • facts content validated against the schema with ajv;
    • dependencies.json byte-identical without the opt-in;
    • -P flags;
    • a golden test that the CLI extracts exactly the include.gradle blocks the Java tests run.
  • Library (cd gradle-api && ./gradlew check, 188 unit + 15 functional, needs JDK 17):
    • App-build tests mimic the runtime template's structure (buildscript.gradle applied
      to: buildscript, include.gradle applied from inside a closure). They load the library from a
      local Maven repo, the way a real plugin does, on Gradle 7.5.1 / 8.14.3 / 9.8.1, with and without
      facts, and with stale facts.
    • Plugin-build tests run vendor/gradle-plugin verbatim with AGP 8.12.1. They prove the guarded
      pattern works without the library (CLI 9.1) and with it (opt-in).
  • CI: new .github/workflows/gradle_api.yml.
  • Not yet done: a run on a real app. To try it:
    # in this checkout
    npm run build
    # in a test app (its local CLI becomes this checkout)
    npm i --no-save -D /path/to/nativescript-cli
    npx ns build android                         # default mode: platforms/android/nativescript-gradle.json appears
    # add `android: { readOnlyNodeModules: true }` to nativescript.config.ts, reinstall node_modules, then:
    npx ns build android                         # node_modules untouched; plugins under platforms/android/.ns-plugins
    (cd platforms/android && ./gradlew assembleDebug)   # the Android Studio path, without the CLI's -P flags

Not in this PR

  • Making readOnlyNodeModules the default. That's a later major, once plugins had time to adopt.
  • The runtime template reading the facts file directly.
  • Per-plugin options from config, and build-time facts (release/flavor/ABIs).
  • Bundling the library jar inside the CLI npm package (offline builds).
  • A binary-compatibility check (japicmp) for the library.

Open questions

  1. Before publishing to Maven Central: we need publishing access to the org.nativescript
    namespace (@triniwiz published org.nativescript:canvas there), a release signing key, and a
    Central Portal token. The four secrets go in a protected maven-central environment; see
    Releasing.
  2. The CLI's vendored Gradle wrapper can't be verified.
    vendor/gradle-plugin/gradle/wrapper/gradle-wrapper.jar (sha256 414142e6…fe0a3) is a Gradle 3.3
    wrapper from 2017, and Gradle doesn't publish checksums for wrappers that old.
    • The CLI copies it into every plugin build and runs it.
    • The new workflow's wrapper validation would reject it, so it allow-lists that one checksum for
      now.
    • I think it should be replaced with the official 8.14.3 wrapper (the one gradle-api/ uses) in
      a separate PR, since that touches every plugin build for every user.
  3. Should the plugin's own buildscript.gradle run in its .aar build whenever a facts file
    exists,
    instead of only with the opt-in? That would let plugins drop the old-CLI branch sooner,
    at the cost of the behaviour change above for everyone on 9.2.
  4. A plugin that ships a prebuilt <short>.aar next to its sources and then gets renamed by a
    clash
    makes prepare stop, because the CLI didn't build that file. Accept that (those projects
    already link duplicate classes), or leave files the CLI didn't build alone?
  5. Smaller items left as follow-ups:
    • If hostProjectPath is on another Windows drive, the facts file can't hold a relative path.
    • runtime.version in the facts is the version declared in package.json, which can be a range.
    • Whether Gradle's configuration cache notices a changed facts file isn't tested; the runtime
      templates don't support the configuration cache anyway.
  6. The runtime template's :app build reads nativescriptDependencies / PLATFORMS_ANDROID
    through the parent project. Gradle 9.8 deprecates that and Gradle 10 will make it an error; that
    needs a fix in NativeScript/android.

… for plugins

A Java Gradle library (plugin id `org.nativescript.api`, extension
`nativescript`) that plugins load from their buildscript.gradle and call from
include.gradle instead of building paths into node_modules or the CLI's output
folders.

It reads the facts file the CLI writes (platforms/android/nativescript-gradle.json,
schema in gradle-api/schema) and falls back to the rules CLIs up to 9.1 use when
there is none, or when it no longer matches dependencies.json. Compiled for
Java 11 against the Gradle 7.5 API; functional tests cover Gradle 7.5.1, 8.14.3
and 9.8.1 and run the CLI's plugin-build template with AGP.

Adds the gradle_api workflow: tests on changes, publishing to Maven Central on
gradle-api-v* tags from the protected maven-central environment.
…read-only node_modules

- `ns prepare android` writes platforms/android/nativescript-gradle.json next
  to dependencies.json (project dirs and every native plugin's package dir,
  native dir and aar, paths relative to the file, plus the hash of
  dependencies.json) and passes -Pnativescript.factsFile to app and plugin
  Gradle builds. org.nativescript:gradle-api reads it.
- Plugins whose short names clash (@foo/plugin-x and @bar/plugin-x) get
  scope-qualified aar names, temp build dirs and namespace fallbacks. Names
  that don't clash are unchanged. An aar left under a plugin's previous name is
  deleted when it is the CLI's own output, otherwise prepare stops.
- `android.readOnlyNodeModules` in nativescript.config: plugins' Android files
  and package.json are mirrored into platforms/android/.ns-plugins, aars are
  built and include.gradle is migrated there, dependencies.json points at the
  mirrors, and plugin aar builds also apply the plugin's own buildscript.gradle
  and the gradle-api library.
@coderabbitai

coderabbitai Bot commented Oct 22, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@NathanWalker

Copy link
Copy Markdown
Contributor

Given the config option is interim (potential to make default in future), the naming is probably ok if a description describes clearly why the option is available. readOnlyNodeModules: true sounds bit opaque otherwise.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants