From a34f381d81e800202d2817376c7a02cd78b7ba97 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 22 Oct 2026 14:15:32 -0300 Subject: [PATCH 1/2] feat(gradle-api): org.nativescript:gradle-api, a versioned Gradle API 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. --- .github/workflows/gradle_api.yml | 135 ++++ .gitignore | 6 + gradle-api/.gitattributes | 2 + gradle-api/README.md | 312 ++++++++ gradle-api/build.gradle.kts | 125 +++ gradle-api/gradle.properties | 27 + gradle-api/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 + gradle-api/gradlew | 251 ++++++ gradle-api/gradlew.bat | 94 +++ gradle-api/samples/sample-plugin/package.json | 12 + .../platforms/android/buildscript.gradle | 10 + .../platforms/android/include.gradle | 13 + .../schema/nativescript-gradle.schema.json | 104 +++ gradle-api/settings.gradle.kts | 14 + .../org/nativescript/gradle/AppBuildTest.java | 150 ++++ .../gradle/ExtractedIncludeGradleTest.java | 58 ++ .../org/nativescript/gradle/Fixtures.java | 161 ++++ .../nativescript/gradle/PluginBuildTest.java | 126 +++ .../packages/fixture-plugin-b/package.json | 1 + .../platforms/android/buildscript.gradle | 3 + .../platforms/android/include.gradle | 17 + .../app-fixture/packages/js-only/package.json | 1 + .../platforms/android/app/build.gradle | 67 ++ .../platforms/android/build.gradle | 62 ++ .../platforms/android/settings.gradle | 2 + .../extracted/sample-plugin-include.gradle | 8 + .../org/nativescript/gradle/NativeScript.java | 43 + .../gradle/NativeScriptExtension.java | 171 ++++ .../gradle/NativeScriptPlugin.java | 26 + .../org/nativescript/gradle/PluginInfo.java | 82 ++ .../gradle/internal/BuildInputs.java | 51 ++ .../DefaultNativeScriptExtension.java | 134 ++++ .../gradle/internal/DefaultPluginInfo.java | 59 ++ .../nativescript/gradle/internal/Facts.java | 315 ++++++++ .../nativescript/gradle/internal/Json.java | 366 +++++++++ .../gradle/internal/NodePackages.java | 41 + .../gradle/internal/PluginData.java | 69 ++ .../gradle/internal/Resolution.java | 332 ++++++++ .../org/nativescript/gradle/package-info.java | 28 + .../gradle/internal/FactsTest.java | 476 ++++++++++++ .../gradle/internal/JsonTest.java | 253 ++++++ .../gradle/internal/ResolutionTest.java | 733 ++++++++++++++++++ .../src/test/resources/facts/v1-basic.json | 41 + .../resources/facts/v2-unknown-fields.json | 24 + 45 files changed, 5013 insertions(+) create mode 100644 .github/workflows/gradle_api.yml create mode 100644 gradle-api/.gitattributes create mode 100644 gradle-api/README.md create mode 100644 gradle-api/build.gradle.kts create mode 100644 gradle-api/gradle.properties create mode 100644 gradle-api/gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle-api/gradle/wrapper/gradle-wrapper.properties create mode 100755 gradle-api/gradlew create mode 100644 gradle-api/gradlew.bat create mode 100644 gradle-api/samples/sample-plugin/package.json create mode 100644 gradle-api/samples/sample-plugin/platforms/android/buildscript.gradle create mode 100644 gradle-api/samples/sample-plugin/platforms/android/include.gradle create mode 100644 gradle-api/schema/nativescript-gradle.schema.json create mode 100644 gradle-api/settings.gradle.kts create mode 100644 gradle-api/src/functionalTest/java/org/nativescript/gradle/AppBuildTest.java create mode 100644 gradle-api/src/functionalTest/java/org/nativescript/gradle/ExtractedIncludeGradleTest.java create mode 100644 gradle-api/src/functionalTest/java/org/nativescript/gradle/Fixtures.java create mode 100644 gradle-api/src/functionalTest/java/org/nativescript/gradle/PluginBuildTest.java create mode 100644 gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/package.json create mode 100644 gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/buildscript.gradle create mode 100644 gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/include.gradle create mode 100644 gradle-api/src/functionalTest/resources/app-fixture/packages/js-only/package.json create mode 100644 gradle-api/src/functionalTest/resources/app-fixture/platforms/android/app/build.gradle create mode 100644 gradle-api/src/functionalTest/resources/app-fixture/platforms/android/build.gradle create mode 100644 gradle-api/src/functionalTest/resources/app-fixture/platforms/android/settings.gradle create mode 100644 gradle-api/src/functionalTest/resources/extracted/sample-plugin-include.gradle create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/NativeScript.java create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/NativeScriptExtension.java create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/NativeScriptPlugin.java create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/PluginInfo.java create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/internal/BuildInputs.java create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultNativeScriptExtension.java create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultPluginInfo.java create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/internal/Facts.java create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/internal/Json.java create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/internal/NodePackages.java create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/internal/PluginData.java create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/internal/Resolution.java create mode 100644 gradle-api/src/main/java/org/nativescript/gradle/package-info.java create mode 100644 gradle-api/src/test/java/org/nativescript/gradle/internal/FactsTest.java create mode 100644 gradle-api/src/test/java/org/nativescript/gradle/internal/JsonTest.java create mode 100644 gradle-api/src/test/java/org/nativescript/gradle/internal/ResolutionTest.java create mode 100644 gradle-api/src/test/resources/facts/v1-basic.json create mode 100644 gradle-api/src/test/resources/facts/v2-unknown-fields.json diff --git a/.github/workflows/gradle_api.yml b/.github/workflows/gradle_api.yml new file mode 100644 index 0000000000..cecc17bd95 --- /dev/null +++ b/.github/workflows/gradle_api.yml @@ -0,0 +1,135 @@ +name: 'org.nativescript:gradle-api' + +on: + workflow_dispatch: + pull_request: + paths: + - 'gradle-api/**' + - 'vendor/gradle-plugin/**' + - 'lib/services/android-plugin-build-service.ts' + - 'lib/services/android-project-service.ts' + - 'lib/services/android/**' + - '.github/workflows/gradle_api.yml' + push: + branches: [ 'main' ] + # Path filters do not apply to tag pushes. + tags: [ 'gradle-api-v*' ] + paths: + - 'gradle-api/**' + - 'vendor/gradle-plugin/**' + - 'lib/services/android-plugin-build-service.ts' + - 'lib/services/android-project-service.ts' + - 'lib/services/android/**' + - '.github/workflows/gradle_api.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + +jobs: + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + # vendor/gradle-plugin/gradle/wrapper/gradle-wrapper.jar, which the CLI copies into + # plugin builds, matches no published Gradle wrapper checksum. + allow-checksums: 414142e6bc3b156908436f13b29e198bb3d59824f9c245ef145c809ddd5fe0a3 + + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + cache-provider: basic + # Done by the wrapper-validation step, which can allow the vendored jar. + validate-wrappers: false + + # The Android SDK preinstalled on the runner (ANDROID_HOME) enables the plugin build tests. + - name: Check + working-directory: gradle-api + run: ./gradlew check --console=plain + + publish: + name: Publish to Maven Central + if: startsWith(github.ref, 'refs/tags/gradle-api-v') + needs: test + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: maven-central + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + allow-checksums: 414142e6bc3b156908436f13b29e198bb3d59824f9c245ef145c809ddd5fe0a3 + + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + cache-provider: basic + cache-read-only: true + validate-wrappers: false + + - name: Check the tag matches the version + working-directory: gradle-api + env: + TAG: ${{ github.ref_name }} + run: | + version="$(sed -n 's/^VERSION_NAME=//p' gradle.properties)" + if [ "$TAG" != "gradle-api-v$version" ]; then + echo "::error::Tag $TAG does not match VERSION_NAME=$version in gradle-api/gradle.properties" + exit 1 + fi + case "$version" in + *-SNAPSHOT) + echo "::error::$version is a snapshot version" + exit 1 + ;; + esac + + - name: Publish + working-directory: gradle-api + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_CENTRAL_SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_CENTRAL_SIGNING_KEY_PASSWORD }} + run: | + # The build skips signing without a key, which Maven Central would only reject later. + for name in ORG_GRADLE_PROJECT_mavenCentralUsername ORG_GRADLE_PROJECT_mavenCentralPassword ORG_GRADLE_PROJECT_signingInMemoryKey; do + if [ -z "${!name}" ]; then + echo "::error::$name is empty; check the maven-central environment's secrets" + exit 1 + fi + done + ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache --console=plain diff --git a/.gitignore b/.gitignore index 2c4b2d2127..1c1878346e 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,9 @@ CLAUDE.local.md .env.* !.env.example .envrc + +# org.nativescript:gradle-api +gradle-api/build/ +gradle-api/.gradle/ +gradle-api/.kotlin/ +gradle-api/samples/**/build/ diff --git a/gradle-api/.gitattributes b/gradle-api/.gitattributes new file mode 100644 index 0000000000..4e03091347 --- /dev/null +++ b/gradle-api/.gitattributes @@ -0,0 +1,2 @@ +gradlew.bat text eol=crlf +*.jar binary diff --git a/gradle-api/README.md b/gradle-api/README.md new file mode 100644 index 0000000000..fdd6951ce7 --- /dev/null +++ b/gradle-api/README.md @@ -0,0 +1,312 @@ +# org.nativescript:gradle-api + +A small, versioned Gradle library that NativeScript plugins call from their Android Gradle files +instead of guessing where things are on disk. + +```groovy +// platforms/android/include.gradle +apply plugin: "org.nativescript.api" + +def ns = nativescript +logger.info("App_Resources: ${ns.appResourcesDir}") +``` + +- **For plugin authors:** [Why](#why), [Adopting it](#adopting-it), [API](#api), + [Recipes](#recipes), [Compatibility](#compatibility), [Rules](#rules-for-plugin-authors). +- **For CLI/runtime maintainers:** [How it works](#how-it-works), [The facts file](#the-facts-file), + [Versioning rules](#versioning-rules), [Releasing](#releasing). + +## Why + +Plugin Gradle files today find things by building paths themselves: + +```groovy +// a real-world include.gradle +def widgetsDir = "$USER_PROJECT_ROOT/node_modules/@nativescript/core/platforms/android" +// another one +maven { url = project.file('../../../node_modules/detox/Detox-android') } +// and the most common one +if (!project.hasProperty("tempBuild")) { apply plugin: 'com.google.gms.google-services' } +``` + +Every one of these bakes in a detail the CLI should be free to change: where `node_modules` is, +where the CLI writes a plugin's built `.aar`, how many `../` lead to the project root, or what the +`tempBuild` property means. This library answers those questions for you. The CLI tells the library +where things are; on CLIs that don't, the library works it out the way those CLIs always did. + +## Adopting it + +Minimum Android runtime: **8.4** (Gradle 7.5, JDK 11), the oldest toolchain the library is built +for. If your plugin still supports older runtimes, keep your current Gradle code for them: adding the +library to `buildscript.gradle` breaks their builds. + +It works with every NativeScript CLI. CLI 9.2+ gives it exact answers; older CLIs use the fallback. + +**1. Put the library on the build classpath** in `platforms/android/buildscript.gradle`: + +```groovy +repositories { + mavenCentral() +} +dependencies { + classpath "org.nativescript:gradle-api:" +} +``` + +(No version is published yet; this README is part of the proposal.) + +Pin the **lowest** version that has what you need, never a range or `+`. Every plugin in an app adds +the library to the same classpath and Gradle keeps the highest version, which is always backwards +compatible. + +**2. Apply it at the top of `platforms/android/include.gradle`:** + +```groovy +apply plugin: "org.nativescript.api" +``` + +Applying it again from several plugins is fine. + +**3. Use `nativescript`** in the rest of the file (see [API](#api) and [Recipes](#recipes)). + +### The one thing to know: your plugin's own `.aar` build + +The CLI builds a plugin's own Android sources into an `.aar` in a separate, temporary Gradle build. +In that build, **only the `dependencies {}` and `repositories {}` blocks of your `include.gradle` +run**: the CLI copies them out of the file. Nothing else in the file runs there, including the +`apply plugin:` line. + +On CLI 9.2+ with `android.readOnlyNodeModules` enabled, the CLI loads your `buildscript.gradle` in +that build and applies the library for you. On older CLIs it doesn't, so the library isn't +available there. Inside those two blocks, look the extension up instead of assuming it exists, and +keep the old code as the fallback: + +```groovy +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") + } +} +``` + +Elsewhere in `include.gradle` (outside those two blocks) you can use `nativescript` directly. + +Never refer to the library's classes from `include.gradle` (`import org.nativescript.gradle...`, +`NativeScript.of(project)`): Gradle compiles applied scripts with a class loader that can't see +them, even when the plugin itself is applied. Always go through the `nativescript` extension. +`NativeScript.of(project)` is for Gradle code written in Java or Kotlin. + +The CLI finds those blocks by searching for the first occurrence of the words `dependencies` and +`repositories` in the file, so keep those words out of comments and code above the real blocks, and +never put a `buildscript {}` block in `include.gradle`. + +## API + +`apiLevel` is `1` for everything below. New methods raise it; nothing is ever removed or changed. + +| Member | Returns | +|---|---| +| `nativescript.apiLevel` | The API level of the library on the classpath. | +| `nativescript.has(String)` | Whether the CLI guarantees a capability. Always `false` on CLIs before 9.2. | +| `nativescript.pluginBuild` | `true` in the CLI's temporary build of some plugin's `.aar`. | +| `nativescript.self` | In a plugin build, the plugin being built (see below). `null` in the app build. | +| `nativescript.isBuildingPlugin(String)` | Whether the plugin build is building that npm package. | +| `nativescript.projectDir` | The NativeScript project root (the folder with the app's `package.json`). | +| `nativescript.appDir` | The app source folder (`appPath` in `nativescript.config`). | +| `nativescript.appResourcesDir` | `App_Resources`. | +| `nativescript.plugin(String)` | A native plugin by npm name. Throws if the app doesn't have it. | +| `nativescript.findPlugin(String)` | Same, or `null`. | +| `nativescript.resolvePackage(String)` | Any installed npm package's folder. Throws if not found. | +| `nativescript.findPackage(String)` | Same, or `null`. | + +A plugin (`PluginInfo`): + +| Member | Returns | +|---|---| +| `name` | The npm package name. | +| `packageDir` | The installed package. Treat it as read-only. | +| `nativeDir` | Where Gradle reads the plugin's Android files from. | +| `artifacts` | Every `.aar` and `.jar` under `nativeDir`, as a lazy `FileCollection`. | +| `aarName`, `aar` | The name and location of the `.aar` the CLI builds from the plugin's sources (the file only exists if it has sources). `null` on CLIs before 9.2, and for installed packages the CLI doesn't list as plugins. | +| `options` | Per-plugin options. Reserved; empty for now. | + +`plugin` / `findPlugin` know the plugins the CLI lists (packages declaring Android support in their +`package.json`) plus any installed package with a `platforms/android` folder, with every CLI version. +For any other npm package, use `resolvePackage` / `findPackage`. + +**`pluginBuild` and `self` in practice.** Your full `include.gradle` runs in the app build and in +the `.aar` builds of plugins that *depend on you*. So a full `include.gradle` that sees +`pluginBuild == true` is always running inside another plugin's build, and `self` is that other +plugin. This is what the common `tempBuild` check was really testing: + +```groovy +// apply the Google Services plugin only to the app, not to dependents' aar builds +if (!nativescript.pluginBuild) { + apply plugin: "com.google.gms.google-services" +} +``` + +`resolvePackage` looks for `node_modules/` from the project root upwards, like Node. It's +best effort: package managers that don't hoist (pnpm's default layout) may not resolve packages +your app doesn't depend on directly. + +## Recipes + +**Compile against another plugin's Android library (e.g. core's widgets) in your own `.aar` build:** +the guarded block in [The one thing to know](#the-one-thing-to-know-your-plugins-own-aar-build). +Use `compileOnly`, not `implementation`: an Android library can't bundle local `.aar` files, and the +app build already links every plugin. + +**Read a file from App_Resources:** + +```groovy +def config = new File(nativescript.appResourcesDir, "Android/my-plugin.json") +``` + +**Point at another npm package's Maven repository** (detox, react-native style): + +```groovy +repositories { + def ns = project.extensions.findByName("nativescript") + def detox = ns != null ? ns.findPackage("detox") : file("$USER_PROJECT_ROOT/node_modules/detox") + maven { url = new File(detox, "Detox-android") } +} +``` + +**Only in the app build:** `if (!nativescript.pluginBuild) { ... }`. + +## Compatibility + +| | CLI 9.1 and older | CLI 9.2+ | CLI 9.2+ with `readOnlyNodeModules` | +|---|---|---|---| +| Plugin not using the library | Unchanged | Unchanged | Gradle reads its files from a copy in `platforms/android/.ns-plugins/`, and its own `buildscript.gradle` also runs in its own `.aar` build | +| Plugin using the library, app build | Fallback rules | Facts from the CLI | Facts from the CLI | +| Plugin using the library, its own `.aar` build | Library not loaded: the guarded fallback runs | Library not loaded: the guarded fallback runs | Library loaded | + +The fallback rules are exactly what CLIs up to 9.1 do: plugins live in +`node_modules//platforms/android`, the project root is `USER_PROJECT_ROOT`, the app and +App_Resources come from `-PappPath` / `-PappResourcesPath`, and `tempBuild` marks a plugin build. + +Building `platforms/android` from Android Studio works too: CLI 9.2+ leaves the facts file there +after `ns prepare android`, so the library has the same answers without the CLI's command-line +flags. + +### For app developers: keeping `node_modules` read-only (opt-in) + +```ts +// nativescript.config.ts +export default { + android: { readOnlyNodeModules: true }, +} satisfies NativeScriptConfig; +``` + +With this, the CLI never writes into `node_modules`. It copies each native plugin's +`platforms/android` and `package.json` into `platforms/android/.ns-plugins//`, builds plugin +`.aar`s there, and points the Android build at the copies. No runtime change is needed (checked +against the runtime's Gradle templates from 6.5.3 to current), and Android Studio builds use the +copies too. Earlier builds may have left `.aar` files inside `node_modules`; +reinstall `node_modules` once after turning this on to get rid of them. + +## Rules for plugin authors + +- Pin the lowest library version you need; no ranges. +- Everything in the version you pin is always there: an app may end up with a newer library, never + an older one. To use something newer only when it's available, check `apiLevel` (or `has(...)`) + first. +- Never build paths into `node_modules` or into the CLI's output folders; ask the library. +- In `dependencies {}` / `repositories {}`, use the guarded lookup shown above. + +## How it works + +``` +nativescript.config.ts ─┐ +package.json files ─────┼─> ns prepare android ─> platforms/android/nativescript-gradle.json +App_Resources ──────────┘ (and dependencies.json, as before) + │ + -Pnativescript.factsFile │ or found next to the app build + ▼ + plugin buildscript.gradle ─> org.nativescript:gradle-api ─> `nativescript` extension + │ + no facts file (CLI ≤ 9.1)│ + ▼ + fallback: dependencies.json, USER_PROJECT_ROOT, tempBuild +``` + +The CLI decides everything (where plugins are, what they're called, project layout) and writes the +answers down. Gradle only reads them. The library hides whether the answer came from the facts file +or from the fallback. + +## The facts file + +`platforms/android/nativescript-gradle.json`, written by `ns prepare android` before any plugin is +built. The schema is [`schema/nativescript-gradle.schema.json`](schema/nativescript-gradle.schema.json). + +```json +{ + "schemaVersion": 1, + "cli": { "version": "9.2.0" }, + "runtime": { "name": "@nativescript/android", "version": "9.0.0" }, + "capabilities": [], + "project": { "root": "../..", "appDir": "../../src", "appResourcesDir": "../../App_Resources" }, + "plugins": [ + { + "name": "@nativescript/core", + "packageDir": "../../node_modules/@nativescript/core", + "nativeDir": "../../node_modules/@nativescript/core/platforms/android", + "aarName": "core", + "aar": "../../node_modules/@nativescript/core/platforms/android/core.aar", + "dependencies": [], + "options": {} + } + ] +} +``` + +- Paths are relative to the file, with forward slashes, so the folder can move. +- `dependenciesJson.sha256` is the hash of the `dependencies.json` the CLI wrote next to it. A CLI + that doesn't know about this file (9.1 and older, e.g. after a downgrade) still rewrites + `dependencies.json` but leaves this file behind; the library notices the mismatch, warns, and uses + the fallback rules instead of outdated answers. +- `capabilities` lists behaviours the CLI guarantees, so the library can tell "not set" apart from + "this CLI doesn't know about it". Empty in version 1. +- `options` is reserved for per-plugin settings from `nativescript.config`. + +## Versioning rules + +These are what keep old plugins working on new CLIs and new plugins working on old ones. + +**Facts file:** fields are only added. A field never changes meaning and is never removed. +`schemaVersion` goes up when fields are added; readers accept any version and read what they know. +Paths are relative. Anything a newer CLI does differently is expressed as data in the file, not as a +rule readers must know. + +**Library:** the public API only grows. Old methods keep working forever; behaviour that depends on +the CLI checks the facts file or a capability before relying on it. The library compiles for Java +11 and the Gradle 7.5 API, the oldest toolchain supported NativeScript runtimes use. + +## Releasing + +Builds and tests run in `.github/workflows/gradle_api.yml`. Pushing a tag `gradle-api-v` +that matches `VERSION_NAME` in `gradle.properties` publishes to Maven Central, from the protected +`maven-central` GitHub environment. It needs these environment secrets: + +- `MAVEN_CENTRAL_USERNAME`, `MAVEN_CENTRAL_PASSWORD`: a Central Portal user token with publishing + rights on the `org.nativescript` namespace. +- `MAVEN_CENTRAL_SIGNING_KEY`, `MAVEN_CENTRAL_SIGNING_KEY_PASSWORD`: the release signing key + (ASCII-armored) and its passphrase. The public key must be on keys.openpgp.org and + keyserver.ubuntu.com. + +Publishing uses `com.vanniktech.maven.publish` 0.35.0, the newest release that runs on the Gradle +8.14 wrapper; moving to Gradle 9 allows newer ones. + +Local development needs JDK 17 installed (the functional tests run Gradle 7.5.1, which doesn't +start on newer JDKs): + +```bash +cd gradle-api +./gradlew check # unit + functional tests (AGP tests need ANDROID_HOME) +./gradlew publishToMavenLocal # try it from a local app +``` diff --git a/gradle-api/build.gradle.kts b/gradle-api/build.gradle.kts new file mode 100644 index 0000000000..be4e172382 --- /dev/null +++ b/gradle-api/build.gradle.kts @@ -0,0 +1,125 @@ +plugins { + `java-gradle-plugin` + id("com.vanniktech.maven.publish") version "0.35.0" +} + +// Plugins on NativeScript Android runtimes back to 8.4 build with Gradle 7.5 on JDK 11. +val oldestSupportedGradle = "7.5.1" +val oldestSupportedJava = 11 + +tasks.withType().configureEach { + options.release = oldestSupportedJava + options.encoding = "UTF-8" + options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) +} + +tasks.javadoc { + (options as StandardJavadocDocletOptions).apply { + encoding = "UTF-8" + addStringOption("-release", oldestSupportedJava.toString()) + addBooleanOption("Xdoclint:all,-missing", true) + } + exclude("org/nativescript/gradle/internal/**") +} + +// java-gradle-plugin adds the running Gradle's API to `api`; compile against the oldest supported +// one instead so that using anything newer fails here rather than in a user's build. +configurations.named("api") { + withDependencies { removeIf { it is FileCollectionDependency } } +} + +val functionalTest: SourceSet by sourceSets.creating + +dependencies { + compileOnly("dev.gradleplugins:gradle-api:$oldestSupportedGradle") + + testImplementation(gradleApi()) + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + "functionalTestImplementation"(gradleTestKit()) + "functionalTestImplementation"(platform("org.junit:junit-bom:5.13.4")) + "functionalTestImplementation"("org.junit.jupiter:junit-jupiter") + "functionalTestRuntimeOnly"("org.junit.platform:junit-platform-launcher") +} + +gradlePlugin { + plugins { + create("nativescript") { + id = "org.nativescript.api" + implementationClass = "org.nativescript.gradle.NativeScriptPlugin" + displayName = "NativeScript Gradle API" + description = providers.gradleProperty("POM_DESCRIPTION").get() + } + } +} + +// Functional tests consume the library the way plugins do, from a Maven repository, so they see +// the published metadata and the real buildscript class loader scoping. +val testRepo = layout.buildDirectory.dir("test-repo") + +publishing { + repositories { + maven { + name = "testRepo" + url = uri(testRepo) + } + } +} + +val cleanTestRepo by tasks.registering(Delete::class) { + delete(testRepo) +} + +tasks.withType().configureEach { + if (name.endsWith("ToTestRepoRepository")) { + dependsOn(cleanTestRepo) + } +} + +// A release version is signed with keys only the publish job has; the test repository needs none. +tasks.withType().configureEach { + val signingKey = providers.gradleProperty("signingInMemoryKey") + onlyIf("a signing key is configured") { signingKey.isPresent } +} + +tasks.withType().configureEach { + useJUnitPlatform() + testLogging { + events("failed", "skipped") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} + +val functionalTestTask = tasks.register("functionalTest") { + description = "Runs the TestKit tests against the library published to the test repository." + group = LifecycleBasePlugin.VERIFICATION_GROUP + testClassesDirs = functionalTest.output.classesDirs + classpath = functionalTest.runtimeClasspath + shouldRunAfter(tasks.test) + + dependsOn("publishAllPublicationsToTestRepoRepository") + // The repository itself differs on every publish: snapshot file names carry a timestamp. + inputs.files(tasks.jar).withNormalizer(ClasspathNormalizer::class) + inputs.dir("samples").withPathSensitivity(PathSensitivity.RELATIVE) + inputs.dir("../vendor/gradle-plugin").withPathSensitivity(PathSensitivity.RELATIVE) + + // Gradle 7.5 cannot run on JDK 21+, and TestKit starts its builds on the test JVM. + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(17) } + + val androidHome = providers.environmentVariable("ANDROID_HOME") + inputs.property("androidHomeSet", androidHome.map { true }.orElse(false)) + + systemProperty("nativescript.testRepo", testRepo.get().asFile.absolutePath) + systemProperty("nativescript.libraryVersion", providers.gradleProperty("VERSION_NAME").get()) + systemProperty("nativescript.samplesDir", file("samples").absolutePath) + systemProperty("nativescript.vendorGradlePluginDir", file("../vendor/gradle-plugin").absolutePath) + val gradleVersions = providers.gradleProperty("functionalTestGradleVersions").orElse("7.5.1,8.14.3,9.8.1") + inputs.property("gradleVersions", gradleVersions) + systemProperty("nativescript.gradleVersions", gradleVersions.get()) +} + +tasks.check { + dependsOn(functionalTestTask) +} diff --git a/gradle-api/gradle.properties b/gradle-api/gradle.properties new file mode 100644 index 0000000000..03d7f45232 --- /dev/null +++ b/gradle-api/gradle.properties @@ -0,0 +1,27 @@ +org.gradle.caching=true +org.gradle.configuration-cache=true + +GROUP=org.nativescript +POM_ARTIFACT_ID=gradle-api +VERSION_NAME=0.1.0-SNAPSHOT + +POM_NAME=NativeScript Gradle API +POM_DESCRIPTION=Gradle API for NativeScript plugins: project paths, installed plugins and CLI capabilities, from the facts the NativeScript CLI writes or from older CLIs' build files. +POM_INCEPTION_YEAR=2026 +POM_URL=https://github.com/NativeScript/nativescript-cli + +POM_LICENSE_NAME=The Apache Software License, Version 2.0 +POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENSE_DIST=repo + +POM_SCM_URL=https://github.com/NativeScript/nativescript-cli +POM_SCM_CONNECTION=scm:git:git://github.com/NativeScript/nativescript-cli.git +POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/NativeScript/nativescript-cli.git + +POM_DEVELOPER_ID=nativescript +POM_DEVELOPER_NAME=NativeScript team +POM_DEVELOPER_URL=https://github.com/NativeScript + +mavenCentralPublishing=true +mavenCentralAutomaticPublishing=true +signAllPublications=true diff --git a/gradle-api/gradle/wrapper/gradle-wrapper.jar b/gradle-api/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradle-api/gradlew.bat b/gradle-api/gradlew.bat new file mode 100644 index 0000000000..db3a6ac207 --- /dev/null +++ b/gradle-api/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/gradle-api/samples/sample-plugin/package.json b/gradle-api/samples/sample-plugin/package.json new file mode 100644 index 0000000000..6724fd6bc2 --- /dev/null +++ b/gradle-api/samples/sample-plugin/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nativescript/gradle-api-sample", + "version": "1.0.0", + "private": true, + "description": "Example NativeScript plugin using org.nativescript:gradle-api from its Android Gradle files.", + "license": "Apache-2.0", + "nativescript": { + "platforms": { + "android": "8.4.0" + } + } +} diff --git a/gradle-api/samples/sample-plugin/platforms/android/buildscript.gradle b/gradle-api/samples/sample-plugin/platforms/android/buildscript.gradle new file mode 100644 index 0000000000..c242271f51 --- /dev/null +++ b/gradle-api/samples/sample-plugin/platforms/android/buildscript.gradle @@ -0,0 +1,10 @@ +// Applied to the app's buildscript (and, with newer CLIs, to this plugin's own aar build), so the +// library is on the classpath before include.gradle runs. When several plugins declare it, Gradle +// uses the highest version. +repositories { + mavenCentral() +} + +dependencies { + classpath "org.nativescript:gradle-api:0.1.0" +} diff --git a/gradle-api/samples/sample-plugin/platforms/android/include.gradle b/gradle-api/samples/sample-plugin/platforms/android/include.gradle new file mode 100644 index 0000000000..7bc4bd54a8 --- /dev/null +++ b/gradle-api/samples/sample-plugin/platforms/android/include.gradle @@ -0,0 +1,13 @@ +apply plugin: "org.nativescript.api" + +// The CLI builds this plugin's aar in a separate project that runs only the first block below, not +// the rest of this file, and newer CLIs apply the library there only on request. Look the extension +// up by name and keep the old CLI branch so the block works in every such build. +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") + } +} diff --git a/gradle-api/schema/nativescript-gradle.schema.json b/gradle-api/schema/nativescript-gradle.schema.json new file mode 100644 index 0000000000..3884356a29 --- /dev/null +++ b/gradle-api/schema/nativescript-gradle.schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nativescript.org/schemas/nativescript-gradle.schema.json", + "title": "NativeScript Gradle facts", + "description": "Written by the NativeScript CLI at prepare time to platforms/android/nativescript-gradle.json and read by org.nativescript:gradle-api. Fields are only ever added: readers must ignore unknown fields, and a field never changes meaning. Every path is relative to the directory containing this file and uses forward slashes.", + "type": "object", + "required": ["schemaVersion", "dependenciesJson", "project", "plugins"], + "properties": { + "schemaVersion": { + "description": "Bumped only when a field is added. Readers accept any version and read the fields they know.", + "type": "integer", + "minimum": 1 + }, + "cli": { + "type": "object", + "properties": { + "version": { "type": "string" } + } + }, + "runtime": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" } + } + }, + "dependenciesJson": { + "description": "The dependencies.json the CLI wrote next to this file, before this file. A CLI that does not write this file still rewrites dependencies.json but leaves this file behind, so readers ignore this file when dependencies.json no longer has this hash.", + "type": "object", + "required": ["sha256"], + "properties": { + "sha256": { + "description": "Lowercase hex SHA-256 of the exact bytes of dependencies.json.", + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "capabilities": { + "description": "Behaviours the CLI that wrote this file guarantees. Lets readers tell 'not set' apart from 'this CLI does not know about it'.", + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "project": { + "type": "object", + "required": ["root", "appDir", "appResourcesDir"], + "properties": { + "root": { + "description": "The NativeScript project root (the folder with the app's package.json).", + "$ref": "#/$defs/path" + }, + "appDir": { "$ref": "#/$defs/path" }, + "appResourcesDir": { "$ref": "#/$defs/path" } + } + }, + "plugins": { + "description": "Every npm package with Android native code, in dependency order (a plugin comes after the plugins it depends on).", + "type": "array", + "items": { "$ref": "#/$defs/plugin" } + } + }, + "$defs": { + "path": { + "type": "string", + "pattern": "^[^\\\\]*$" + }, + "plugin": { + "type": "object", + "required": ["name", "packageDir", "nativeDir", "aarName", "aar", "dependencies"], + "properties": { + "name": { + "description": "The npm package name.", + "type": "string" + }, + "packageDir": { + "description": "The installed npm package. Read-only.", + "$ref": "#/$defs/path" + }, + "nativeDir": { + "description": "The directory Gradle reads the plugin's Android files from: the package's platforms/android, or the CLI's copy of it when node_modules is kept read-only.", + "$ref": "#/$defs/path" + }, + "aarName": { + "description": "File name, without extension, of the aar the CLI builds from the plugin's sources. Unique across the project.", + "type": "string" + }, + "aar": { + "description": "Where the CLI puts that aar. The file only exists if the plugin has sources to build.", + "$ref": "#/$defs/path" + }, + "dependencies": { + "description": "Names of the other entries in plugins that this plugin depends on.", + "type": "array", + "items": { "type": "string" } + }, + "options": { + "description": "Per-plugin options for the plugin's own Gradle code. Reserved; always empty for now.", + "type": "object" + } + } + } + } +} diff --git a/gradle-api/settings.gradle.kts b/gradle-api/settings.gradle.kts new file mode 100644 index 0000000000..54c0e01236 --- /dev/null +++ b/gradle-api/settings.gradle.kts @@ -0,0 +1,14 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +rootProject.name = "gradle-api" diff --git a/gradle-api/src/functionalTest/java/org/nativescript/gradle/AppBuildTest.java b/gradle-api/src/functionalTest/java/org/nativescript/gradle/AppBuildTest.java new file mode 100644 index 0000000000..62c1c200cc --- /dev/null +++ b/gradle-api/src/functionalTest/java/org/nativescript/gradle/AppBuildTest.java @@ -0,0 +1,150 @@ +package org.nativescript.gradle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * The app build: plugins' buildscript.gradle and include.gradle applied to {@code :app} the way the + * Android runtime's template does, without the Android Gradle Plugin. + */ +class AppBuildTest { + enum Cli { + /** CLI 9.1 and older: no facts file. */ + LEGACY, + /** A facts-writing CLI, which passes the file's path. */ + FACTS_PROPERTY, + /** platforms/android opened without the CLI, e.g. in Android Studio. */ + FACTS_FOUND, + /** An older CLI prepared the project after a facts-writing one: facts left behind. */ + STALE_FACTS; + + boolean usesFacts() { + return this == FACTS_PROPERTY || this == FACTS_FOUND; + } + } + + @TempDir + Path tempDir; + + static Stream cases() { + List cases = new ArrayList<>(); + for (String gradleVersion : Fixtures.gradleVersions()) { + for (Cli cli : Cli.values()) { + cases.add(Arguments.of(gradleVersion, cli)); + } + } + return cases.stream(); + } + + @ParameterizedTest(name = "Gradle {0}, {1}") + @MethodSource("cases") + void answersFromTheAppBuild(String gradleVersion, Cli cli) throws Exception { + Path project = tempDir.toRealPath().resolve("project"); + Path platformsAndroid = project.resolve("platforms/android"); + Fixtures.copyTree(fixture().resolve("platforms/android"), platformsAndroid); + Fixtures.copyTree(fixture().resolve("packages"), project.resolve("node_modules")); + Path pluginB = project.resolve("node_modules/fixture-plugin-b/platforms/android/buildscript.gradle"); + Fixtures.write(pluginB, Fixtures.read(pluginB).replace("@LIBRARY_VERSION@", Fixtures.libraryVersion())); + Path core = Fixtures.installCore(project); + Path sample = Fixtures.installSample(project); + Fixtures.write(project.resolve("package.json"), "{ \"name\": \"fixture-app\" }\n"); + Path dependenciesJson = Fixtures.write(platformsAndroid.resolve("dependencies.json"), String.join("\n", + "[", + " { \"name\": \"@nativescript/core\", \"directory\": \"../../node_modules/@nativescript/core\", \"dependencies\": [] },", + " { \"name\": \"" + Fixtures.SAMPLE_NAME + "\", \"directory\": \"../../node_modules/" + Fixtures.SAMPLE_NAME + "\", \"dependencies\": [\"@nativescript/core\"] },", + " { \"name\": \"fixture-plugin-b\", \"directory\": \"../../node_modules/fixture-plugin-b\", \"dependencies\": [] }", + "]", + "")); + + List arguments = new ArrayList<>(List.of( + "help", "--offline", "--stacktrace", "--warning-mode=fail", + "--init-script", Fixtures.path(Fixtures.writeInitScript(tempDir)))); + Path factsFile = platformsAndroid.resolve("nativescript-gradle.json"); + if (cli != Cli.LEGACY) { + Fixtures.write(factsFile, String.join("\n", + "{", + " \"schemaVersion\": 1,", + " \"dependenciesJson\": { \"sha256\": \"" + Fixtures.sha256(dependenciesJson) + "\" },", + " \"capabilities\": [\"fixture.capability\"],", + " \"project\": { \"root\": \"../..\", \"appDir\": \"../../src\", \"appResourcesDir\": \"../../src/App_Resources\" },", + " \"plugins\": [", + " { \"name\": \"@nativescript/core\", \"packageDir\": \"../../node_modules/@nativescript/core\",", + " \"nativeDir\": \"../../node_modules/@nativescript/core/platforms/android\", \"aarName\": \"core\",", + " \"aar\": \"../../node_modules/@nativescript/core/platforms/android/core.aar\", \"dependencies\": [],", + " \"options\": { \"flag\": true } },", + " { \"name\": \"" + Fixtures.SAMPLE_NAME + "\", \"packageDir\": \"../../node_modules/" + Fixtures.SAMPLE_NAME + "\",", + " \"nativeDir\": \".ns-plugins/sample/platforms/android\", \"aarName\": \"sample\",", + " \"aar\": \".ns-plugins/sample/platforms/android/sample.aar\", \"dependencies\": [\"@nativescript/core\"] }", + " ]", + "}", + "")); + if (cli == Cli.FACTS_PROPERTY) { + arguments.add("-Pnativescript.factsFile=" + Fixtures.path(factsFile)); + } + if (cli == Cli.STALE_FACTS) { + Fixtures.write(dependenciesJson, Fixtures.read(dependenciesJson) + "\n"); + } + } + + BuildResult result = GradleRunner.create() + .withGradleVersion(gradleVersion) + .withProjectDir(platformsAndroid.toFile()) + .withArguments(arguments) + .forwardOutput() + .build(); + Map probes = Fixtures.probes(result); + + boolean facts = cli.usesFacts(); + String staleWarning = "Ignoring " + Fixtures.path(factsFile) + ": it does not match " + Fixtures.path(dependenciesJson) + + ", which a different NativeScript CLI wrote. Using the fallback rules."; + assertEquals(cli == Cli.STALE_FACTS, result.getOutput().contains(staleWarning), result.getOutput()); + Path appDir = project.resolve(facts ? "src" : "app"); + assertEquals("true", probes.get("typed"), "NativeScript.of returns the extension"); + assertEquals("true", probes.get("b.sameExtension"), "a second plugin's include.gradle sees the same extension"); + assertEquals("false", probes.get("b.libraryClassVisible"), "include.gradle cannot see the library's classes"); + assertEquals(Fixtures.path(project), probes.get("b.projectDir")); + assertEquals("1", probes.get("apiLevel")); + assertEquals("false", probes.get("pluginBuild")); + assertEquals("null", probes.get("self")); + assertEquals("false", probes.get("buildingSample")); + assertEquals(Fixtures.path(project), probes.get("projectDir")); + assertEquals(Fixtures.path(appDir), probes.get("appDir")); + assertEquals(Fixtures.path(appDir.resolve("App_Resources")), probes.get("appResourcesDir")); + assertEquals(String.valueOf(facts), probes.get("has")); + assertEquals(Fixtures.path(core), probes.get("core.packageDir")); + assertEquals(Fixtures.path(core.resolve("platforms/android")), probes.get("core.nativeDir")); + assertEquals("inner.aar,widgets-release.aar", probes.get("core.artifacts")); + assertEquals(facts ? "core" : "null", probes.get("core.aarName")); + assertEquals(facts ? Fixtures.path(core.resolve("platforms/android/core.aar")) : "null", probes.get("core.aar")); + assertEquals(facts ? "[flag:true]" : "[:]", probes.get("core.options")); + assertEquals( + facts ? Fixtures.path(platformsAndroid.resolve(".ns-plugins/sample/platforms/android")) + : Fixtures.path(sample.resolve("platforms/android")), + probes.get("sample.nativeDir")); + assertEquals(Fixtures.path(project.resolve("node_modules/js-only")), probes.get("jsOnly")); + assertEquals("null", probes.get("jsOnlyPlugin"), "a package without native code is not a plugin"); + assertEquals("null", probes.get("missingPlugin")); + assertEquals("null", probes.get("missingPackage")); + String error = probes.get("pluginError"); + assertTrue(error.contains("'not-installed'") && error.contains("@nativescript/core, " + Fixtures.SAMPLE_NAME), + "error names the plugin and lists the known ones: " + error); + } + + private static Path fixture() throws URISyntaxException { + return new File(AppBuildTest.class.getResource("/app-fixture").toURI()).toPath(); + } +} diff --git a/gradle-api/src/functionalTest/java/org/nativescript/gradle/ExtractedIncludeGradleTest.java b/gradle-api/src/functionalTest/java/org/nativescript/gradle/ExtractedIncludeGradleTest.java new file mode 100644 index 0000000000..62a882dfeb --- /dev/null +++ b/gradle-api/src/functionalTest/java/org/nativescript/gradle/ExtractedIncludeGradleTest.java @@ -0,0 +1,58 @@ +package org.nativescript.gradle; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * The golden file the plugin build test appends must be what the CLI extracts from the sample's + * include.gradle; the CLI's own tests check it produces the same file. + */ +class ExtractedIncludeGradleTest { + @Test + void goldenFileMatchesTheSample() throws Exception { + String includeGradle = Fixtures.read(Fixtures.samplesDir().resolve("sample-plugin/platforms/android/include.gradle")); + String golden = Fixtures.read(PluginBuildTest.extractedIncludeGradle()); + + assertEquals(golden, String.join("\n", extract(includeGradle))); + } + + // A port of getIncludeGradleCompileDependenciesScope and getScope in + // lib/services/android-plugin-build-service.ts. + private static List extract(String content) { + List result = new ArrayList<>(); + if (content.indexOf("dependencies") == -1) { + return result; + } + if (content.indexOf("repositories") >= 0) { + result.add(scope("repositories", content)); + } + result.add(scope("dependencies", content)); + return result; + } + + private static String scope(String name, String content) { + StringBuilder result = new StringBuilder(); + boolean foundFirstBracket = false; + int openBrackets = 0; + for (int i = content.indexOf(name); i != -1 && i < content.length(); i++) { + char c = content.charAt(i); + if (c == '{') { + if (openBrackets == 0) { + foundFirstBracket = true; + } + openBrackets++; + } + if (c == '}') { + openBrackets--; + } + result.append(c); + if (openBrackets == 0 && foundFirstBracket) { + break; + } + } + return result.toString(); + } +} diff --git a/gradle-api/src/functionalTest/java/org/nativescript/gradle/Fixtures.java b/gradle-api/src/functionalTest/java/org/nativescript/gradle/Fixtures.java new file mode 100644 index 0000000000..215024bb5f --- /dev/null +++ b/gradle-api/src/functionalTest/java/org/nativescript/gradle/Fixtures.java @@ -0,0 +1,161 @@ +package org.nativescript.gradle; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.gradle.testkit.runner.BuildResult; + +final class Fixtures { + static final String SAMPLE_NAME = "@nativescript/gradle-api-sample"; + + private Fixtures() {} + + static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isEmpty()) { + throw new IllegalStateException("System property " + name + " is not set; run through the functionalTest task."); + } + return value; + } + + static Path testRepo() { + return Path.of(requiredProperty("nativescript.testRepo")); + } + + static String libraryVersion() { + return requiredProperty("nativescript.libraryVersion"); + } + + static Path samplesDir() { + return Path.of(requiredProperty("nativescript.samplesDir")); + } + + static Path vendorGradlePluginDir() { + return Path.of(requiredProperty("nativescript.vendorGradlePluginDir")); + } + + static List gradleVersions() { + return Arrays.asList(requiredProperty("nativescript.gradleVersions").split(",")); + } + + /** Makes every buildscript resolve the library from the test repository first. */ + static Path writeInitScript(Path dir) { + String repo = testRepo().toUri().toString(); + return write(dir.resolve("test-repo.init.gradle"), String.join("\n", + "allprojects {", + " buildscript {", + " repositories {", + " maven { url = '" + repo + "' }", + " }", + " }", + "}", + "")); + } + + /** Copies the sample plugin into node_modules, pointing its buildscript at the version under test. */ + static Path installSample(Path projectDir) { + Path target = projectDir.resolve("node_modules").resolve(SAMPLE_NAME); + copyTree(samplesDir().resolve("sample-plugin"), target); + Path buildscript = target.resolve("platforms/android/buildscript.gradle"); + String text = read(buildscript); + String replaced = text.replaceAll("org\\.nativescript:gradle-api:[^\"']+", "org.nativescript:gradle-api:" + libraryVersion()); + if (replaced.equals(text) && !text.contains(libraryVersion())) { + throw new IllegalStateException("No org.nativescript:gradle-api classpath dependency in " + buildscript); + } + write(buildscript, replaced); + return target; + } + + /** A stand-in for @nativescript/core: a package whose native directory holds archives. */ + static Path installCore(Path projectDir) { + Path core = projectDir.resolve("node_modules/@nativescript/core"); + write(core.resolve("package.json"), "{ \"name\": \"@nativescript/core\", \"version\": \"9.0.0\" }\n"); + write(core.resolve("platforms/android/widgets-release.aar"), "not really an aar"); + write(core.resolve("platforms/android/nested/inner.aar"), "not really an aar"); + return core; + } + + static Path write(Path file, String content) { + try { + Files.createDirectories(file.getParent()); + Files.write(file, content.getBytes(StandardCharsets.UTF_8)); + return file; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + static String read(Path file) { + try { + return new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + static void copyTree(Path source, Path target) { + try (Stream paths = Files.walk(source)) { + for (Path path : paths.collect(Collectors.toList())) { + Path destination = target.resolve(source.relativize(path).toString()); + if (Files.isDirectory(path)) { + Files.createDirectories(destination); + } else { + Files.createDirectories(destination.getParent()); + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** Lines printed as {@code NS|key|value}, by key. A key printed twice must print the same value. */ + static Map probes(BuildResult result) { + Map values = new LinkedHashMap<>(); + for (String line : result.getOutput().split("\\R")) { + if (!line.startsWith("NS|")) { + continue; + } + String[] parts = line.split("\\|", 3); + String previous = values.put(parts[1], parts.length > 2 ? parts[2] : ""); + if (previous != null && !previous.equals(values.get(parts[1]))) { + throw new AssertionError("Probe " + parts[1] + " printed both '" + previous + "' and '" + values.get(parts[1]) + "'"); + } + } + return values; + } + + static String sha256(Path file) { + try { + StringBuilder hex = new StringBuilder(); + for (byte b : MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file))) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + static String path(Path path) { + return path.toAbsolutePath().normalize().toFile().getPath(); + } + + static String path(File file) { + return path(file.toPath()); + } +} diff --git a/gradle-api/src/functionalTest/java/org/nativescript/gradle/PluginBuildTest.java b/gradle-api/src/functionalTest/java/org/nativescript/gradle/PluginBuildTest.java new file mode 100644 index 0000000000..b298ac7f0f --- /dev/null +++ b/gradle-api/src/functionalTest/java/org/nativescript/gradle/PluginBuildTest.java @@ -0,0 +1,126 @@ +package org.nativescript.gradle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.File; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * The CLI's build of a plugin's aar: the CLI's own template from vendor/gradle-plugin, with the + * extracted block of the sample's include.gradle appended, as the CLI does. + */ +class PluginBuildTest { + // The Android Gradle Plugin version decides which Gradle versions can run this build. + private static final String ANDROID_GRADLE_PLUGIN = "8.12.1"; + private static final String GRADLE = "8.14.3"; + + private static final String PROBE = String.join("\n", + "", + "def nsProbe = project.extensions.findByName('nativescript')", + "println \"NS|extension|${nsProbe != null}\"", + "if (nsProbe != null) {", + " println \"NS|pluginBuild|${nsProbe.pluginBuild}\"", + " println \"NS|self|${nsProbe.self?.name}\"", + " println \"NS|buildingSample|${nsProbe.isBuildingPlugin('" + Fixtures.SAMPLE_NAME + "')}\"", + " println \"NS|projectDir|${nsProbe.projectDir}\"", + " println \"NS|appDir|${nsProbe.appDir}\"", + "}", + "println \"NS|compileOnly|\" + configurations.compileOnly.dependencies.withType(FileCollectionDependency)" + + ".collectMany { it.files.files }.collect { it.name }.sort().join(',')", + ""); + + @TempDir + Path tempDir; + + @BeforeAll + static void requireAndroidSdk() { + String androidHome = System.getenv("ANDROID_HOME"); + assumeTrue(androidHome != null && new File(androidHome).isDirectory(), "ANDROID_HOME is not set"); + } + + @ParameterizedTest(name = "readOnlyNodeModules={0}") + @ValueSource(booleans = {false, true}) + void configuresThePluginBuild(boolean readOnlyNodeModules) throws Exception { + Path project = tempDir.toRealPath().resolve("project"); + Path platformsAndroid = project.resolve("platforms/android"); + Fixtures.installCore(project); + Fixtures.installSample(project); + Fixtures.write(project.resolve("package.json"), "{ \"name\": \"fixture-app\" }\n"); + Fixtures.write(platformsAndroid.resolve("dependencies.json"), String.join("\n", + "[", + " { \"name\": \"@nativescript/core\", \"directory\": \"../../node_modules/@nativescript/core\", \"dependencies\": [] },", + " { \"name\": \"" + Fixtures.SAMPLE_NAME + "\", \"directory\": \"../../node_modules/" + Fixtures.SAMPLE_NAME + "\", \"dependencies\": [\"@nativescript/core\"] }", + "]", + "")); + + Path pluginDir = project.resolve("platforms/tempPlugin/gradle-api-sample"); + Path vendor = Fixtures.vendorGradlePluginDir(); + for (String name : List.of("build.gradle", "settings.gradle", "gradle.properties")) { + String text = Fixtures.read(vendor.resolve(name)) + .replace("{{pluginName}}", Fixtures.SAMPLE_NAME) + .replace("{{pluginNamespace}}", "org.nativescript.gradle_api_sample") + .replace("{{runtimeAndroidPluginVersion}}", ANDROID_GRADLE_PLUGIN); + assertFalse(text.contains("{{"), "unknown placeholder left in " + name); + Fixtures.write(pluginDir.resolve(name), text); + } + Path buildGradle = pluginDir.resolve("build.gradle"); + Fixtures.write(buildGradle, Fixtures.read(buildGradle) + "\n" + Fixtures.read(extractedIncludeGradle()) + "\n" + PROBE); + Fixtures.write(pluginDir.resolve("src/main/AndroidManifest.xml"), + "\n"); + + List arguments = new ArrayList<>(List.of( + "help", "--stacktrace", + "--init-script", Fixtures.path(Fixtures.writeInitScript(tempDir)), + "-PtempBuild=true", + "-PappPath=" + Fixtures.path(project.resolve("app")), + "-PappResourcesPath=" + Fixtures.path(project.resolve("app/App_Resources")))); + if (readOnlyNodeModules) { + arguments.add("-Pnativescript.readOnlyNodeModules=true"); + } + Map environment = new HashMap<>(System.getenv()); + environment.put("USER_PROJECT_ROOT", Fixtures.path(project)); + environment.put("USER_PROJECT_PLATFORMS_ANDROID", Fixtures.path(platformsAndroid)); + + BuildResult result = GradleRunner.create() + .withGradleVersion(GRADLE) + .withProjectDir(pluginDir.toFile()) + .withArguments(arguments) + .withEnvironment(environment) + .forwardOutput() + .build(); + Map probes = Fixtures.probes(result); + + if (!readOnlyNodeModules) { + // The template only runs the plugin's own buildscript.gradle on request, so the + // library is not on the classpath and the block takes its old CLI branch. + assertEquals("false", probes.get("extension")); + assertEquals("widgets-release.aar", probes.get("compileOnly")); + return; + } + assertEquals("true", probes.get("extension")); + assertEquals("true", probes.get("pluginBuild")); + assertEquals(Fixtures.SAMPLE_NAME, probes.get("self")); + assertEquals("true", probes.get("buildingSample")); + assertEquals(Fixtures.path(project), probes.get("projectDir")); + assertEquals(Fixtures.path(project.resolve("app")), probes.get("appDir")); + // Only the library's artifacts include archives below the top level. + assertEquals("inner.aar,widgets-release.aar", probes.get("compileOnly")); + } + + static Path extractedIncludeGradle() throws URISyntaxException { + return new File(PluginBuildTest.class.getResource("/extracted/sample-plugin-include.gradle").toURI()).toPath(); + } +} diff --git a/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/package.json b/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/package.json new file mode 100644 index 0000000000..66669d27af --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/package.json @@ -0,0 +1 @@ +{ "name": "fixture-plugin-b", "version": "1.0.0" } diff --git a/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/buildscript.gradle b/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/buildscript.gradle new file mode 100644 index 0000000000..a7a528e6f5 --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/buildscript.gradle @@ -0,0 +1,3 @@ +dependencies { + classpath "org.nativescript:gradle-api:@LIBRARY_VERSION@" +} diff --git a/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/include.gradle b/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/include.gradle new file mode 100644 index 0000000000..f0ddb0584e --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/include.gradle @@ -0,0 +1,17 @@ +// A second plugin applying the library to the same project, after the sample has. +apply plugin: "org.nativescript.api" + +def nsFromB = project.extensions.getByName("nativescript") +println "NS|b.sameExtension|${nativescript.is(nsFromB)}" +println "NS|b.projectDir|${nsFromB.projectDir}" + +// Scripts applied with `apply from` get their own class loader, which does not see the +// classes buildscript.gradle put on the project's classpath, even though the plugin id resolves. +def libraryClassVisible +try { + Class.forName("org.nativescript.gradle.NativeScript") + libraryClassVisible = true +} catch (ClassNotFoundException ignored) { + libraryClassVisible = false +} +println "NS|b.libraryClassVisible|$libraryClassVisible" diff --git a/gradle-api/src/functionalTest/resources/app-fixture/packages/js-only/package.json b/gradle-api/src/functionalTest/resources/app-fixture/packages/js-only/package.json new file mode 100644 index 0000000000..49e0840168 --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/packages/js-only/package.json @@ -0,0 +1 @@ +{ "name": "js-only", "version": "1.0.0" } diff --git a/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/app/build.gradle b/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/app/build.gradle new file mode 100644 index 0000000000..e5cf426701 --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/app/build.gradle @@ -0,0 +1,67 @@ +// Mirrors how the @nativescript/android app template's app/build.gradle applies plugins' Gradle +// files: buildscript.gradle to this project's buildscript, include.gradle from inside `android {}`. +// The template reads the root project's properties implicitly, which Gradle 9 deprecates; reading +// them through rootProject keeps the build free of deprecations so the tests can fail on any. +buildscript { + def applyBuildScriptConfigurations = { -> + rootProject.nativescriptDependencies.each { dep -> + def pathToPluginBuildScriptGradle = "$rootDir/${dep.directory}/${rootProject.PLATFORMS_ANDROID}/buildscript.gradle" + def pluginBuildScriptGradle = file(pathToPluginBuildScriptGradle) + if (pluginBuildScriptGradle.exists()) { + apply from: pathToPluginBuildScriptGradle, to: buildscript + } + } + } + applyBuildScriptConfigurations() +} + +// Stands in for the Android Gradle Plugin's extension, so include.gradle runs with the same +// closure delegation as in a real app build. +class FakeAndroidExtension { + String namespace +} + +extensions.create("android", FakeAndroidExtension) + +def applyPluginGradleConfigurations = { -> + rootProject.nativescriptDependencies.each { dep -> + def includeGradlePath = "$rootDir/${dep.directory}/${rootProject.PLATFORMS_ANDROID}/include.gradle" + if (file(includeGradlePath).exists()) { + apply from: includeGradlePath + } + } +} + +android { + namespace = "org.nativescript.fixture" + applyPluginGradleConfigurations() +} + +def ns = extensions.getByName("nativescript") +println "NS|typed|${org.nativescript.gradle.NativeScript.of(project).is(ns)}" +println "NS|apiLevel|${ns.apiLevel}" +println "NS|pluginBuild|${ns.pluginBuild}" +println "NS|self|${ns.self}" +println "NS|buildingSample|${ns.isBuildingPlugin('@nativescript/gradle-api-sample')}" +println "NS|projectDir|${ns.projectDir}" +println "NS|appDir|${ns.appDir}" +println "NS|appResourcesDir|${ns.appResourcesDir}" +println "NS|has|${ns.has('fixture.capability')}" +def core = ns.plugin("@nativescript/core") +println "NS|core.packageDir|${core.packageDir}" +println "NS|core.nativeDir|${core.nativeDir}" +println "NS|core.artifacts|${core.artifacts.files.collect { it.name }.sort().join(',')}" +println "NS|core.aarName|${core.aarName}" +println "NS|core.aar|${core.aar}" +println "NS|core.options|${core.options}" +println "NS|sample.nativeDir|${ns.plugin('@nativescript/gradle-api-sample').nativeDir}" +println "NS|jsOnly|${ns.findPackage('js-only')}" +println "NS|jsOnlyPlugin|${ns.findPlugin('js-only')}" +println "NS|missingPlugin|${ns.findPlugin('not-installed')}" +println "NS|missingPackage|${ns.findPackage('not-installed')}" +try { + ns.plugin("not-installed") + println "NS|pluginError|none" +} catch (GradleException e) { + println "NS|pluginError|${e.message}" +} diff --git a/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/build.gradle b/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/build.gradle new file mode 100644 index 0000000000..c0b59b083d --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/build.gradle @@ -0,0 +1,62 @@ +// Mirrors the parts of the @nativescript/android app template's root build.gradle that the library +// and plugins' Gradle files rely on. +import groovy.json.JsonSlurper + +import java.nio.file.Paths + +buildscript { + def initialize = { -> + def dependenciesJson = file("$rootDir/dependencies.json") + project.ext.nativescriptDependencies = new JsonSlurper().parseText(dependenciesJson.text) + project.ext.PLATFORMS_ANDROID = "platforms/android" + project.ext.USER_PROJECT_ROOT = "$rootDir/../.." + + project.ext.getAppPath = { -> + def relativePathToApp = "app" + def nsConfigFile = file("$USER_PROJECT_ROOT/nsconfig.json") + def nsConfig + + if (nsConfigFile.exists()) { + nsConfig = new JsonSlurper().parseText(nsConfigFile.getText("UTF-8")) + } + + if (project.hasProperty("appPath")) { + relativePathToApp = appPath + } else if (nsConfig != null && nsConfig.appPath != null) { + relativePathToApp = nsConfig.appPath + } + + project.ext.appPath = Paths.get(USER_PROJECT_ROOT).resolve(relativePathToApp).toAbsolutePath() + + return project.ext.appPath + } + + project.ext.getAppResourcesPath = { -> + def relativePathToAppResources + def absolutePathToAppResources + def nsConfigFile = file("$USER_PROJECT_ROOT/nsconfig.json") + def nsConfig + + if (nsConfigFile.exists()) { + nsConfig = new JsonSlurper().parseText(nsConfigFile.getText("UTF-8")) + } + + if (project.hasProperty("appResourcesPath")) { + relativePathToAppResources = appResourcesPath + absolutePathToAppResources = Paths.get(USER_PROJECT_ROOT).resolve(relativePathToAppResources).toAbsolutePath() + } else if (nsConfig != null && nsConfig.appResourcesPath != null) { + relativePathToAppResources = nsConfig.appResourcesPath + absolutePathToAppResources = Paths.get(USER_PROJECT_ROOT).resolve(relativePathToAppResources).toAbsolutePath() + } else { + absolutePathToAppResources = "${getAppPath()}/App_Resources" + } + + project.ext.appResourcesPath = absolutePathToAppResources + + return absolutePathToAppResources + } + } + + initialize() + getAppResourcesPath() +} diff --git a/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/settings.gradle b/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/settings.gradle new file mode 100644 index 0000000000..0c6d0a2de5 --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = "fixture-app" +include ":app" diff --git a/gradle-api/src/functionalTest/resources/extracted/sample-plugin-include.gradle b/gradle-api/src/functionalTest/resources/extracted/sample-plugin-include.gradle new file mode 100644 index 0000000000..fb5fb51f2e --- /dev/null +++ b/gradle-api/src/functionalTest/resources/extracted/sample-plugin-include.gradle @@ -0,0 +1,8 @@ +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") + } +} \ No newline at end of file diff --git a/gradle-api/src/main/java/org/nativescript/gradle/NativeScript.java b/gradle-api/src/main/java/org/nativescript/gradle/NativeScript.java new file mode 100644 index 0000000000..25b8998e74 --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/NativeScript.java @@ -0,0 +1,43 @@ +package org.nativescript.gradle; + +import org.gradle.api.GradleException; +import org.gradle.api.Project; + +/** + * Entry point for Java and Kotlin code, such as another Gradle plugin built on this library. + * + *

Scripts applied with {@code apply from}, which include every plugin's {@code include.gradle}, + * get their own class loader and cannot see this class even when {@code buildscript.gradle} put the + * library on the project's classpath. They apply the plugin by id and use the extension + * dynamically, through {@code project.extensions.getByName("nativescript")}. + */ +public final class NativeScript { + private NativeScript() {} + + /** + * The {@value NativeScriptExtension#NAME} extension of {@code project}, applying {@link + * NativeScriptPlugin} first if needed. + * + * @param project the project, normally {@code :app} in the app build + * @return the extension + * @throws GradleException if the project's extension comes from another copy of this library, + * loaded by a different class loader + */ + public static NativeScriptExtension of(Project project) { + Object extension = project.getExtensions().findByName(NativeScriptExtension.NAME); + if (extension == null) { + project.getPluginManager().apply(NativeScriptPlugin.class); + extension = project.getExtensions().getByName(NativeScriptExtension.NAME); + } + if (extension instanceof NativeScriptExtension) { + return (NativeScriptExtension) extension; + } + throw new GradleException("The '" + NativeScriptExtension.NAME + "' extension of " + project + + " is a " + extension.getClass().getName() + " (class loader " + extension.getClass().getClassLoader() + + "), not this copy of org.nativescript:gradle-api's " + NativeScriptExtension.class.getName() + + ". Either something else registered an extension of that name, or another copy of the library" + + " in a different class loader did; then put the library on the classpath of the buildscript that" + + " applies it, or use the extension dynamically through project.extensions.getByName(\"" + + NativeScriptExtension.NAME + "\")."); + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/NativeScriptExtension.java b/gradle-api/src/main/java/org/nativescript/gradle/NativeScriptExtension.java new file mode 100644 index 0000000000..6f133975de --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/NativeScriptExtension.java @@ -0,0 +1,171 @@ +package org.nativescript.gradle; + +import java.io.File; + +/** + * What a NativeScript plugin's Gradle code can ask about the project being built, registered as + * the {@value #NAME} extension by {@link NativeScriptPlugin}. + * + *

Answers come from the facts file the NativeScript CLI writes ({@code + * platforms/android/nativescript-gradle.json}) or, with CLIs that do not write one (9.1 and older), + * from the same properties and files those CLIs' Gradle templates use. A facts file that such a CLI + * left behind no longer matches the {@code dependencies.json} next to it, and is ignored with a + * warning. A plugin written against + * this API therefore builds with old and new CLIs alike. + * + *

Everything is worked out on first use, during configuration, and does not change afterwards. + * Read what a task needs while configuring it rather than calling this from a task action. + * + *

Two builds can use this API: + * + *

    + *
  • the app build ({@code platforms/android}), where every plugin's {@code include.gradle} is + * applied to the {@code :app} project; + *
  • a plugin build, the throwaway project in which the CLI compiles one plugin's sources into + * an aar. Only the {@code repositories} and {@code dependencies} blocks of that plugin's own + * {@code include.gradle} run there, but the {@code include.gradle} files of the plugins it + * depends on run in full. + *
+ * + *

Not intended to be implemented outside this library; methods may be added in later versions. + * Check {@link #getApiLevel()} before calling a method newer than the version a plugin declares, + * because another plugin may have put a newer version of this library on the classpath, never an + * older one. + * + *

{@code
+ * apply plugin: "org.nativescript.api"
+ *
+ * dependencies {
+ *     def ns = project.extensions.findByName("nativescript")
+ *     if (ns != null && ns.pluginBuild) {
+ *         compileOnly ns.plugin("@nativescript/core").artifacts
+ *     }
+ * }
+ * }
+ */ +public interface NativeScriptExtension { + /** The name the extension is registered under. */ + String NAME = "nativescript"; + + /** + * The API level this copy of the library implements: 1 for the methods documented as such, and + * one more for each later version that adds methods. + * + * @return the API level, at least 1 + * @since API level 1 + */ + int getApiLevel(); + + /** + * Whether the CLI that prepared the project guarantees a behaviour. Capability names are listed + * in the facts file's schema documentation. + * + * @param capability the capability name + * @return true if the CLI declared it; always false with CLIs that do not write a facts file + * @since API level 1 + */ + boolean has(String capability); + + /** + * Whether this Gradle build is the CLI's build of one plugin's aar rather than the app build. + * + * @return true in a plugin build + * @since API level 1 + */ + boolean isPluginBuild(); + + /** + * In a plugin build, the plugin whose aar is being built. A dependency's {@code include.gradle} + * applied there sees the plugin that depends on it, not itself. + * + * @return that plugin, or null in the app build or if the CLI did not name the plugin + * @since API level 1 + */ + PluginInfo getSelf(); + + /** + * Whether this is the plugin build of the given plugin. + * + * @param npmName the plugin's npm package name, such as {@code @nativescript/core} + * @return true only in that plugin's own aar build + * @since API level 1 + */ + boolean isBuildingPlugin(String npmName); + + /** + * The NativeScript project root: the directory with the app's {@code package.json}. + * + * @return an absolute, normalized directory + * @since API level 1 + */ + File getProjectDir(); + + /** + * The app's source directory ({@code appPath} in the NativeScript config). + * + * @return an absolute, normalized directory + * @since API level 1 + */ + File getAppDir(); + + /** + * The app's {@code App_Resources} directory ({@code appResourcesPath} in the NativeScript config). + * + * @return an absolute, normalized directory + * @since API level 1 + */ + File getAppResourcesDir(); + + /** + * A plugin of the project: a package the CLI lists as having Android native code or, failing + * that, an installed package (found as in {@link #findPackage(String)}) with a {@code + * platforms/android} directory. The same rule applies with every CLI version, so a plugin gets + * the same answer whichever CLI prepared the project; only the details of the CLI's list, such + * as {@link PluginInfo#getAarName()}, depend on the version. + * + * @param npmName the npm package name, such as {@code @nativescript/core} + * @return the plugin + * @throws org.gradle.api.GradleException if there is no such plugin; the message lists the + * plugins that are known + * @throws IllegalArgumentException if {@code npmName} is not an npm package name + * @since API level 1 + */ + PluginInfo plugin(String npmName); + + /** + * Like {@link #plugin(String)}, but returns null when there is no such plugin. + * + * @param npmName the npm package name, such as {@code @nativescript/core} + * @return the plugin, or null + * @throws IllegalArgumentException if {@code npmName} is not an npm package name + * @since API level 1 + */ + PluginInfo findPlugin(String npmName); + + /** + * The directory of any installed npm package, native plugin or not. + * + *

Packages the CLI lists as plugins resolve to the directory it recorded. Others are looked + * up the way Node does: {@code node_modules/} in the project root, then in each parent + * directory. This is best effort: layouts that keep packages out of the project's {@code + * node_modules}, such as pnpm's isolated mode for packages the app does not depend on directly, + * may not resolve. + * + * @param npmName the npm package name + * @return the package directory + * @throws org.gradle.api.GradleException if the package is not found + * @throws IllegalArgumentException if {@code npmName} is not an npm package name + * @since API level 1 + */ + File resolvePackage(String npmName); + + /** + * Like {@link #resolvePackage(String)}, but returns null when the package is not found. + * + * @param npmName the npm package name + * @return the package directory, or null + * @throws IllegalArgumentException if {@code npmName} is not an npm package name + * @since API level 1 + */ + File findPackage(String npmName); +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/NativeScriptPlugin.java b/gradle-api/src/main/java/org/nativescript/gradle/NativeScriptPlugin.java new file mode 100644 index 0000000000..d04366103a --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/NativeScriptPlugin.java @@ -0,0 +1,26 @@ +package org.nativescript.gradle; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.nativescript.gradle.internal.DefaultNativeScriptExtension; + +/** + * Registers the {@value NativeScriptExtension#NAME} extension, plugin id {@code + * org.nativescript.api}. + * + *

Every plugin of an app applies this to the same {@code :app} project; applying it again, or + * when the extension already exists, does nothing. + */ +public class NativeScriptPlugin implements Plugin { + /** Creates the plugin; Gradle does this when it is applied. */ + public NativeScriptPlugin() {} + + @Override + public void apply(Project project) { + if (project.getExtensions().findByName(NativeScriptExtension.NAME) != null) { + return; + } + project.getExtensions() + .add(NativeScriptExtension.class, NativeScriptExtension.NAME, new DefaultNativeScriptExtension(project)); + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/PluginInfo.java b/gradle-api/src/main/java/org/nativescript/gradle/PluginInfo.java new file mode 100644 index 0000000000..9339666971 --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/PluginInfo.java @@ -0,0 +1,82 @@ +package org.nativescript.gradle; + +import java.io.File; +import java.util.Map; +import org.gradle.api.file.FileCollection; + +/** + * A plugin of the NativeScript project: an npm package with Android native code. + * + *

Not intended to be implemented outside this library; methods may be added in later versions. + * + * @see NativeScriptExtension#plugin(String) + */ +public interface PluginInfo { + /** + * The npm package name. + * + * @return the name, such as {@code @nativescript/core} + * @since API level 1 + */ + String getName(); + + /** + * The installed npm package. Treat it as read-only: the CLI may keep {@code node_modules} + * read-only and work on a copy of the native files instead. + * + * @return an absolute, normalized directory + * @since API level 1 + */ + File getPackageDir(); + + /** + * The directory the build reads the plugin's Android files from: the package's {@code + * platforms/android}, or the CLI's copy of it. + * + * @return an absolute, normalized directory + * @since API level 1 + */ + File getNativeDir(); + + /** + * Every {@code .aar} and {@code .jar} under the {@linkplain #getNativeDir() native directory}, + * including the aar the CLI builds from the plugin's sources once it exists. Evaluated lazily. + * + *

Meant for {@code compileOnly} in a dependent plugin's build: the app build already + * packages these files. + * + * @return a live view of those files + * @since API level 1 + */ + FileCollection getArtifacts(); + + /** + * Options the project sets for this plugin. Values are JSON values: {@code String}, {@code + * Boolean}, {@code Integer}, {@code Long}, {@code java.math.BigInteger}, {@code + * java.math.BigDecimal}, {@code null}, and unmodifiable {@code List}s and {@code Map}s of them. + * + * @return an unmodifiable map; empty with CLIs that do not write a facts file + * @since API level 1 + */ + Map getOptions(); + + /** + * File name, without extension, of the aar the CLI builds from the plugin's sources; unique + * across the project. + * + * @return the name, or null with CLIs that do not write a facts file, and for installed + * packages the CLI does not list as plugins + * @since API level 1 + */ + String getAarName(); + + /** + * Where the CLI puts the aar it builds from the plugin's sources. The file only exists once + * built, and only if the plugin has sources. + * + * @return the file, or null with CLIs that do not write a facts file, and for installed + * packages the CLI does not list as plugins + * @since API level 1 + */ + File getAar(); +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/BuildInputs.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/BuildInputs.java new file mode 100644 index 0000000000..f7c03d1ded --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/BuildInputs.java @@ -0,0 +1,51 @@ +package org.nativescript.gradle.internal; + +import java.io.File; +import org.gradle.api.Project; +import org.gradle.api.plugins.ExtraPropertiesExtension; +import org.gradle.api.provider.ProviderFactory; + +/** What {@link Resolution} reads from the build. */ +public interface BuildInputs { + /** + * An extra property of the project or, failing that, of its nearest ancestor that has one; else + * a Gradle property ({@code -P}, gradle.properties). Null when absent. + */ + Object property(String name); + + /** An environment variable, null when absent or empty. */ + String environmentVariable(String name); + + /** The root project's directory. */ + File rootDir(); + + static BuildInputs of(Project project) { + ProviderFactory providers = project.getProviders(); + File rootDir = project.getRootDir(); + return new BuildInputs() { + // Not Project.findProperty: its implicit lookup in parent projects is deprecated since + // Gradle 9.x, and the templates set their extra properties on the root project. + @Override + public Object property(String name) { + for (Project p = project; p != null; p = p.getParent()) { + ExtraPropertiesExtension extra = p.getExtensions().getExtraProperties(); + if (extra.has(name)) { + return extra.get(name); + } + } + return providers.gradleProperty(name).getOrNull(); + } + + @Override + public String environmentVariable(String name) { + String value = providers.environmentVariable(name).getOrNull(); + return value == null || value.isEmpty() ? null : value; + } + + @Override + public File rootDir() { + return rootDir; + } + }; + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultNativeScriptExtension.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultNativeScriptExtension.java new file mode 100644 index 0000000000..c34cd15e85 --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultNativeScriptExtension.java @@ -0,0 +1,134 @@ +package org.nativescript.gradle.internal; + +import java.io.File; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.gradle.api.GradleException; +import org.gradle.api.Project; +import org.gradle.api.model.ObjectFactory; +import org.nativescript.gradle.NativeScriptExtension; +import org.nativescript.gradle.PluginInfo; + +public final class DefaultNativeScriptExtension implements NativeScriptExtension { + public static final int API_LEVEL = 1; + + private final BuildInputs inputs; + private final ObjectFactory objects; + private final Map pluginInfos = new ConcurrentHashMap<>(); + private volatile Resolution resolution; + + public DefaultNativeScriptExtension(Project project) { + this(BuildInputs.of(project), project.getObjects()); + } + + public DefaultNativeScriptExtension(BuildInputs inputs, ObjectFactory objects) { + this.inputs = inputs; + this.objects = objects; + } + + // Resolved on first use rather than when applied: in the plugin build the library is applied + // reflectively, and plugins may apply it before the template has set its extra properties. + private Resolution resolution() { + Resolution result = resolution; + if (result == null) { + synchronized (this) { + result = resolution; + if (result == null) { + result = Resolution.resolve(inputs); + resolution = result; + } + } + } + return result; + } + + @Override + public int getApiLevel() { + return API_LEVEL; + } + + @Override + public boolean has(String capability) { + return resolution().getCapabilities().contains(capability); + } + + @Override + public boolean isPluginBuild() { + return resolution().isPluginBuild(); + } + + @Override + public PluginInfo getSelf() { + String name = resolution().getPluginName(); + return name != null ? findPlugin(name) : null; + } + + @Override + public boolean isBuildingPlugin(String npmName) { + return npmName != null && npmName.equals(resolution().getPluginName()); + } + + @Override + public File getProjectDir() { + return resolution().getProjectDir(); + } + + @Override + public File getAppDir() { + return resolution().getAppDir(); + } + + @Override + public File getAppResourcesDir() { + return resolution().getAppResourcesDir(); + } + + @Override + public PluginInfo plugin(String npmName) { + PluginInfo plugin = findPlugin(npmName); + if (plugin == null) { + Resolution r = resolution(); + String list = r.getFacts() != null ? r.getFacts().getFile().toString() : "dependencies.json"; + throw new GradleException("NativeScript plugin '" + npmName + "' is not listed in " + list + + ", nor installed with a platforms/android directory in " + + new File(r.getProjectDir(), "node_modules") + " or a parent directory" + + ". Known plugins: " + + (r.getPlugins().isEmpty() ? "none" : String.join(", ", r.getPlugins().keySet())) + "."); + } + return plugin; + } + + @Override + public PluginInfo findPlugin(String npmName) { + NodePackages.validate(npmName); + PluginInfo cached = pluginInfos.get(npmName); + if (cached != null) { + return cached; + } + PluginData data = resolution().findPlugin(npmName); + if (data == null) { + return null; + } + return pluginInfos.computeIfAbsent(npmName, name -> new DefaultPluginInfo(data, objects)); + } + + @Override + public File resolvePackage(String npmName) { + File dir = findPackage(npmName); + if (dir == null) { + throw new GradleException("npm package '" + npmName + "' is not installed in " + + new File(getProjectDir(), "node_modules") + " or a parent directory."); + } + return dir; + } + + @Override + public File findPackage(String npmName) { + return resolution().findPackage(npmName); + } + + @Override + public String toString() { + return "extension '" + NAME + "'"; + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultPluginInfo.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultPluginInfo.java new file mode 100644 index 0000000000..453fdea331 --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultPluginInfo.java @@ -0,0 +1,59 @@ +package org.nativescript.gradle.internal; + +import java.io.File; +import java.util.Map; +import org.gradle.api.file.FileCollection; +import org.gradle.api.model.ObjectFactory; +import org.nativescript.gradle.PluginInfo; + +final class DefaultPluginInfo implements PluginInfo { + private final PluginData data; + private final FileCollection artifacts; + + DefaultPluginInfo(PluginData data, ObjectFactory objects) { + this.data = data; + this.artifacts = objects.fileTree() + .from(data.getNativeDir()) + .matching(patterns -> patterns.include("**/*.aar", "**/*.jar")); + } + + @Override + public String getName() { + return data.getName(); + } + + @Override + public File getPackageDir() { + return data.getPackageDir(); + } + + @Override + public File getNativeDir() { + return data.getNativeDir(); + } + + @Override + public FileCollection getArtifacts() { + return artifacts; + } + + @Override + public Map getOptions() { + return data.getOptions(); + } + + @Override + public String getAarName() { + return data.getAarName(); + } + + @Override + public File getAar() { + return data.getAar(); + } + + @Override + public String toString() { + return "NativeScript plugin " + data; + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/Facts.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/Facts.java new file mode 100644 index 0000000000..59706db2ae --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/Facts.java @@ -0,0 +1,315 @@ +package org.nativescript.gradle.internal; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.gradle.api.GradleException; +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; + +/** + * The CLI's {@code nativescript-gradle.json}, described by {@code schema/nativescript-gradle.schema.json}. + * + *

Fields are only ever added to the format, so unknown fields are ignored and a newer {@code + * schemaVersion} is read like this one. + */ +public final class Facts { + /** The newest schemaVersion this library knows every field of. */ + public static final int KNOWN_SCHEMA_VERSION = 1; + + private static final Logger LOGGER = Logging.getLogger(Facts.class); + + // Checked against the file's whole content, not its mtime: the class loader holding this cache + // outlives builds in the daemon, and a rewrite can keep the size and land in the same mtime tick. + private static final Map CACHE = new ConcurrentHashMap<>(); + + private final File file; + private final int schemaVersion; + private final String dependenciesJsonSha256; + private final Set capabilities; + private final File projectRoot; + private final File appDir; + private final File appResourcesDir; + private final Map plugins; + + private Facts( + File file, + int schemaVersion, + String dependenciesJsonSha256, + Set capabilities, + File projectRoot, + File appDir, + File appResourcesDir, + Map plugins) { + this.file = file; + this.schemaVersion = schemaVersion; + this.dependenciesJsonSha256 = dependenciesJsonSha256; + this.capabilities = Collections.unmodifiableSet(capabilities); + this.projectRoot = projectRoot; + this.appDir = appDir; + this.appResourcesDir = appResourcesDir; + this.plugins = Collections.unmodifiableMap(plugins); + } + + /** + * Reads {@code file}, reusing the previous result while its content is unchanged. + * + * @throws GradleException if the file cannot be read or is not valid facts + */ + public static Facts read(File file) { + File absolute = file.getAbsoluteFile(); + String key = absolute.getPath(); + byte[] content; + try { + content = Files.readAllBytes(absolute.toPath()); + } catch (IOException e) { + throw new GradleException("Could not read NativeScript facts file " + absolute + ": " + e, e); + } + CachedFacts cached = CACHE.get(key); + if (cached != null && Arrays.equals(cached.content, content)) { + return cached.facts; + } + Facts facts = parse(absolute, new String(content, StandardCharsets.UTF_8)); + CACHE.put(key, new CachedFacts(content, facts)); + return facts; + } + + /** + * Parses facts whose relative paths are relative to {@code file}'s directory. Does not touch the + * file system. + * + * @throws GradleException if {@code text} is not valid facts + */ + public static Facts parse(File file, String text) { + Object root; + try { + root = Json.parse(text); + } catch (Json.ParseException e) { + throw invalid(file, e.getMessage()); + } + Path base = file.getAbsoluteFile().toPath().getParent(); + Reader reader = new Reader(file, base); + Map top = reader.object(root, "the top level"); + + int schemaVersion = reader.integer(top, "schemaVersion", ""); + if (schemaVersion < 1) { + throw invalid(file, "schemaVersion must be at least 1"); + } + if (schemaVersion > KNOWN_SCHEMA_VERSION) { + LOGGER.info( + "{} has schemaVersion {}; this version of org.nativescript:gradle-api knows up to {}" + + " and ignores the fields it does not know.", + file, + schemaVersion, + KNOWN_SCHEMA_VERSION); + } + + // Required by the schema, but a reader must stay usable if a later writer drops it. + String dependenciesJsonSha256 = null; + if (top.containsKey("dependenciesJson")) { + Map dependenciesJson = reader.object(top.get("dependenciesJson"), "dependenciesJson"); + dependenciesJsonSha256 = reader.requiredString(dependenciesJson, "sha256", "dependenciesJson."); + if (!dependenciesJsonSha256.matches("[0-9a-f]{64}")) { + throw invalid(file, "dependenciesJson.sha256 must be 64 lowercase hex digits"); + } + } + + Set capabilities = new LinkedHashSet<>(); + if (top.containsKey("capabilities")) { + List list = reader.array(top.get("capabilities"), "capabilities"); + for (int i = 0; i < list.size(); i++) { + capabilities.add(reader.string(list.get(i), "capabilities[" + i + "]")); + } + } + + Map project = reader.object(top.get("project"), "project"); + File projectRoot = reader.path(project, "root", "project."); + File appDir = reader.path(project, "appDir", "project."); + File appResourcesDir = reader.path(project, "appResourcesDir", "project."); + + Map plugins = new LinkedHashMap<>(); + List pluginList = reader.array(top.get("plugins"), "plugins"); + for (int i = 0; i < pluginList.size(); i++) { + String at = "plugins[" + i + "]"; + Map entry = reader.object(pluginList.get(i), at); + String prefix = at + "."; + String name = reader.requiredString(entry, "name", prefix); + List deps = reader.array(entry.get("dependencies"), prefix + "dependencies"); + List dependencies = new ArrayList<>(); + for (int d = 0; d < deps.size(); d++) { + dependencies.add(reader.string(deps.get(d), prefix + "dependencies[" + d + "]")); + } + Map options = + entry.containsKey("options") ? reader.object(entry.get("options"), prefix + "options") : Collections.emptyMap(); + PluginData plugin = new PluginData( + name, + reader.path(entry, "packageDir", prefix), + reader.path(entry, "nativeDir", prefix), + reader.requiredString(entry, "aarName", prefix), + reader.path(entry, "aar", prefix), + dependencies, + options); + if (plugins.put(name, plugin) != null) { + throw invalid(file, "plugin '" + name + "' is listed more than once"); + } + } + + return new Facts( + file, schemaVersion, dependenciesJsonSha256, capabilities, projectRoot, appDir, appResourcesDir, plugins); + } + + private static GradleException invalid(File file, String problem) { + return new GradleException("Invalid NativeScript facts file " + file + ": " + problem); + } + + /** + * Why these facts no longer describe the project, or null if they still do: a CLI that does not + * write facts rewrites the dependencies.json next to them but leaves them behind. Reads that + * file on every call, so the answer is never cached. + * + * @throws GradleException if dependencies.json exists but cannot be read + */ + public String staleness() { + if (dependenciesJsonSha256 == null) { + return null; + } + File dependenciesJson = new File(file.getAbsoluteFile().getParentFile(), "dependencies.json"); + if (!dependenciesJson.isFile()) { + return "there is no " + dependenciesJson + " next to it"; + } + byte[] content; + try { + content = Files.readAllBytes(dependenciesJson.toPath()); + } catch (IOException e) { + throw new GradleException("Could not read " + dependenciesJson + ": " + e, e); + } + if (sha256(content).equals(dependenciesJsonSha256)) { + return null; + } + return "it does not match " + dependenciesJson + ", which a different NativeScript CLI wrote"; + } + + static String sha256(byte[] content) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Every Java platform provides SHA-256", e); + } + StringBuilder hex = new StringBuilder(64); + for (byte b : digest.digest(content)) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } + + public File getFile() { + return file; + } + + public int getSchemaVersion() { + return schemaVersion; + } + + /** Null if the writer did not record it. */ + public String getDependenciesJsonSha256() { + return dependenciesJsonSha256; + } + + public Set getCapabilities() { + return capabilities; + } + + public File getProjectRoot() { + return projectRoot; + } + + public File getAppDir() { + return appDir; + } + + public File getAppResourcesDir() { + return appResourcesDir; + } + + /** By npm name, in the order the CLI listed them. */ + public Map getPlugins() { + return plugins; + } + + private static final class CachedFacts { + final byte[] content; + final Facts facts; + + CachedFacts(byte[] content, Facts facts) { + this.content = content; + this.facts = facts; + } + } + + private static final class Reader { + private final File file; + private final Path base; + + Reader(File file, Path base) { + this.file = file; + this.base = base; + } + + @SuppressWarnings("unchecked") + Map object(Object value, String what) { + if (!(value instanceof Map)) { + throw invalid(file, what + " must be an object"); + } + return (Map) value; + } + + @SuppressWarnings("unchecked") + List array(Object value, String what) { + if (!(value instanceof List)) { + throw invalid(file, what + " must be an array"); + } + return (List) value; + } + + String string(Object value, String what) { + if (!(value instanceof String)) { + throw invalid(file, what + " must be a string"); + } + return (String) value; + } + + String requiredString(Map object, String key, String prefix) { + String value = string(object.get(key), prefix + key); + if (value.isEmpty()) { + throw invalid(file, prefix + key + " must not be empty"); + } + return value; + } + + int integer(Map object, String key, String prefix) { + Object value = object.get(key); + if (!(value instanceof Integer)) { + throw invalid(file, prefix + key + " must be an integer"); + } + return (Integer) value; + } + + File path(Map object, String key, String prefix) { + return base.resolve(requiredString(object, key, prefix)).normalize().toFile(); + } + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/Json.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/Json.java new file mode 100644 index 0000000000..89ebffca44 --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/Json.java @@ -0,0 +1,366 @@ +package org.nativescript.gradle.internal; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Strict RFC 8259 JSON parser. + * + *

The library must not depend on a JSON library, nor on Groovy's: Gradle 7 ships Groovy 3 and + * Gradle 8+ ship Groovy 4, under different package names. + * + *

Objects become unmodifiable {@link LinkedHashMap}s (a repeated key keeps the last value, as + * {@code JSON.parse} does), arrays unmodifiable lists, integers {@link Integer}, {@link Long} or + * {@link BigInteger} (the smallest that fits), other numbers {@link BigDecimal}, and {@code null} + * stays {@code null}. A leading byte order mark is skipped. + */ +public final class Json { + private static final int MAX_DEPTH = 512; + + private final String text; + private int pos; + private int depth; + + private Json(String text) { + this.text = text; + } + + /** + * Parses one JSON value, which must be the whole of {@code text} apart from whitespace. + * + * @throws ParseException if {@code text} is not valid JSON + */ + public static Object parse(String text) { + Json parser = new Json(text); + if (!text.isEmpty() && text.charAt(0) == 0xFEFF) { + parser.pos = 1; + } + parser.skipWhitespace(); + Object value = parser.readValue(); + parser.skipWhitespace(); + if (parser.pos < text.length()) { + throw parser.error("Unexpected " + parser.describeCurrent() + " after the JSON value"); + } + return value; + } + + private Object readValue() { + if (pos >= text.length()) { + throw error("Unexpected end of input, expected a value"); + } + char c = text.charAt(pos); + switch (c) { + case '{': + return readObject(); + case '[': + return readArray(); + case '"': + return readString(); + case 't': + expectLiteral("true"); + return Boolean.TRUE; + case 'f': + expectLiteral("false"); + return Boolean.FALSE; + case 'n': + expectLiteral("null"); + return null; + default: + if (c == '-' || (c >= '0' && c <= '9')) { + return readNumber(); + } + throw error("Unexpected " + describeCurrent() + ", expected a value"); + } + } + + private Map readObject() { + enter(); + pos++; + Map result = new LinkedHashMap<>(); + skipWhitespace(); + if (peek() == '}') { + pos++; + depth--; + return Collections.unmodifiableMap(result); + } + while (true) { + skipWhitespace(); + if (peek() != '"') { + throw error("Unexpected " + describeCurrent() + ", expected a string key"); + } + String key = readString(); + skipWhitespace(); + expect(':'); + skipWhitespace(); + result.put(key, readValue()); + skipWhitespace(); + if (peek() == ',') { + pos++; + } else if (peek() == '}') { + pos++; + depth--; + return Collections.unmodifiableMap(result); + } else { + throw error("Unexpected " + describeCurrent() + ", expected ',' or '}'"); + } + } + } + + private List readArray() { + enter(); + pos++; + List result = new ArrayList<>(); + skipWhitespace(); + if (peek() == ']') { + pos++; + depth--; + return Collections.unmodifiableList(result); + } + while (true) { + skipWhitespace(); + result.add(readValue()); + skipWhitespace(); + if (peek() == ',') { + pos++; + } else if (peek() == ']') { + pos++; + depth--; + return Collections.unmodifiableList(result); + } else { + throw error("Unexpected " + describeCurrent() + ", expected ',' or ']'"); + } + } + } + + private String readString() { + pos++; + StringBuilder sb = new StringBuilder(); + while (true) { + if (pos >= text.length()) { + throw error("Unterminated string"); + } + char c = text.charAt(pos); + if (c == '"') { + pos++; + return sb.toString(); + } + if (c < 0x20) { + throw error("Unescaped control character U+" + String.format("%04X", (int) c) + " in string"); + } + if (c != '\\') { + sb.append(c); + pos++; + continue; + } + pos++; + if (pos >= text.length()) { + throw error("Unterminated string"); + } + char escaped = text.charAt(pos); + switch (escaped) { + case '"': + case '\\': + case '/': + sb.append(escaped); + break; + case 'b': + sb.append('\b'); + break; + case 'f': + sb.append('\f'); + break; + case 'n': + sb.append('\n'); + break; + case 'r': + sb.append('\r'); + break; + case 't': + sb.append('\t'); + break; + case 'u': + sb.append(readUnicodeEscape()); + continue; + default: + throw error("Invalid escape sequence '\\" + escaped + "'"); + } + pos++; + } + } + + private char readUnicodeEscape() { + int start = pos + 1; + if (start + 4 > text.length()) { + throw error("Incomplete \\u escape"); + } + int value = 0; + for (int i = start; i < start + 4; i++) { + char c = text.charAt(i); + int digit = c < 0x80 ? Character.digit(c, 16) : -1; + if (digit < 0) { + pos = i; + throw error("Invalid hex digit " + describeCurrent() + " in \\u escape"); + } + value = value * 16 + digit; + } + pos = start + 4; + return (char) value; + } + + private Number readNumber() { + int start = pos; + boolean integral = true; + if (peek() == '-') { + pos++; + } + if (peek() == '0') { + pos++; + if (isDigit(peek())) { + throw error("Leading zeros are not allowed"); + } + } else if (isDigit(peek())) { + skipDigits(); + } else { + throw error("Unexpected " + describeCurrent() + ", expected a digit"); + } + if (peek() == '.') { + integral = false; + pos++; + if (!isDigit(peek())) { + throw error("Unexpected " + describeCurrent() + ", expected a digit after '.'"); + } + skipDigits(); + } + if (peek() == 'e' || peek() == 'E') { + integral = false; + pos++; + if (peek() == '+' || peek() == '-') { + pos++; + } + if (!isDigit(peek())) { + throw error("Unexpected " + describeCurrent() + ", expected a digit in the exponent"); + } + skipDigits(); + } + String literal = text.substring(start, pos); + if (!integral) { + return new BigDecimal(literal); + } + BigInteger value = new BigInteger(literal); + if (value.bitLength() < 32) { + return value.intValue(); + } + if (value.bitLength() < 64) { + return value.longValue(); + } + return value; + } + + private void skipDigits() { + while (isDigit(peek())) { + pos++; + } + } + + private static boolean isDigit(int c) { + return c >= '0' && c <= '9'; + } + + private void expectLiteral(String literal) { + for (int i = 0; i < literal.length(); i++) { + if (peek() != literal.charAt(i)) { + throw error("Unexpected " + describeCurrent() + ", expected '" + literal + "'"); + } + pos++; + } + } + + private void expect(char c) { + if (peek() != c) { + throw error("Unexpected " + describeCurrent() + ", expected '" + c + "'"); + } + pos++; + } + + private void enter() { + if (++depth > MAX_DEPTH) { + throw error("Nesting deeper than " + MAX_DEPTH + " levels"); + } + } + + /** The current character, or -1 at the end of the input. */ + private int peek() { + return pos < text.length() ? text.charAt(pos) : -1; + } + + private void skipWhitespace() { + while (pos < text.length()) { + char c = text.charAt(pos); + if (c != ' ' && c != '\t' && c != '\n' && c != '\r') { + return; + } + pos++; + } + } + + private String describeCurrent() { + if (pos >= text.length()) { + return "end of input"; + } + char c = text.charAt(pos); + if (c < 0x20 || c == 0x7F) { + return "character U+" + String.format("%04X", (int) c); + } + return "character '" + c + "'"; + } + + private ParseException error(String message) { + int line = 1; + int column = 1; + for (int i = 0; i < pos && i < text.length(); i++) { + if (text.charAt(i) == '\n') { + line++; + column = 1; + } else { + column++; + } + } + return new ParseException(message, pos, line, column); + } + + /** Invalid JSON, with the position of the first offending character. */ + public static final class ParseException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final int offset; + private final int line; + private final int column; + + ParseException(String message, int offset, int line, int column) { + super(message + " at line " + line + ", column " + column); + this.offset = offset; + this.line = line; + this.column = column; + } + + /** Zero-based character offset into the parsed text. */ + public int getOffset() { + return offset; + } + + /** One-based line number. */ + public int getLine() { + return line; + } + + /** One-based column number, counted in UTF-16 characters. */ + public int getColumn() { + return column; + } + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/NodePackages.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/NodePackages.java new file mode 100644 index 0000000000..714c1dd23c --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/NodePackages.java @@ -0,0 +1,41 @@ +package org.nativescript.gradle.internal; + +import java.io.File; + +/** Node's lookup of installed packages: {@code node_modules/} in a directory or any ancestor. */ +public final class NodePackages { + private NodePackages() {} + + /** + * The directory of the installed package {@code name} as seen from {@code fromDir}, or null. + * + * @throws IllegalArgumentException if {@code name} is not an npm package name + */ + public static File find(File fromDir, String name) { + validate(name); + for (File dir = fromDir.getAbsoluteFile(); dir != null; dir = dir.getParentFile()) { + File candidate = new File(new File(dir, "node_modules"), name); + if (new File(candidate, "package.json").isFile()) { + return candidate.toPath().normalize().toFile(); + } + } + return null; + } + + static void validate(String name) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("An npm package name must not be empty"); + } + String[] parts = name.split("/", -1); + boolean scoped = name.startsWith("@"); + boolean valid = scoped ? parts.length == 2 && parts[0].length() > 1 : parts.length == 1; + for (String part : parts) { + if (part.isEmpty() || part.equals(".") || part.equals("..") || part.indexOf('\\') >= 0 || part.indexOf(':') >= 0) { + valid = false; + } + } + if (!valid) { + throw new IllegalArgumentException("'" + name + "' is not an npm package name"); + } + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/PluginData.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/PluginData.java new file mode 100644 index 0000000000..a4ffd0754e --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/PluginData.java @@ -0,0 +1,69 @@ +package org.nativescript.gradle.internal; + +import java.io.File; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** What is known about one npm package with Android native code. Immutable. */ +public final class PluginData { + private final String name; + private final File packageDir; + private final File nativeDir; + private final String aarName; + private final File aar; + private final List dependencies; + private final Map options; + + public PluginData( + String name, + File packageDir, + File nativeDir, + String aarName, + File aar, + List dependencies, + Map options) { + this.name = name; + this.packageDir = packageDir; + this.nativeDir = nativeDir; + this.aarName = aarName; + this.aar = aar; + this.dependencies = Collections.unmodifiableList(dependencies); + this.options = Collections.unmodifiableMap(options); + } + + public String getName() { + return name; + } + + public File getPackageDir() { + return packageDir; + } + + public File getNativeDir() { + return nativeDir; + } + + /** Null unless the CLI wrote facts. */ + public String getAarName() { + return aarName; + } + + /** Null unless the CLI wrote facts. */ + public File getAar() { + return aar; + } + + public List getDependencies() { + return dependencies; + } + + public Map getOptions() { + return options; + } + + @Override + public String toString() { + return name + " (" + nativeDir + ")"; + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/Resolution.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/Resolution.java new file mode 100644 index 0000000000..3b527fb7ba --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/Resolution.java @@ -0,0 +1,332 @@ +package org.nativescript.gradle.internal; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.gradle.api.GradleException; +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; + +/** + * Everything the extension answers, worked out once from the CLI's facts file or, for CLIs that do + * not write one (9.1 and older), from the same inputs their Gradle templates read. + */ +public final class Resolution { + public static final String FACTS_FILE_NAME = "nativescript-gradle.json"; + public static final String FACTS_FILE_PROPERTY = "nativescript.factsFile"; + + private static final String PLATFORMS_ANDROID = "platforms/android"; + private static final Logger LOGGER = Logging.getLogger(Resolution.class); + + private final Facts facts; + private final boolean pluginBuild; + private final String pluginName; + private final File projectDir; + private final File appDir; + private final File appResourcesDir; + private final Map plugins; + private final Set capabilities; + + private Resolution( + Facts facts, + boolean pluginBuild, + String pluginName, + File projectDir, + File appDir, + File appResourcesDir, + Map plugins, + Set capabilities) { + this.facts = facts; + this.pluginBuild = pluginBuild; + this.pluginName = pluginName; + this.projectDir = projectDir; + this.appDir = appDir; + this.appResourcesDir = appResourcesDir; + this.plugins = plugins; + this.capabilities = capabilities; + } + + public static Resolution resolve(BuildInputs inputs) { + // The CLI passes -PtempBuild=true only to the throwaway project that builds a plugin's aar, + // whose template also sets PLUGIN_NAME. + boolean pluginBuild = inputs.property("tempBuild") != null; + String pluginName = pluginBuild ? asString(inputs.property("PLUGIN_NAME")) : null; + + File factsFile = locateFacts(inputs, pluginBuild); + Facts facts = factsFile != null ? Facts.read(factsFile) : null; + String staleness = facts != null ? facts.staleness() : null; + if (staleness != null) { + LOGGER.warn("Ignoring {}: {}. Using the fallback rules.", factsFile, staleness); + facts = null; + } + if (facts != null) { + return new Resolution( + facts, + pluginBuild, + pluginName, + facts.getProjectRoot(), + facts.getAppDir(), + facts.getAppResourcesDir(), + facts.getPlugins(), + facts.getCapabilities()); + } + return resolveLegacy(inputs, pluginBuild, pluginName); + } + + private static File locateFacts(BuildInputs inputs, boolean pluginBuild) { + String explicit = asString(inputs.property(FACTS_FILE_PROPERTY)); + if (explicit != null) { + File file = absolute(inputs.rootDir(), explicit); + if (!file.isFile()) { + throw new GradleException("-P" + FACTS_FILE_PROPERTY + " points to " + file + ", which does not exist."); + } + return file; + } + // Lets the app build find the facts without -P, as when Android Studio opens platforms/android. + File inRoot = new File(inputs.rootDir(), FACTS_FILE_NAME); + if (inRoot.isFile()) { + return inRoot.getAbsoluteFile(); + } + File platformsAndroid = explicitPlatformsAndroid(inputs, pluginBuild); + if (platformsAndroid != null) { + File file = new File(platformsAndroid, FACTS_FILE_NAME); + if (file.isFile()) { + return file; + } + } + return null; + } + + private static Resolution resolveLegacy(BuildInputs inputs, boolean pluginBuild, String pluginName) { + File rootDir = inputs.rootDir(); + File projectDir = legacyProjectDir(inputs, pluginBuild); + + // dependencies.json directories are relative to the folder holding it: the app's + // platforms/android, which is the app build's root but not the plugin build's. + File dependenciesBase; + if (pluginBuild) { + File platformsAndroid = explicitPlatformsAndroid(inputs, true); + dependenciesBase = platformsAndroid != null ? platformsAndroid : normalize(new File(projectDir, PLATFORMS_ANDROID)); + } else { + dependenciesBase = normalize(rootDir); + } + Map plugins = readDependenciesJson(new File(dependenciesBase, "dependencies.json")); + + NsConfig nsConfig = new NsConfig(new File(projectDir, "nsconfig.json")); + String appPath = asString(inputs.property("appPath")); + if (appPath == null) { + appPath = nsConfig.get("appPath"); + } + File appDir = absolute(projectDir, appPath != null ? appPath : "app"); + + String appResourcesPath = asString(inputs.property("appResourcesPath")); + if (appResourcesPath == null) { + appResourcesPath = nsConfig.get("appResourcesPath"); + } + File appResourcesDir = appResourcesPath != null + ? absolute(projectDir, appResourcesPath) + : normalize(new File(appDir, "App_Resources")); + + return new Resolution( + null, pluginBuild, pluginName, projectDir, appDir, appResourcesDir, plugins, Collections.emptySet()); + } + + private static File legacyProjectDir(BuildInputs inputs, boolean pluginBuild) { + File rootDir = inputs.rootDir(); + String value = asString(inputs.property("USER_PROJECT_ROOT")); + if (value == null && pluginBuild) { + value = inputs.environmentVariable("USER_PROJECT_ROOT"); + } + if (value != null) { + return absolute(rootDir, value); + } + // The templates' own defaults: platforms/android, and platforms/tempPlugin/. + return normalize(new File(rootDir, pluginBuild ? "../../.." : "../..")); + } + + /** USER_PROJECT_PLATFORMS_ANDROID, which only the plugin build template defines. */ + private static File explicitPlatformsAndroid(BuildInputs inputs, boolean pluginBuild) { + String value = asString(inputs.property("USER_PROJECT_PLATFORMS_ANDROID")); + if (value == null && pluginBuild) { + value = inputs.environmentVariable("USER_PROJECT_PLATFORMS_ANDROID"); + } + return value != null ? absolute(inputs.rootDir(), value) : null; + } + + private static Map readDependenciesJson(File file) { + if (!file.isFile()) { + return Collections.emptyMap(); + } + Object root; + try { + root = Json.parse(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new GradleException("Could not read " + file + ": " + e, e); + } catch (Json.ParseException e) { + throw new GradleException("Invalid " + file + ": " + e.getMessage(), e); + } + if (!(root instanceof List)) { + throw new GradleException("Invalid " + file + ": expected an array"); + } + File base = file.getAbsoluteFile().getParentFile(); + Map plugins = new LinkedHashMap<>(); + for (Object item : (List) root) { + if (!(item instanceof Map)) { + throw new GradleException("Invalid " + file + ": every entry must be an object"); + } + Map entry = (Map) item; + if (!(entry.get("name") instanceof String) || !(entry.get("directory") instanceof String)) { + throw new GradleException("Invalid " + file + ": every entry needs a string name and directory"); + } + String name = (String) entry.get("name"); + File packageDir = absolute(base, (String) entry.get("directory")); + List dependencies = new ArrayList<>(); + if (entry.get("dependencies") instanceof List) { + for (Object dependency : (List) entry.get("dependencies")) { + if (dependency instanceof String) { + dependencies.add((String) dependency); + } + } + } + plugins.putIfAbsent(name, new PluginData( + name, + packageDir, + normalize(new File(packageDir, PLATFORMS_ANDROID)), + null, + null, + dependencies, + Collections.emptyMap())); + } + return Collections.unmodifiableMap(plugins); + } + + /** Null in legacy mode. */ + public Facts getFacts() { + return facts; + } + + public boolean isPluginBuild() { + return pluginBuild; + } + + /** The plugin whose aar this build makes; null outside plugin builds. */ + public String getPluginName() { + return pluginName; + } + + public File getProjectDir() { + return projectDir; + } + + public File getAppDir() { + return appDir; + } + + public File getAppResourcesDir() { + return appResourcesDir; + } + + public Set getCapabilities() { + return capabilities; + } + + /** The plugins the CLI listed, by npm name. */ + public Map getPlugins() { + return plugins; + } + + /** + * A plugin the CLI listed, else an installed package that has a {@code platforms/android} + * directory. The fallback applies with and without facts, so that a plugin's Gradle code gets the + * same answers from every CLI: neither list need contain every package a plugin build compiles + * against. + */ + public PluginData findPlugin(String name) { + NodePackages.validate(name); + PluginData listed = plugins.get(name); + if (listed != null) { + return listed; + } + File packageDir = NodePackages.find(projectDir, name); + if (packageDir == null) { + return null; + } + File nativeDir = normalize(new File(packageDir, PLATFORMS_ANDROID)); + if (!nativeDir.isDirectory()) { + return null; + } + return new PluginData( + name, + packageDir, + nativeDir, + null, + null, + Collections.emptyList(), + Collections.emptyMap()); + } + + /** The installed package's directory, null if not found. */ + public File findPackage(String name) { + NodePackages.validate(name); + PluginData listed = plugins.get(name); + if (listed != null) { + return listed.getPackageDir(); + } + return NodePackages.find(projectDir, name); + } + + private static String asString(Object value) { + if (value == null) { + return null; + } + String string = value.toString(); + return string.isEmpty() ? null : string; + } + + private static File absolute(File base, String path) { + return base.toPath().resolve(path).toAbsolutePath().normalize().toFile(); + } + + private static File normalize(File file) { + return file.toPath().toAbsolutePath().normalize().toFile(); + } + + /** The nsconfig.json the templates fall back to, read only if needed. */ + private static final class NsConfig { + private final File file; + private Map values; + + NsConfig(File file) { + this.file = file; + } + + String get(String key) { + if (values == null) { + values = read(); + } + Object value = values.get(key); + return value instanceof String && !((String) value).isEmpty() ? (String) value : null; + } + + private Map read() { + if (!file.isFile()) { + return Collections.emptyMap(); + } + try { + Object root = Json.parse(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)); + return root instanceof Map ? (Map) root : Collections.emptyMap(); + } catch (IOException e) { + throw new GradleException("Could not read " + file + ": " + e, e); + } catch (Json.ParseException e) { + throw new GradleException("Invalid " + file + ": " + e.getMessage(), e); + } + } + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/package-info.java b/gradle-api/src/main/java/org/nativescript/gradle/package-info.java new file mode 100644 index 0000000000..41731116a5 --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/package-info.java @@ -0,0 +1,28 @@ +/** + * Gradle API for NativeScript plugins. + * + *

A plugin puts the library on the build's classpath from its {@code + * platforms/android/buildscript.gradle}: + * + *

{@code
+ * repositories { mavenCentral() }
+ * dependencies { classpath "org.nativescript:gradle-api:" }
+ * }
+ * + *

and applies it at the top of its {@code platforms/android/include.gradle}: + * + *

{@code
+ * apply plugin: "org.nativescript.api"
+ * }
+ * + *

The {@link org.nativescript.gradle.NativeScriptExtension nativescript} extension then answers + * questions about the project. {@code include.gradle} cannot see the library's classes (see {@link + * org.nativescript.gradle.NativeScript}), so it uses the extension dynamically: + * + *

{@code
+ * def ns = project.extensions.getByName("nativescript")
+ * }
+ * + *

Everything outside this package is internal. + */ +package org.nativescript.gradle; diff --git a/gradle-api/src/test/java/org/nativescript/gradle/internal/FactsTest.java b/gradle-api/src/test/java/org/nativescript/gradle/internal/FactsTest.java new file mode 100644 index 0000000000..c2ffdc200b --- /dev/null +++ b/gradle-api/src/test/java/org/nativescript/gradle/internal/FactsTest.java @@ -0,0 +1,476 @@ +package org.nativescript.gradle.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; +import org.gradle.api.GradleException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class FactsTest { + @TempDir + Path tempDir; + + private Path projectDir; + private Path platformsAndroid; + private File factsFile; + + @BeforeEach + void setUp() throws IOException { + projectDir = tempDir.toRealPath(); + platformsAndroid = projectDir.resolve("platforms/android"); + Files.createDirectories(platformsAndroid); + factsFile = platformsAndroid.resolve("nativescript-gradle.json").toFile(); + } + + @Test + void readsBasicFacts() throws IOException { + copyResource("/facts/v1-basic.json", factsFile.toPath()); + + Facts facts = Facts.read(factsFile); + + assertEquals(factsFile, facts.getFile()); + assertEquals(1, facts.getSchemaVersion()); + assertTrue(facts.getCapabilities().isEmpty()); + assertEquals(projectDir.toFile(), facts.getProjectRoot()); + assertEquals(projectDir.resolve("src").toFile(), facts.getAppDir()); + assertEquals(projectDir.resolve("App_Resources").toFile(), facts.getAppResourcesDir()); + assertEquals( + Arrays.asList("@nativescript/core", "@foo/plugin-x", "@bar/plugin-x"), + new ArrayList<>(facts.getPlugins().keySet())); + + PluginData core = facts.getPlugins().get("@nativescript/core"); + assertEquals("@nativescript/core", core.getName()); + assertEquals(projectDir.resolve("node_modules/@nativescript/core").toFile(), core.getPackageDir()); + assertEquals( + projectDir.resolve("node_modules/@nativescript/core/platforms/android").toFile(), core.getNativeDir()); + assertEquals("core", core.getAarName()); + assertEquals( + projectDir.resolve("node_modules/@nativescript/core/platforms/android/core.aar").toFile(), core.getAar()); + assertEquals(Collections.emptyList(), core.getDependencies()); + + PluginData x = facts.getPlugins().get("@foo/plugin-x"); + assertEquals(projectDir.resolve("node_modules/@foo/plugin-x").toFile(), x.getPackageDir()); + assertEquals(platformsAndroid.resolve(".ns-plugins/foo_plugin_x/platforms/android").toFile(), x.getNativeDir()); + assertEquals("foo_plugin_x", x.getAarName()); + assertEquals( + platformsAndroid.resolve(".ns-plugins/foo_plugin_x/platforms/android/foo_plugin_x.aar").toFile(), + x.getAar()); + assertEquals(Collections.singletonList("@nativescript/core"), x.getDependencies()); + assertTrue(x.getOptions().isEmpty()); + + assertThrows(UnsupportedOperationException.class, () -> x.getOptions().put("k", "v")); + assertThrows(UnsupportedOperationException.class, () -> x.getDependencies().add("y")); + assertThrows(UnsupportedOperationException.class, () -> facts.getPlugins().remove("@foo/plugin-x")); + assertThrows(UnsupportedOperationException.class, () -> facts.getCapabilities().add("c")); + } + + @Test + void readsNewerSchemaVersionAndIgnoresUnknownFields() throws IOException { + copyResource("/facts/v2-unknown-fields.json", factsFile.toPath()); + + Facts facts = Facts.read(factsFile); + + assertEquals(2, facts.getSchemaVersion()); + assertEquals(Collections.singleton("some.future.capability"), facts.getCapabilities()); + assertEquals(projectDir.toFile(), facts.getProjectRoot()); + assertEquals(projectDir.resolve("app").toFile(), facts.getAppDir()); + assertEquals(projectDir.resolve("app/App_Resources").toFile(), facts.getAppResourcesDir()); + assertEquals(Collections.singletonList("nativescript-plugin"), new ArrayList<>(facts.getPlugins().keySet())); + PluginData plugin = facts.getPlugins().get("nativescript-plugin"); + assertEquals("nativescript_plugin", plugin.getAarName()); + assertEquals(Collections.singletonMap("someOption", Arrays.asList("a", "b")), plugin.getOptions()); + } + + @Test + void readsFactsFromResourceInPlace() throws Exception { + File resource = new File(getClass().getResource("/facts/v1-basic.json").toURI()); + + Facts facts = Facts.read(resource); + + Path dir = resource.getAbsoluteFile().toPath().getParent(); + assertEquals(dir.resolve("../..").normalize().toFile(), facts.getProjectRoot()); + assertEquals(3, facts.getPlugins().size()); + } + + @Test + void optionsDefaultToEmptyWhenAbsent() { + Map plugin = pluginFields("p"); + plugin.remove("options"); + + Facts facts = parse(document(PROJECT, "[" + object(plugin) + "]")); + + assertTrue(facts.getPlugins().get("p").getOptions().isEmpty()); + assertTrue(facts.getCapabilities().isEmpty()); + } + + @Test + void keepsAbsolutePathsAbsolute() { + Path elsewhere = projectDir.resolve("elsewhere"); + String project = "{\"root\": " + quote(elsewhere) + ", \"appDir\": " + quote(elsewhere.resolve("app")) + + ", \"appResourcesDir\": " + quote(elsewhere.resolve("res")) + "}"; + Map plugin = pluginFields("p"); + plugin.put("nativeDir", quote(elsewhere.resolve("native"))); + + Facts facts = parse(document(project, "[" + object(plugin) + "]")); + + assertEquals(elsewhere.toFile(), facts.getProjectRoot()); + assertEquals(elsewhere.resolve("app").toFile(), facts.getAppDir()); + assertEquals(elsewhere.resolve("res").toFile(), facts.getAppResourcesDir()); + assertEquals(elsewhere.resolve("native").toFile(), facts.getPlugins().get("p").getNativeDir()); + } + + @Test + void normalizesRelativePaths() { + String project = + "{\"root\": \"./a/../../..\", \"appDir\": \"../../x/./y/../app\", \"appResourcesDir\": \"res/\"}"; + + Facts facts = parse(document(project, "[]")); + + assertEquals(projectDir.toFile(), facts.getProjectRoot()); + assertEquals(projectDir.resolve("x/app").toFile(), facts.getAppDir()); + assertEquals(platformsAndroid.resolve("res").toFile(), facts.getAppResourcesDir()); + } + + @Test + void acceptsBomAndCapabilities() { + Facts facts = parse("{\"schemaVersion\": 1, \"capabilities\": [\"a\", \"b\"], \"project\": " + PROJECT + + ", \"plugins\": []}"); + + assertEquals(Arrays.asList("a", "b"), new ArrayList<>(facts.getCapabilities())); + } + + @Test + void reportsMissingSchemaVersion() { + assertInvalid("{\"project\": " + PROJECT + ", \"plugins\": []}", "schemaVersion must be an integer"); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "-1"}) + void reportsSchemaVersionBelowOne(String version) { + assertInvalid(document(version, PROJECT, "[]"), "schemaVersion must be at least 1"); + } + + @ParameterizedTest + @ValueSource(strings = {"\"1\"", "1.5", "null", "true", "2147483648"}) + void reportsNonIntegerSchemaVersion(String version) { + assertInvalid(document(version, PROJECT, "[]"), "schemaVersion must be an integer"); + } + + @ParameterizedTest + @ValueSource(strings = {"[]", "1", "\"facts\"", "null"}) + void reportsNonObjectTopLevel(String text) { + assertInvalid(text, "the top level must be an object"); + } + + @Test + void reportsMissingProject() { + assertInvalid("{\"schemaVersion\": 1, \"plugins\": []}", "project must be an object"); + } + + @Test + void reportsNonObjectProject() { + assertInvalid(document("\"..\"", "[]"), "project must be an object"); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "{\"root\": 1, \"appDir\": \"a\", \"appResourcesDir\": \"r\"}|project.root must be a string", + "{\"root\": \"\", \"appDir\": \"a\", \"appResourcesDir\": \"r\"}|project.root must not be empty", + "{\"appDir\": \"a\", \"appResourcesDir\": \"r\"}|project.root must be a string", + "{\"root\": \".\", \"appResourcesDir\": \"r\"}|project.appDir must be a string", + "{\"root\": \".\", \"appDir\": \"a\"}|project.appResourcesDir must be a string", + "{\"root\": \".\", \"appDir\": \"a\", \"appResourcesDir\": \"\"}|project.appResourcesDir must not be empty", + }) + void reportsInvalidProjectFields(String project, String problem) { + assertInvalid(document(project, "[]"), problem); + } + + @ParameterizedTest + @ValueSource(strings = {"{}", "\"p\"", "null"}) + void reportsNonArrayPlugins(String plugins) { + assertInvalid(document(PROJECT, plugins), "plugins must be an array"); + } + + @Test + void reportsMissingPlugins() { + assertInvalid("{\"schemaVersion\": 1, \"project\": " + PROJECT + "}", "plugins must be an array"); + } + + @Test + void reportsNonObjectPlugin() { + assertInvalid(document(PROJECT, "[" + object(pluginFields("a")) + ", \"b\"]"), "plugins[1] must be an object"); + } + + @ParameterizedTest + @CsvSource({ + "name, plugins[0].name must be a string", + "packageDir, plugins[0].packageDir must be a string", + "nativeDir, plugins[0].nativeDir must be a string", + "aarName, plugins[0].aarName must be a string", + "aar, plugins[0].aar must be a string", + "dependencies, plugins[0].dependencies must be an array", + }) + void reportsMissingPluginField(String field, String problem) { + Map plugin = pluginFields("p"); + plugin.remove(field); + assertInvalid(document(PROJECT, "[" + object(plugin) + "]"), problem); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "name|\"\"|plugins[0].name must not be empty", + "aarName|\"\"|plugins[0].aarName must not be empty", + "nativeDir|\"\"|plugins[0].nativeDir must not be empty", + "nativeDir|3|plugins[0].nativeDir must be a string", + "dependencies|[\"a\", 1]|plugins[0].dependencies[1] must be a string", + "dependencies|\"a\"|plugins[0].dependencies must be an array", + "options|[]|plugins[0].options must be an object", + "options|null|plugins[0].options must be an object", + }) + void reportsInvalidPluginField(String field, String value, String problem) { + Map plugin = pluginFields("p"); + plugin.put(field, value); + assertInvalid(document(PROJECT, "[" + object(plugin) + "]"), problem); + } + + @Test + void reportsNonArrayCapabilities() { + assertInvalid( + "{\"schemaVersion\": 1, \"capabilities\": \"a\", \"project\": " + PROJECT + ", \"plugins\": []}", + "capabilities must be an array"); + } + + @Test + void reportsNonStringCapability() { + assertInvalid( + "{\"schemaVersion\": 1, \"capabilities\": [\"a\", 1], \"project\": " + PROJECT + ", \"plugins\": []}", + "capabilities[1] must be a string"); + } + + @Test + void reportsDuplicatePluginName() { + String plugins = "[" + object(pluginFields("a")) + ", " + object(pluginFields("b")) + ", " + + object(pluginFields("a")) + "]"; + assertInvalid(document(PROJECT, plugins), "plugin 'a' is listed more than once"); + } + + @Test + void reportsInvalidJsonWithPosition() { + GradleException e = assertThrows( + GradleException.class, () -> Facts.parse(factsFile, "{\n \"schemaVersion\": 1,\n}")); + assertTrue(e.getMessage().startsWith("Invalid NativeScript facts file " + factsFile + ": "), e.getMessage()); + assertTrue(e.getMessage().contains("expected a string key at line 3, column 1"), e.getMessage()); + } + + @Test + void readReusesResultWhileFileIsUnchanged() throws IOException { + write(factsFile, minimal("\"a\"")); + + Facts first = Facts.read(factsFile); + + assertSame(first, Facts.read(factsFile)); + assertSame(first, Facts.read(new File(factsFile.getPath()))); + } + + @Test + void readReparsesWhenLengthChanges() throws IOException { + write(factsFile, minimal("\"a\"")); + Facts first = Facts.read(factsFile); + long lastModified = factsFile.lastModified(); + + write(factsFile, minimal("\"a\", \"longer\"")); + assertTrue(factsFile.setLastModified(lastModified)); + + Facts second = Facts.read(factsFile); + assertNotSame(first, second); + assertEquals(Arrays.asList("a", "longer"), new ArrayList<>(second.getCapabilities())); + } + + @Test + void readReparsesWhenContentChangesWithSameLengthAndModificationTime() throws IOException { + write(factsFile, minimal("\"a\"")); + Facts first = Facts.read(factsFile); + long lastModified = factsFile.lastModified(); + + write(factsFile, minimal("\"b\"")); + assertTrue(factsFile.setLastModified(lastModified)); + + Facts second = Facts.read(factsFile); + assertNotSame(first, second); + assertEquals(Collections.singleton("b"), second.getCapabilities()); + } + + @Test + void readReusesResultWhenOnlyModificationTimeChanges() throws IOException { + write(factsFile, minimal("\"a\"")); + Facts first = Facts.read(factsFile); + + assertTrue(factsFile.setLastModified(factsFile.lastModified() + 10_000)); + + assertSame(first, Facts.read(factsFile)); + } + + @Test + void readReportsMissingFile() { + File missing = platformsAndroid.resolve("missing.json").toFile(); + GradleException e = assertThrows(GradleException.class, () -> Facts.read(missing)); + assertTrue(e.getMessage().startsWith("Could not read NativeScript facts file " + missing), e.getMessage()); + } + + @Test + void readReportsInvalidFacts() throws IOException { + write(factsFile, "{\"schemaVersion\": 1}"); + GradleException e = assertThrows(GradleException.class, () -> Facts.read(factsFile)); + assertEquals("Invalid NativeScript facts file " + factsFile + ": project must be an object", e.getMessage()); + } + + private static final String PROJECT = + "{\"root\": \"../..\", \"appDir\": \"../../app\", \"appResourcesDir\": \"../../app/App_Resources\"}"; + + private static Map pluginFields(String name) { + Map fields = new LinkedHashMap<>(); + fields.put("name", "\"" + name + "\""); + fields.put("packageDir", "\"../../node_modules/" + name + "\""); + fields.put("nativeDir", "\"../../node_modules/" + name + "/platforms/android\""); + fields.put("aarName", "\"" + name + "\""); + fields.put("aar", "\"../../node_modules/" + name + "/platforms/android/" + name + ".aar\""); + fields.put("dependencies", "[]"); + fields.put("options", "{}"); + return fields; + } + + private static String object(Map fields) { + return fields.entrySet().stream() + .map(e -> "\"" + e.getKey() + "\": " + e.getValue()) + .collect(Collectors.joining(", ", "{", "}")); + } + + private static String document(String project, String plugins) { + return document("1", project, plugins); + } + + private static final String EMPTY_ARRAY_SHA256 = "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570"; + + @Test + void sha256IsLowercaseHex() { + assertEquals(EMPTY_ARRAY_SHA256, Facts.sha256("[]\n".getBytes(StandardCharsets.UTF_8))); + } + + @Test + void readsDependenciesJsonHash() throws IOException { + copyResource("/facts/v1-basic.json", factsFile.toPath()); + + assertEquals(EMPTY_ARRAY_SHA256, Facts.read(factsFile).getDependenciesJsonSha256()); + } + + @Test + void toleratesMissingDependenciesJsonHash() { + assertEquals(null, parse(minimal("")).getDependenciesJsonSha256()); + } + + @ParameterizedTest + @ValueSource(strings = { + "\"x\"", + "{}", + "{\"sha256\": 1}", + "{\"sha256\": \"37517E5F3DC66819F61F5A7BB8ACE1921282415F10551D2DEFA5C3EB0985B570\"}", + "{\"sha256\": \"37517e5f\"}" + }) + void rejectsMalformedDependenciesJsonHash(String value) { + GradleException e = assertThrows(GradleException.class, () -> parse(withHash(value))); + assertTrue(e.getMessage().startsWith("Invalid NativeScript facts file " + factsFile + ": dependenciesJson"), + e.getMessage()); + } + + @Test + void currentWhenDependenciesJsonMatches() throws IOException { + write(platformsAndroid.resolve("dependencies.json").toFile(), "[]\n"); + + assertEquals(null, parse(withHash("{\"sha256\": \"" + EMPTY_ARRAY_SHA256 + "\"}")).staleness()); + } + + @Test + void staleWhenDependenciesJsonDiffers() throws IOException { + File dependenciesJson = platformsAndroid.resolve("dependencies.json").toFile(); + write(dependenciesJson, "[ ]\n"); + + assertEquals("it does not match " + dependenciesJson + ", which a different NativeScript CLI wrote", + parse(withHash("{\"sha256\": \"" + EMPTY_ARRAY_SHA256 + "\"}")).staleness()); + } + + @Test + void staleWhenDependenciesJsonIsMissing() { + File dependenciesJson = platformsAndroid.resolve("dependencies.json").toFile(); + + assertEquals("there is no " + dependenciesJson + " next to it", + parse(withHash("{\"sha256\": \"" + EMPTY_ARRAY_SHA256 + "\"}")).staleness()); + } + + @Test + void trustedWithoutHashWhateverDependenciesJsonHolds() throws IOException { + assertEquals(null, parse(minimal("")).staleness()); + write(platformsAndroid.resolve("dependencies.json").toFile(), "[{\"name\": \"other\"}]"); + assertEquals(null, parse(minimal("")).staleness()); + } + + private static String withHash(String dependenciesJson) { + return "{\"schemaVersion\": 1, \"dependenciesJson\": " + dependenciesJson + ", \"project\": " + PROJECT + + ", \"plugins\": []}"; + } + + private static String document(String schemaVersion, String project, String plugins) { + return "{\"schemaVersion\": " + schemaVersion + ", \"project\": " + project + ", \"plugins\": " + plugins + "}"; + } + + private static String minimal(String capabilities) { + return "{\"schemaVersion\": 1, \"capabilities\": [" + capabilities + "], \"project\": " + PROJECT + + ", \"plugins\": []}"; + } + + private static String quote(Path path) { + return "\"" + path.toString().replace("\\", "\\\\") + "\""; + } + + private Facts parse(String text) { + return Facts.parse(factsFile, text); + } + + private void assertInvalid(String text, String problem) { + GradleException e = assertThrows(GradleException.class, () -> parse(text)); + assertEquals("Invalid NativeScript facts file " + factsFile + ": " + problem, e.getMessage()); + } + + private static void write(File file, String text) throws IOException { + Files.write(file.toPath(), text.getBytes(StandardCharsets.UTF_8)); + } + + private void copyResource(String name, Path target) throws IOException { + try (InputStream in = getClass().getResourceAsStream(name)) { + Files.copy(in, target); + } + } +} diff --git a/gradle-api/src/test/java/org/nativescript/gradle/internal/JsonTest.java b/gradle-api/src/test/java/org/nativescript/gradle/internal/JsonTest.java new file mode 100644 index 0000000000..076279370c --- /dev/null +++ b/gradle-api/src/test/java/org/nativescript/gradle/internal/JsonTest.java @@ -0,0 +1,253 @@ +package org.nativescript.gradle.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class JsonTest { + + @Test + void parsesLiterals() { + assertEquals(Boolean.TRUE, Json.parse("true")); + assertEquals(Boolean.FALSE, Json.parse("false")); + assertNull(Json.parse("null")); + assertEquals("hello", Json.parse("\"hello\"")); + assertEquals("", Json.parse("\"\"")); + assertEquals(42, Json.parse("42")); + } + + @Test + void parsesEmptyContainers() { + assertEquals(Collections.emptyMap(), Json.parse("{}")); + assertEquals(Collections.emptyList(), Json.parse("[]")); + assertEquals(Collections.emptyMap(), Json.parse("{ \n }")); + assertEquals(Collections.emptyList(), Json.parse("[\t]")); + } + + @Test + void parsesNestedStructures() { + Object value = Json.parse( + "{\"a\": [1, {\"b\": null, \"c\": [true, false]}, \"s\"], \"d\": {\"e\": {}}, \"f\": [[]]}"); + + Map root = asMap(value); + assertEquals(Arrays.asList("a", "d", "f"), new ArrayList<>(root.keySet())); + List a = asList(root.get("a")); + assertEquals(3, a.size()); + assertEquals(1, a.get(0)); + Map inner = asMap(a.get(1)); + assertTrue(inner.containsKey("b")); + assertNull(inner.get("b")); + assertEquals(Arrays.asList(true, false), inner.get("c")); + assertEquals("s", a.get(2)); + assertEquals(Collections.emptyMap(), asMap(root.get("d")).get("e")); + assertEquals(Collections.singletonList(Collections.emptyList()), root.get("f")); + } + + @Test + void keepsObjectKeyOrder() { + Map map = asMap(Json.parse("{\"z\": 1, \"a\": 2, \"m\": 3}")); + assertEquals(Arrays.asList("z", "a", "m"), new ArrayList<>(map.keySet())); + } + + @Test + void repeatedKeyKeepsLastValueAtFirstPosition() { + Map map = asMap(Json.parse("{\"a\": 1, \"b\": 2, \"a\": 3}")); + assertEquals(3, map.get("a")); + assertEquals(Arrays.asList("a", "b"), new ArrayList<>(map.keySet())); + } + + @Test + void resultsAreUnmodifiable() { + Map map = asMap(Json.parse("{\"a\": [1], \"b\": {}}")); + assertThrows(UnsupportedOperationException.class, () -> map.put("c", 1)); + assertThrows(UnsupportedOperationException.class, () -> asList(map.get("a")).add(2)); + assertThrows(UnsupportedOperationException.class, () -> asMap(map.get("b")).put("x", 1)); + assertThrows(UnsupportedOperationException.class, () -> asMap(Json.parse("{}")).put("x", 1)); + assertThrows(UnsupportedOperationException.class, () -> asList(Json.parse("[]")).add(1)); + } + + @Test + void integersUseTheSmallestTypeThatFits() { + assertEquals(Integer.valueOf(0), Json.parse("0")); + assertEquals(Integer.valueOf(0), Json.parse("-0")); + assertEquals(Integer.valueOf(Integer.MAX_VALUE), Json.parse("2147483647")); + assertEquals(Integer.valueOf(Integer.MIN_VALUE), Json.parse("-2147483648")); + assertEquals(Long.valueOf(2147483648L), Json.parse("2147483648")); + assertEquals(Long.valueOf(-2147483649L), Json.parse("-2147483649")); + assertEquals(Long.valueOf(Long.MAX_VALUE), Json.parse("9223372036854775807")); + assertEquals(Long.valueOf(Long.MIN_VALUE), Json.parse("-9223372036854775808")); + assertEquals(new BigInteger("9223372036854775808"), Json.parse("9223372036854775808")); + assertEquals(new BigInteger("-9223372036854775809"), Json.parse("-9223372036854775809")); + assertEquals(new BigInteger("123456789012345678901234567890"), Json.parse("123456789012345678901234567890")); + } + + @Test + void fractionsAndExponentsAreBigDecimals() { + assertEquals(new BigDecimal("1.5"), Json.parse("1.5")); + assertEquals(new BigDecimal("-0.25"), Json.parse("-0.25")); + assertEquals(new BigDecimal("1e3"), Json.parse("1e3")); + assertEquals(new BigDecimal("3.5E+2"), Json.parse("3.5E+2")); + assertEquals(new BigDecimal("2e-2"), Json.parse("2e-2")); + assertInstanceOf(BigDecimal.class, Json.parse("1.0")); + assertInstanceOf(BigDecimal.class, Json.parse("-0.0")); + assertEquals(0, new BigDecimal("350").compareTo((BigDecimal) Json.parse("3.5E+2"))); + } + + @Test + void decodesEscapes() { + assertEquals("\"\\/\b\f\n\r\t", Json.parse("\"\\\"\\\\\\/\\b\\f\\n\\r\\t\"")); + assertEquals("\u00e9", Json.parse("\"\\u00e9\"")); + assertEquals("\u00e9", Json.parse("\"\\u00E9\"")); + assertEquals("\uD83D\uDE00", Json.parse("\"\\uD83D\\uDE00\"")); + assertEquals("a\u0000b", Json.parse("\"a\\u0000b\"")); + assertEquals("x\ny", Json.parse("\"x\\u000ay\"")); + } + + @Test + void passesThroughNonAsciiCharacters() { + assertEquals("caf\u00e9 \uD83D\uDE00 \u65e5\u672c", Json.parse("\"caf\u00e9 \uD83D\uDE00 \u65e5\u672c\"")); + } + + @Test + void skipsLeadingByteOrderMark() { + assertEquals(Collections.emptyMap(), Json.parse("\uFEFF{}")); + assertEquals(1, Json.parse("\uFEFF 1 ")); + } + + @Test + void byteOrderMarkIsOnlySkippedAtTheStart() { + Json.ParseException e = assertThrows(Json.ParseException.class, () -> Json.parse(" \uFEFF1")); + assertEquals(1, e.getOffset()); + } + + @Test + void skipsJsonWhitespace() { + assertEquals(Arrays.asList(1, 2), Json.parse(" \t\r\n[ 1 ,\n\t2\r\n]\n ")); + assertEquals(Collections.singletonMap("a", "b"), Json.parse("{\n\t\"a\"\r\n :\t\"b\" }")); + } + + @ParameterizedTest + @ValueSource(strings = {"\f1", "\u00a01", "1\u000b"}) + void rejectsNonJsonWhitespace(String text) { + assertThrows(Json.ParseException.class, () -> Json.parse(text)); + } + + @Test + void acceptsNestingOf512Levels() { + String text = "[".repeat(512) + "]".repeat(512); + Object value = Json.parse(text); + for (int i = 0; i < 511; i++) { + value = asList(value).get(0); + } + assertEquals(Collections.emptyList(), value); + } + + @Test + void rejectsNestingOf513Levels() { + Json.ParseException e = assertThrows( + Json.ParseException.class, () -> Json.parse("[".repeat(513) + "]".repeat(513))); + assertEquals(512, e.getOffset()); + assertTrue(e.getMessage().startsWith("Nesting deeper than 512 levels"), e.getMessage()); + } + + @Test + void depthCountsObjectsAndArraysTogether() { + StringBuilder ok = new StringBuilder(); + for (int i = 0; i < 256; i++) { + ok.append("{\"a\":["); + } + ok.append("]}".repeat(256)); + Json.parse(ok.toString()); + + Json.ParseException e = assertThrows(Json.ParseException.class, () -> Json.parse("[" + ok + "]")); + assertTrue(e.getMessage().startsWith("Nesting deeper"), e.getMessage()); + } + + static Stream invalidDocuments() { + return Stream.of( + Arguments.of("trailing comma in object", "{\"a\":1,}", 7, 1, 8, "expected a string key"), + Arguments.of("trailing comma in array", "[1,]", 3, 1, 4, "expected a value"), + Arguments.of("leading zero", "01", 1, 1, 2, "Leading zeros"), + Arguments.of("negative leading zero", "-01", 2, 1, 3, "Leading zeros"), + Arguments.of("no digit after dot", "1.", 2, 1, 3, "after '.'"), + Arguments.of("no exponent digit", "1e", 2, 1, 3, "exponent"), + Arguments.of("no exponent digit after sign", "1e+", 3, 1, 4, "exponent"), + Arguments.of("lone minus", "-", 1, 1, 2, "expected a digit"), + Arguments.of("leading plus", "+1", 0, 1, 1, "expected a value"), + Arguments.of("leading dot", ".5", 0, 1, 1, "expected a value"), + Arguments.of("unterminated string", "\"abc", 4, 1, 5, "Unterminated string"), + Arguments.of("unterminated after backslash", "\"abc\\", 5, 1, 6, "Unterminated string"), + Arguments.of("raw tab in string", "\"a\tb\"", 2, 1, 3, "U+0009"), + Arguments.of("raw newline in string", "\"a\nb\"", 2, 1, 3, "U+000A"), + Arguments.of("invalid escape", "\"\\x\"", 2, 1, 3, "Invalid escape sequence '\\x'"), + Arguments.of("bad hex digit", "\"\\u12G4\"", 5, 1, 6, "Invalid hex digit character 'G'"), + Arguments.of("single quotes", "'a'", 0, 1, 1, "character '''"), + Arguments.of("NaN", "NaN", 0, 1, 1, "expected a value"), + Arguments.of("Infinity", "-Infinity", 1, 1, 2, "expected a digit"), + Arguments.of("truncated literal", "tru", 3, 1, 4, "expected 'true'"), + Arguments.of("misspelled literal", "nul1", 3, 1, 4, "expected 'null'"), + Arguments.of("trailing garbage", "{} x", 3, 1, 4, "after the JSON value"), + Arguments.of("two values", "1 2", 2, 1, 3, "after the JSON value"), + Arguments.of("empty input", "", 0, 1, 1, "end of input"), + Arguments.of("whitespace only", " \n ", 4, 2, 2, "end of input"), + Arguments.of("missing colon", "{\"a\" 1}", 5, 1, 6, "expected ':'"), + Arguments.of("unquoted key", "{a:1}", 1, 1, 2, "expected a string key"), + Arguments.of("missing comma in array", "[1 2]", 3, 1, 4, "expected ',' or ']'"), + Arguments.of("missing comma in object", "{\"a\":1 \"b\":2}", 7, 1, 8, "expected ',' or '}'"), + Arguments.of("unclosed array", "[1", 2, 1, 3, "end of input"), + Arguments.of("unclosed object", "{\"a\":1", 6, 1, 7, "end of input"), + Arguments.of("comment", "[1] // no", 4, 1, 5, "after the JSON value"), + Arguments.of("multi-line", "{\n \"a\": 1,\n \"b\": x\n}", 19, 3, 8, "character 'x'"), + Arguments.of("CRLF line ending", "[\r\n x]", 4, 2, 2, "character 'x'")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidDocuments") + void reportsWhereInvalidJsonFails( + String description, String text, int offset, int line, int column, String messagePart) { + Json.ParseException e = assertThrows(Json.ParseException.class, () -> Json.parse(text)); + assertEquals(offset, e.getOffset(), "offset"); + assertEquals(line, e.getLine(), "line"); + assertEquals(column, e.getColumn(), "column"); + assertTrue(e.getMessage().contains(messagePart), e.getMessage()); + assertTrue(e.getMessage().endsWith(" at line " + line + ", column " + column), e.getMessage()); + } + + @Test + void rejectsIncompleteUnicodeEscape() { + Json.ParseException e = assertThrows(Json.ParseException.class, () -> Json.parse("\"\\u12\"")); + assertTrue(e.getMessage().startsWith("Incomplete \\u escape"), e.getMessage()); + } + + @Test + void describesControlCharactersByCodePoint() { + Json.ParseException e = assertThrows(Json.ParseException.class, () -> Json.parse("[1\u0001]")); + assertTrue(e.getMessage().contains("character U+0001"), e.getMessage()); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object value) { + return (Map) assertInstanceOf(Map.class, value); + } + + @SuppressWarnings("unchecked") + private static List asList(Object value) { + return (List) assertInstanceOf(List.class, value); + } +} diff --git a/gradle-api/src/test/java/org/nativescript/gradle/internal/ResolutionTest.java b/gradle-api/src/test/java/org/nativescript/gradle/internal/ResolutionTest.java new file mode 100644 index 0000000000..fa36c1520c --- /dev/null +++ b/gradle-api/src/test/java/org/nativescript/gradle/internal/ResolutionTest.java @@ -0,0 +1,733 @@ +package org.nativescript.gradle.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.gradle.api.GradleException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ResolutionTest { + @TempDir + Path tempDir; + + /** Real path of the temp dir, which on macOS sits behind the /var symlink. */ + private Path base; + + private Path project; + private Path platformsAndroid; + private Path tempPluginRoot; + + @BeforeEach + void setUp() throws IOException { + base = tempDir.toRealPath(); + project = base.resolve("proj"); + platformsAndroid = project.resolve("platforms/android"); + tempPluginRoot = project.resolve("platforms/tempPlugin/p"); + Files.createDirectories(platformsAndroid); + Files.createDirectories(tempPluginRoot); + } + + @Nested + class LegacyAppBuild { + @Test + void resolvesFromTemplateInputs() throws IOException { + write(platformsAndroid.resolve("dependencies.json"), "[" + + "{\"name\": \"@scope/a\", \"directory\": \"../../node_modules/@scope/a\", \"dependencies\": []}," + + "{\"name\": \"b\", \"directory\": \"../../node_modules/b\", \"dependencies\": [\"@scope/a\"]}" + + "]"); + FakeInputs inputs = appBuild().property("USER_PROJECT_ROOT", platformsAndroid + "/../.."); + + Resolution resolution = Resolution.resolve(inputs); + + assertNull(resolution.getFacts()); + assertFalse(resolution.isPluginBuild()); + assertNull(resolution.getPluginName()); + assertEquals(project.toFile(), resolution.getProjectDir()); + assertEquals(project.resolve("app").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("app/App_Resources").toFile(), resolution.getAppResourcesDir()); + assertTrue(resolution.getCapabilities().isEmpty()); + assertEquals(Arrays.asList("@scope/a", "b"), new ArrayList<>(resolution.getPlugins().keySet())); + + PluginData a = resolution.getPlugins().get("@scope/a"); + assertEquals("@scope/a", a.getName()); + assertEquals(project.resolve("node_modules/@scope/a").toFile(), a.getPackageDir()); + assertEquals(project.resolve("node_modules/@scope/a/platforms/android").toFile(), a.getNativeDir()); + assertNull(a.getAarName()); + assertNull(a.getAar()); + assertTrue(a.getOptions().isEmpty()); + assertEquals(Collections.emptyList(), a.getDependencies()); + assertEquals(Collections.singletonList("@scope/a"), resolution.getPlugins().get("b").getDependencies()); + } + + @Test + void defaultsProjectDirToTwoLevelsUp() { + assertEquals(project.toFile(), Resolution.resolve(appBuild()).getProjectDir()); + } + + @Test + void ignoresEnvironmentVariables() throws IOException { + Path elsewhere = Files.createDirectories(base.resolve("elsewhere")); + writeFacts(elsewhere.resolve(Resolution.FACTS_FILE_NAME), elsewhere, "[]"); + FakeInputs inputs = appBuild() + .env("USER_PROJECT_ROOT", elsewhere.toString()) + .env("USER_PROJECT_PLATFORMS_ANDROID", elsewhere.toString()); + + Resolution resolution = Resolution.resolve(inputs); + + assertNull(resolution.getFacts()); + assertEquals(project.toFile(), resolution.getProjectDir()); + } + + @Test + void ignoresPluginNameOutsidePluginBuilds() { + assertNull(Resolution.resolve(appBuild().property("PLUGIN_NAME", "x")).getPluginName()); + } + + @Test + void resolvesRelativeAppPathProperty() { + Resolution resolution = Resolution.resolve(appBuild().property("appPath", "src")); + + assertEquals(project.resolve("src").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("src/App_Resources").toFile(), resolution.getAppResourcesDir()); + } + + @Test + void keepsAbsoluteAppPathProperty() { + Path app = base.resolve("other/app"); + + Resolution resolution = Resolution.resolve(appBuild().property("appPath", app.toString())); + + assertEquals(app.toFile(), resolution.getAppDir()); + assertEquals(app.resolve("App_Resources").toFile(), resolution.getAppResourcesDir()); + } + + // The Android template stores appPath in ext as a java.nio.file.Path, not a String. + @Test + void acceptsPathObjectsAsProperties() { + Path app = base.resolve("other/app"); + + Resolution resolution = Resolution.resolve( + appBuild().property("appPath", app).property("appResourcesPath", Path.of("res"))); + + assertEquals(app.toFile(), resolution.getAppDir()); + assertEquals(project.resolve("res").toFile(), resolution.getAppResourcesDir()); + } + + @Test + void resolvesAppResourcesPathProperty() { + assertEquals( + project.resolve("res").toFile(), + Resolution.resolve(appBuild().property("appResourcesPath", "res")).getAppResourcesDir()); + Path absolute = base.resolve("abs/res"); + assertEquals( + absolute.toFile(), + Resolution.resolve(appBuild().property("appResourcesPath", absolute.toString())) + .getAppResourcesDir()); + } + + @Test + void readsPathsFromNsConfig() throws IOException { + write(project.resolve("nsconfig.json"), "{\"appPath\": \"source\", \"appResourcesPath\": \"resources\"}"); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertEquals(project.resolve("source").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("resources").toFile(), resolution.getAppResourcesDir()); + } + + @Test + void derivesAppResourcesFromNsConfigAppPath() throws IOException { + write(project.resolve("nsconfig.json"), "{\"appPath\": \"source\"}"); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertEquals(project.resolve("source").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("source/App_Resources").toFile(), resolution.getAppResourcesDir()); + } + + @Test + void propertiesWinOverNsConfig() throws IOException { + write(project.resolve("nsconfig.json"), "{\"appPath\": \"source\", \"appResourcesPath\": \"resources\"}"); + + Resolution resolution = + Resolution.resolve(appBuild().property("appPath", "src").property("appResourcesPath", "res")); + + assertEquals(project.resolve("src").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("res").toFile(), resolution.getAppResourcesDir()); + } + + @Test + void treatsEmptyPropertiesAndNsConfigValuesAsAbsent() throws IOException { + write(project.resolve("nsconfig.json"), "{\"appPath\": \"\", \"appResourcesPath\": 5}"); + + Resolution resolution = Resolution.resolve(appBuild().property("appPath", "")); + + assertEquals(project.resolve("app").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("app/App_Resources").toFile(), resolution.getAppResourcesDir()); + } + + @Test + void reportsInvalidNsConfig() throws IOException { + Path nsconfig = project.resolve("nsconfig.json"); + write(nsconfig, "{\"appPath\": "); + + GradleException e = assertThrows(GradleException.class, () -> Resolution.resolve(appBuild())); + assertTrue(e.getMessage().contains(nsconfig.toString()), e.getMessage()); + } + + @Test + void hasNoPluginsWithoutDependenciesJson() { + assertTrue(Resolution.resolve(appBuild()).getPlugins().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = { + "[{\"name\": \"a\", ", + "{}", + "[1]", + "[{\"name\": \"a\"}]", + "[{\"name\": 1, \"directory\": \"x\"}]", + }) + void reportsInvalidDependenciesJson(String text) throws IOException { + Path file = platformsAndroid.resolve("dependencies.json"); + write(file, text); + + GradleException e = assertThrows(GradleException.class, () -> Resolution.resolve(appBuild())); + assertTrue(e.getMessage().startsWith("Invalid " + file), e.getMessage()); + } + } + + @Nested + class LegacyPluginBuild { + @Test + void resolvesFromProperties() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + FakeInputs inputs = pluginBuild() + .property("USER_PROJECT_PLATFORMS_ANDROID", platformsAndroid.toString()) + .property("USER_PROJECT_ROOT", project.toString()); + + Resolution resolution = Resolution.resolve(inputs); + + assertNull(resolution.getFacts()); + assertTrue(resolution.isPluginBuild()); + assertEquals("@scope/p", resolution.getPluginName()); + assertEquals(project.toFile(), resolution.getProjectDir()); + assertEquals(project.resolve("app").toFile(), resolution.getAppDir()); + assertEquals(Collections.singleton("x"), resolution.getPlugins().keySet()); + assertEquals( + project.resolve("node_modules/x/platforms/android").toFile(), + resolution.getPlugins().get("x").getNativeDir()); + } + + @Test + void fallsBackToEnvironmentVariables() throws IOException { + Path otherProject = Files.createDirectories(base.resolve("other")); + Path otherAndroid = Files.createDirectories(base.resolve("elsewhere/android")); + writeDependencies(otherAndroid, "../modules/x"); + + Resolution resolution = Resolution.resolve(pluginBuild() + .env("USER_PROJECT_PLATFORMS_ANDROID", otherAndroid.toString()) + .env("USER_PROJECT_ROOT", otherProject.toString())); + + assertEquals(otherProject.toFile(), resolution.getProjectDir()); + assertEquals( + base.resolve("elsewhere/modules/x").toFile(), + resolution.getPlugins().get("x").getPackageDir()); + } + + @Test + void propertiesWinOverEnvironmentVariables() throws IOException { + Path otherProject = Files.createDirectories(base.resolve("other")); + Path otherAndroid = Files.createDirectories(base.resolve("elsewhere/android")); + writeDependencies(platformsAndroid, "../../node_modules/x"); + writeDependencies(otherAndroid, "../modules/y"); + + Resolution resolution = Resolution.resolve(pluginBuild() + .property("USER_PROJECT_PLATFORMS_ANDROID", platformsAndroid.toString()) + .property("USER_PROJECT_ROOT", project.toString()) + .env("USER_PROJECT_PLATFORMS_ANDROID", otherAndroid.toString()) + .env("USER_PROJECT_ROOT", otherProject.toString())); + + assertEquals(project.toFile(), resolution.getProjectDir()); + assertEquals(Collections.singleton("x"), resolution.getPlugins().keySet()); + } + + @Test + void defaultsToThreeLevelsUpAndItsPlatformsAndroid() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + + Resolution resolution = Resolution.resolve(pluginBuild()); + + assertEquals(project.toFile(), resolution.getProjectDir()); + assertEquals(project.resolve("node_modules/x").toFile(), resolution.getPlugins().get("x").getPackageDir()); + } + + @Test + void doesNotReadDependenciesJsonFromItsOwnRoot() throws IOException { + writeDependencies(tempPluginRoot, "../../node_modules/x"); + + assertTrue(Resolution.resolve(pluginBuild()).getPlugins().isEmpty()); + } + } + + @Nested + class FactsLookup { + @Test + void usesFactsWhoseDependenciesJsonHashMatches() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + writeFactsMatchingDependencies(platformsAndroid, "\"from-facts\""); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertNotNull(resolution.getFacts()); + assertEquals(Collections.singleton("from-facts"), resolution.getCapabilities()); + } + + @Test + void ignoresFactsWhenDependenciesJsonChangedSince() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + writeFactsMatchingDependencies(platformsAndroid, "\"from-facts\""); + writeDependencies(platformsAndroid, "../../node_modules/rewritten"); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertNull(resolution.getFacts()); + assertTrue(resolution.getCapabilities().isEmpty()); + assertEquals(Collections.singleton("rewritten"), resolution.getPlugins().keySet()); + } + + @Test + void ignoresFactsWhenDependenciesJsonIsMissing() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + writeFactsMatchingDependencies(platformsAndroid, "\"from-facts\""); + Files.delete(platformsAndroid.resolve("dependencies.json")); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertNull(resolution.getFacts()); + assertTrue(resolution.getPlugins().isEmpty()); + } + + @Test + void ignoresStaleExplicitFactsFileInsteadOfFailing() throws IOException { + Path dir = Files.createDirectories(base.resolve("explicit")); + writeDependencies(dir, "../project/node_modules/x"); + Path explicit = writeFactsMatchingDependencies(dir, "\"explicit\""); + writeDependencies(dir, "../project/node_modules/changed"); + + Resolution resolution = + Resolution.resolve(appBuild().property(Resolution.FACTS_FILE_PROPERTY, explicit.toString())); + + assertNull(resolution.getFacts()); + assertTrue(resolution.getCapabilities().isEmpty()); + } + + @Test + void trustsFactsWithoutDependenciesJsonHash() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), project, "[]", "\"no-hash\""); + writeDependencies(platformsAndroid, "../../node_modules/rewritten"); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertNotNull(resolution.getFacts()); + assertEquals(Collections.singleton("no-hash"), resolution.getCapabilities()); + } + + @Test + void explicitPropertyWins() throws IOException { + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), project, "[]", "\"in-root\""); + Path explicit = base.resolve("explicit/facts.json"); + Files.createDirectories(explicit.getParent()); + writeFacts(explicit, project, "[]", "\"explicit\""); + + Resolution resolution = + Resolution.resolve(appBuild().property(Resolution.FACTS_FILE_PROPERTY, explicit.toString())); + + assertEquals(explicit.toFile(), resolution.getFacts().getFile()); + assertEquals(Collections.singleton("explicit"), resolution.getCapabilities()); + } + + @Test + void resolvesRelativePropertyAgainstRootDir() throws IOException { + Path facts = platformsAndroid.resolve("sub/facts.json"); + Files.createDirectories(facts.getParent()); + writeFacts(facts, project, "[]"); + + Resolution resolution = + Resolution.resolve(appBuild().property(Resolution.FACTS_FILE_PROPERTY, "sub/facts.json")); + + assertEquals(facts.toFile(), resolution.getFacts().getFile()); + } + + @Test + void reportsMissingExplicitFile() { + Path missing = base.resolve("missing.json"); + + GradleException e = assertThrows( + GradleException.class, + () -> Resolution.resolve(appBuild().property(Resolution.FACTS_FILE_PROPERTY, missing.toString()))); + assertTrue(e.getMessage().contains(missing.toString()), e.getMessage()); + } + + @Test + void findsFactsInRootDir() throws IOException { + Path factsFile = platformsAndroid.resolve(Resolution.FACTS_FILE_NAME); + writeFacts(factsFile, project, "[" + plugin("@s/listed", "../../node_modules/@s/listed") + "]", "\"cap\""); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertNotNull(resolution.getFacts()); + assertEquals(factsFile.toFile(), resolution.getFacts().getFile()); + assertFalse(resolution.isPluginBuild()); + assertEquals(project.toFile(), resolution.getProjectDir()); + assertEquals(project.resolve("src").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("src/App_Resources").toFile(), resolution.getAppResourcesDir()); + assertEquals(Collections.singleton("cap"), resolution.getCapabilities()); + assertEquals(Collections.singleton("@s/listed"), resolution.getPlugins().keySet()); + assertEquals("s_listed", resolution.getPlugins().get("@s/listed").getAarName()); + } + + @Test + void factsTakePrecedenceOverDependenciesJson() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/legacy"); + writeFacts( + platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), + project, + "[" + plugin("from-facts", "../../node_modules/from-facts") + "]"); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertEquals(Collections.singleton("from-facts"), resolution.getPlugins().keySet()); + } + + @Test + void factsTakePrecedenceOverTemplateProperties() throws IOException { + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), project, "[]"); + + Resolution resolution = Resolution.resolve(appBuild() + .property("USER_PROJECT_ROOT", base.toString()) + .property("appPath", "elsewhere")); + + assertEquals(project.toFile(), resolution.getProjectDir()); + assertEquals(project.resolve("src").toFile(), resolution.getAppDir()); + } + + @Test + void pluginBuildFindsFactsInUserProjectPlatformsAndroidProperty() throws IOException { + Path factsFile = platformsAndroid.resolve(Resolution.FACTS_FILE_NAME); + writeFacts(factsFile, project, "[]", "\"cap\""); + + Resolution resolution = Resolution.resolve( + pluginBuild().property("USER_PROJECT_PLATFORMS_ANDROID", platformsAndroid.toString())); + + assertEquals(factsFile.toFile(), resolution.getFacts().getFile()); + assertTrue(resolution.isPluginBuild()); + assertEquals("@scope/p", resolution.getPluginName()); + assertEquals(Collections.singleton("cap"), resolution.getCapabilities()); + } + + @Test + void pluginBuildFindsFactsInUserProjectPlatformsAndroidEnvironmentVariable() throws IOException { + Path factsFile = platformsAndroid.resolve(Resolution.FACTS_FILE_NAME); + writeFacts(factsFile, project, "[]"); + + Resolution resolution = Resolution.resolve( + pluginBuild().env("USER_PROJECT_PLATFORMS_ANDROID", platformsAndroid.toString())); + + assertEquals(factsFile.toFile(), resolution.getFacts().getFile()); + } + + @Test + void pluginBuildWithoutFactsLocationFallsBackToLegacy() throws IOException { + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), project, "[]"); + + assertNull(Resolution.resolve(pluginBuild()).getFacts()); + } + + @Test + void reportsInvalidFacts() throws IOException { + Path factsFile = platformsAndroid.resolve(Resolution.FACTS_FILE_NAME); + write(factsFile, "{}"); + + GradleException e = assertThrows(GradleException.class, () -> Resolution.resolve(appBuild())); + assertTrue(e.getMessage().startsWith("Invalid NativeScript facts file " + factsFile), e.getMessage()); + } + } + + @Nested + class PackageLookup { + @Test + void findPluginReturnsListedPlugin() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + Resolution resolution = Resolution.resolve(appBuild()); + + assertSame(resolution.getPlugins().get("x"), resolution.findPlugin("x")); + } + + @Test + void findPluginFallsBackToInstalledPackageWithoutFacts() throws IOException { + Path pkg = installPackage(project, "@s/unlisted"); + Files.createDirectories(pkg.resolve("platforms/android")); + + PluginData plugin = Resolution.resolve(appBuild()).findPlugin("@s/unlisted"); + + assertNotNull(plugin); + assertEquals("@s/unlisted", plugin.getName()); + assertEquals(pkg.toFile(), plugin.getPackageDir()); + assertEquals(pkg.resolve("platforms/android").toFile(), plugin.getNativeDir()); + assertNull(plugin.getAarName()); + assertNull(plugin.getAar()); + assertTrue(plugin.getDependencies().isEmpty()); + assertTrue(plugin.getOptions().isEmpty()); + } + + @Test + void findPluginIgnoresInstalledPackageWithoutNativeDir() throws IOException { + installPackage(project, "js-only"); + Resolution resolution = Resolution.resolve(appBuild()); + + assertNull(resolution.findPlugin("js-only")); + assertNotNull(resolution.findPackage("js-only")); + } + + @Test + void findPluginFallsBackToInstalledPackageWithFacts() throws IOException { + Path pkg = installPackage(project, "@s/unlisted"); + Files.createDirectories(pkg.resolve("platforms/android")); + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), project, "[]"); + Resolution resolution = Resolution.resolve(appBuild()); + + PluginData plugin = resolution.findPlugin("@s/unlisted"); + + assertNotNull(resolution.getFacts()); + assertNotNull(plugin); + assertEquals(pkg.toFile(), plugin.getPackageDir()); + assertEquals(pkg.resolve("platforms/android").toFile(), plugin.getNativeDir()); + assertNull(plugin.getAarName()); + assertNull(plugin.getAar()); + assertTrue(plugin.getOptions().isEmpty()); + } + + @Test + void findPluginWithFactsIgnoresInstalledPackageWithoutNativeDir() throws IOException { + installPackage(project, "js-only"); + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), project, "[]"); + Resolution resolution = Resolution.resolve(appBuild()); + + assertNotNull(resolution.getFacts()); + assertNull(resolution.findPlugin("js-only")); + assertNull(resolution.findPlugin("not-installed")); + } + + @Test + void findPluginWithFactsPrefersListedDataOverInstalledPackage() throws IOException { + Path installed = installPackage(project, "@s/listed"); + Files.createDirectories(installed.resolve("platforms/android")); + writeFacts( + platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), + project, + "[" + plugin("@s/listed", "../../vendor/listed") + "]"); + + PluginData plugin = Resolution.resolve(appBuild()).findPlugin("@s/listed"); + + assertEquals(project.resolve("vendor/listed/platforms/android").toFile(), plugin.getNativeDir()); + assertEquals("s_listed", plugin.getAarName()); + } + + @Test + void findPluginWalksUpLikeNode() throws IOException { + Path pkg = installPackage(base, "hoisted"); + Files.createDirectories(pkg.resolve("platforms/android")); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertEquals(pkg.toFile(), resolution.findPlugin("hoisted").getPackageDir()); + assertEquals(pkg.toFile(), resolution.findPackage("hoisted")); + } + + @Test + void nearestInstallWins() throws IOException { + installPackage(base, "dup"); + Path near = installPackage(project, "dup"); + + assertEquals(near.toFile(), Resolution.resolve(appBuild()).findPackage("dup")); + } + + @Test + void directoryWithoutPackageJsonIsNotAPackage() throws IOException { + Files.createDirectories(project.resolve("node_modules/empty")); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertNull(resolution.findPlugin("empty")); + assertNull(resolution.findPackage("empty")); + } + + @Test + void returnsNullWhenNotInstalled() { + Resolution resolution = Resolution.resolve(appBuild()); + + assertNull(resolution.findPlugin("nativescript-gradle-api-test-not-installed")); + assertNull(resolution.findPackage("@nativescript-gradle-api-test/not-installed")); + } + + @Test + void findPackageReturnsListedPackageDirOverNodeLookup() throws IOException { + installPackage(project, "@s/listed"); + writeFacts( + platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), + project, + "[" + plugin("@s/listed", "../../vendor/listed") + "]"); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertEquals(project.resolve("vendor/listed").toFile(), resolution.findPackage("@s/listed")); + } + + @Test + void findPackageFindsScopedInstalledPackage() throws IOException { + Path pkg = installPackage(project, "@scope/name"); + + assertEquals(pkg.toFile(), Resolution.resolve(appBuild()).findPackage("@scope/name")); + } + + @Test + void lookupStartsAtFactsProjectRoot() throws IOException { + Path otherProject = Files.createDirectories(base.resolve("other")); + Path pkg = installPackage(otherProject, "elsewhere"); + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), otherProject, "[]"); + + assertEquals(pkg.toFile(), Resolution.resolve(appBuild()).findPackage("elsewhere")); + } + + @ParameterizedTest + @ValueSource(strings = {"", "../x", "@scope", "@scope/", "a/b", "@a/b/c", "a\\b", "..", ".", "@/x", "@s/..", "c:x"}) + void rejectsInvalidNames(String name) { + Resolution resolution = Resolution.resolve(appBuild()); + + assertThrows(IllegalArgumentException.class, () -> resolution.findPlugin(name)); + assertThrows(IllegalArgumentException.class, () -> resolution.findPackage(name)); + } + + @Test + void rejectsNullName() { + Resolution resolution = Resolution.resolve(appBuild()); + + assertThrows(IllegalArgumentException.class, () -> resolution.findPlugin(null)); + assertThrows(IllegalArgumentException.class, () -> resolution.findPackage(null)); + } + } + + private FakeInputs appBuild() { + return new FakeInputs(platformsAndroid.toFile()); + } + + private FakeInputs pluginBuild() { + return new FakeInputs(tempPluginRoot.toFile()).property("tempBuild", "true").property("PLUGIN_NAME", "@scope/p"); + } + + private static Path installPackage(Path dir, String name) throws IOException { + Path pkg = dir.resolve("node_modules").resolve(name); + Files.createDirectories(pkg); + write(pkg.resolve("package.json"), "{\"name\": \"" + name + "\"}"); + return pkg; + } + + private static void writeDependencies(Path dir, String directory) throws IOException { + String name = directory.substring(directory.lastIndexOf('/') + 1); + write(dir.resolve("dependencies.json"), + "[{\"name\": \"" + name + "\", \"directory\": \"" + directory + "\", \"dependencies\": []}]"); + } + + /** Writes facts whose project root is {@code root}, given relative to the file's directory. */ + private static void writeFacts(Path file, Path root, String plugins, String... capabilities) throws IOException { + String rootPath = file.getParent().relativize(root).toString().replace('\\', '/'); + if (rootPath.isEmpty()) { + rootPath = "."; + } + write(file, "{\"schemaVersion\": 1, \"capabilities\": [" + String.join(", ", capabilities) + "]," + + " \"project\": {\"root\": \"" + rootPath + "\", \"appDir\": \"" + rootPath + "/src\"," + + " \"appResourcesDir\": \"" + rootPath + "/src/App_Resources\"}," + + " \"plugins\": " + plugins + "}"); + } + + /** + * Writes facts next to {@code dir}'s current dependencies.json, recording its hash as the CLI + * does, with the project root at {@code project}. + */ + private Path writeFactsMatchingDependencies(Path dir, String capability) throws IOException { + Path file = dir.resolve(Resolution.FACTS_FILE_NAME); + writeFacts(file, project, "[]", capability); + String sha256 = Facts.sha256(Files.readAllBytes(dir.resolve("dependencies.json"))); + String text = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + write(file, text.replaceFirst("\\{", "{\"dependenciesJson\": {\"sha256\": \"" + sha256 + "\"}, ")); + return file; + } + + private static String plugin(String name, String packageDir) { + String aarName = name.replace("@", "").replace('/', '_'); + return "{\"name\": \"" + name + "\", \"packageDir\": \"" + packageDir + "\"," + + " \"nativeDir\": \"" + packageDir + "/platforms/android\", \"aarName\": \"" + aarName + "\"," + + " \"aar\": \"" + packageDir + "/platforms/android/" + aarName + ".aar\", \"dependencies\": []}"; + } + + private static void write(Path file, String text) throws IOException { + Files.write(file, text.getBytes(StandardCharsets.UTF_8)); + } + + private static final class FakeInputs implements BuildInputs { + private final Map properties = new HashMap<>(); + private final Map environment = new HashMap<>(); + private final File rootDir; + + FakeInputs(File rootDir) { + this.rootDir = rootDir; + } + + FakeInputs property(String name, Object value) { + properties.put(name, value); + return this; + } + + FakeInputs env(String name, String value) { + environment.put(name, value); + return this; + } + + @Override + public Object property(String name) { + return properties.get(name); + } + + @Override + public String environmentVariable(String name) { + return environment.get(name); + } + + @Override + public File rootDir() { + return rootDir; + } + } +} diff --git a/gradle-api/src/test/resources/facts/v1-basic.json b/gradle-api/src/test/resources/facts/v1-basic.json new file mode 100644 index 0000000000..6d6cf52acf --- /dev/null +++ b/gradle-api/src/test/resources/facts/v1-basic.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "cli": { "version": "9.2.0" }, + "runtime": { "name": "@nativescript/android", "version": "9.0.0" }, + "dependenciesJson": { "sha256": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570" }, + "capabilities": [], + "project": { + "root": "../..", + "appDir": "../../src", + "appResourcesDir": "../../App_Resources" + }, + "plugins": [ + { + "name": "@nativescript/core", + "packageDir": "../../node_modules/@nativescript/core", + "nativeDir": "../../node_modules/@nativescript/core/platforms/android", + "aarName": "core", + "aar": "../../node_modules/@nativescript/core/platforms/android/core.aar", + "dependencies": [], + "options": {} + }, + { + "name": "@foo/plugin-x", + "packageDir": "../../node_modules/@foo/plugin-x", + "nativeDir": ".ns-plugins/foo_plugin_x/platforms/android", + "aarName": "foo_plugin_x", + "aar": ".ns-plugins/foo_plugin_x/platforms/android/foo_plugin_x.aar", + "dependencies": ["@nativescript/core"], + "options": {} + }, + { + "name": "@bar/plugin-x", + "packageDir": "../../node_modules/@bar/plugin-x", + "nativeDir": "../../node_modules/@bar/plugin-x/platforms/android", + "aarName": "bar_plugin_x", + "aar": "../../node_modules/@bar/plugin-x/platforms/android/bar_plugin_x.aar", + "dependencies": [], + "options": {} + } + ] +} diff --git a/gradle-api/src/test/resources/facts/v2-unknown-fields.json b/gradle-api/src/test/resources/facts/v2-unknown-fields.json new file mode 100644 index 0000000000..aaa5c9297f --- /dev/null +++ b/gradle-api/src/test/resources/facts/v2-unknown-fields.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 2, + "dependenciesJson": { "sha256": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570" }, + "capabilities": ["some.future.capability"], + "someFutureSection": { "nested": [1, 2, { "deep": true }], "flag": null }, + "project": { + "root": "../..", + "appDir": "../../app", + "appResourcesDir": "../../app/App_Resources", + "futureField": "ignored" + }, + "plugins": [ + { + "name": "nativescript-plugin", + "packageDir": "../../node_modules/nativescript-plugin", + "nativeDir": "../../node_modules/nativescript-plugin/platforms/android", + "aarName": "nativescript_plugin", + "aar": "../../node_modules/nativescript-plugin/platforms/android/nativescript_plugin.aar", + "dependencies": [], + "options": { "someOption": ["a", "b"] }, + "futureField": 3.5e2 + } + ] +} From bf19f23193d996aadc707e14a7d06da3bbcce4e1 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 22 Oct 2026 14:15:39 -0300 Subject: [PATCH 2/2] feat(android): Gradle facts file, unique plugin aar names and opt-in 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. --- lib/bootstrap.ts | 4 + lib/common/helpers.ts | 65 +++ lib/common/test/unit-tests/helpers.ts | 125 +++++ lib/constants.ts | 2 + lib/definitions/android-plugin-migrator.d.ts | 51 ++ lib/definitions/gradle-facts.d.ts | 35 ++ lib/definitions/project.d.ts | 6 + lib/services/android-plugin-build-service.ts | 163 +++++- lib/services/android-project-service.ts | 313 ++++++++++- .../android/android-plugin-mirror-service.ts | 184 ++++++ .../android/gradle-build-args-service.ts | 32 +- lib/services/android/gradle-facts.ts | 89 +++ package-lock.json | 59 ++ package.json | 1 + test/plugins-service.ts | 1 + test/services/android-gradle-facts.ts | 524 ++++++++++++++++++ test/services/android-plugin-build-service.ts | 231 +++++++- test/services/android-project-service.ts | 33 +- .../android/android-plugin-mirror-service.ts | 194 +++++++ .../android/gradle-build-args-service.ts | 47 ++ .../gradle-api-include-gradle-extraction.ts | 42 ++ test/stubs.ts | 4 + vendor/gradle-plugin/build.gradle | 20 + 23 files changed, 2148 insertions(+), 77 deletions(-) create mode 100644 lib/definitions/gradle-facts.d.ts create mode 100644 lib/services/android/android-plugin-mirror-service.ts create mode 100644 lib/services/android/gradle-facts.ts create mode 100644 test/services/android-gradle-facts.ts create mode 100644 test/services/android/android-plugin-mirror-service.ts create mode 100644 test/services/gradle-api-include-gradle-extraction.ts diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 154a325910..9ce26ff518 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -48,6 +48,10 @@ injector.require( "gradleBuildArgsService", "./services/android/gradle-build-args-service", ); +injector.require( + "androidPluginMirrorService", + "./services/android/android-plugin-mirror-service", +); injector.require( "androidBundleToolService", "./services/android/android-bundle-tool-service", diff --git a/lib/common/helpers.ts b/lib/common/helpers.ts index 9ee0886f8f..f10e720172 100644 --- a/lib/common/helpers.ts +++ b/lib/common/helpers.ts @@ -125,6 +125,71 @@ function sanitizePluginName(pluginName: string): string { return pluginName.split("/").pop(); } +function getScopeQualifiedPluginName(pluginName: string): string | null { + const parts = pluginName.split("/"); + if (parts.length < 2 || !parts[0].startsWith("@")) { + return null; + } + + const scope = parts[0].slice(1).replace(/[^A-Za-z0-9_]/g, "_"); + return `${scope}_${getShortPluginName(pluginName)}`; +} + +// The aar name doubles as a Java package segment (the `org.nativescript.` +// namespace fallback), which cannot start with a digit. +function withoutLeadingDigit(name: string): string { + return /^[0-9]/.test(name) ? `_${name}` : name; +} + +/** + * Maps each plugin name to a file-system and Java-package safe name for its aar + * that is unique among `pluginNames`. A plugin whose short name is not shared + * keeps `getShortPluginName(name)`, so projects without clashes keep their names. + * Throws if two plugins still end up with the same name. + */ +export function getUniqueShortPluginNames( + pluginNames: string[], +): Map { + const sortedNames = _.sortBy(_.uniq(pluginNames)); + const namesByShortName = _.groupBy(sortedNames, getShortPluginName); + const result = new Map(); + const owners = new Map(); + + for (const pluginName of sortedNames) { + const shortName = getShortPluginName(pluginName); + const aarName = + namesByShortName[shortName].length === 1 + ? shortName + : withoutLeadingDigit( + getScopeQualifiedPluginName(pluginName) ?? shortName, + ); + + const owner = owners.get(aarName); + if (owner !== undefined) { + throw new Error( + `The plugins "${owner}" and "${pluginName}" both need the Android aar name "${aarName}". Remove one of them from the project.`, + ); + } + + owners.set(aarName, pluginName); + result.set(pluginName, aarName); + } + + return result; +} + +/** + * Every aar name `getUniqueShortPluginNames` can give this plugin, depending on + * which other plugins are installed. + */ +export function getCandidateAarNames(pluginName: string): string[] { + const shortName = getShortPluginName(pluginName); + return _.uniq([ + shortName, + withoutLeadingDigit(getScopeQualifiedPluginName(pluginName) ?? shortName), + ]); +} + export async function executeActionByChunks( initialData: T[] | IDictionary, chunkSize: number, diff --git a/lib/common/test/unit-tests/helpers.ts b/lib/common/test/unit-tests/helpers.ts index 39db1dbdf2..ee5aa542f3 100644 --- a/lib/common/test/unit-tests/helpers.ts +++ b/lib/common/test/unit-tests/helpers.ts @@ -1111,4 +1111,129 @@ const test = require("./test");`, assert.deepStrictEqual(seen.sink, ["kept"]); }); }); + + describe("getUniqueShortPluginNames", () => { + const toObject = (names: string[]) => + Object.fromEntries(helpers.getUniqueShortPluginNames(names)); + + it("keeps the short name of plugins without a clash", () => { + assert.deepStrictEqual( + toObject([ + "@nativescript/core", + "nativescript-plugin", + "@scope/my-plugin", + "9-lives", + ]), + { + "@nativescript/core": "core", + "nativescript-plugin": "nativescript_plugin", + "@scope/my-plugin": "my_plugin", + "9-lives": "9_lives", + }, + ); + }); + + it("qualifies clashing scoped plugins with their scope", () => { + assert.deepStrictEqual( + toObject(["@foo/plugin-x", "@bar/plugin-x", "@nativescript/core"]), + { + "@foo/plugin-x": "foo_plugin_x", + "@bar/plugin-x": "bar_plugin_x", + "@nativescript/core": "core", + }, + ); + }); + + it("lets an unscoped plugin keep its name when it clashes with a scoped one", () => { + assert.deepStrictEqual(toObject(["plugin-x", "@foo/plugin-x"]), { + "plugin-x": "plugin_x", + "@foo/plugin-x": "foo_plugin_x", + }); + }); + + it("replaces characters a scope may contain but a name may not", () => { + assert.deepStrictEqual(toObject(["@my.org~x/plugin", "@a/plugin"]), { + "@my.org~x/plugin": "my_org_x_plugin", + "@a/plugin": "a_plugin", + }); + }); + + it("prefixes clashing names that would start with a digit", () => { + assert.deepStrictEqual(toObject(["@1password/plugin", "@a/plugin"]), { + "@1password/plugin": "_1password_plugin", + "@a/plugin": "a_plugin", + }); + assert.deepStrictEqual(toObject(["9-lives", "@a/9-lives"]), { + "9-lives": "_9_lives", + "@a/9-lives": "a_9_lives", + }); + }); + + it("fails when two plugins still share a name", () => { + assert.throws( + () => + helpers.getUniqueShortPluginNames(["@foo/plugin-x", "@foo/plugin_x"]), + /"@foo\/plugin-x" and "@foo\/plugin_x"/, + ); + assert.throws( + () => + helpers.getUniqueShortPluginNames([ + "foo-plugin-x", + "@foo/plugin-x", + "@bar/plugin-x", + ]), + /"@foo\/plugin-x" and "foo-plugin-x".*"foo_plugin_x"/, + ); + }); + + it("does not depend on the order of the plugins", () => { + const names = ["@foo/plugin-x", "plugin-x", "@bar/plugin-x", "other"]; + const expected = toObject(names); + assert.deepStrictEqual(toObject([...names].reverse()), expected); + assert.deepStrictEqual( + toObject([names[2], names[0], names[3], names[1]]), + expected, + ); + assert.throws( + () => + helpers.getUniqueShortPluginNames(["@foo/plugin_x", "@foo/plugin-x"]), + /"@foo\/plugin-x" and "@foo\/plugin_x"/, + ); + }); + }); + + describe("getCandidateAarNames", () => { + it("lists the short and the scope-qualified name of a scoped plugin", () => { + assert.deepStrictEqual(helpers.getCandidateAarNames("@foo/plugin-x"), [ + "plugin_x", + "foo_plugin_x", + ]); + }); + + it("lists the short name of an unscoped plugin", () => { + assert.deepStrictEqual(helpers.getCandidateAarNames("plugin-x"), [ + "plugin_x", + ]); + assert.deepStrictEqual(helpers.getCandidateAarNames("9-lives"), [ + "9_lives", + "_9_lives", + ]); + }); + + it("covers every name getUniqueShortPluginNames gives", () => { + const projects = [ + ["@foo/plugin-x"], + ["@foo/plugin-x", "@bar/plugin-x"], + ["plugin-x", "@foo/plugin-x"], + ["@1a/plugin", "@b/plugin", "plugin", "9-lives", "@c/9-lives"], + ]; + for (const project of projects) { + for (const [name, aarName] of helpers.getUniqueShortPluginNames( + project, + )) { + assert.include(helpers.getCandidateAarNames(name), aarName); + } + } + }); + }); }); diff --git a/lib/constants.ts b/lib/constants.ts index 7420893fab..6a595c714c 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -73,6 +73,8 @@ export const CONFIG_FILE_NAME_DISPLAY = "nativescript.config.(js|ts)"; export const CONFIG_FILE_NAME_JS = "nativescript.config.js"; export const CONFIG_FILE_NAME_TS = "nativescript.config.ts"; export const DEPENDENCIES_JSON_NAME = "dependencies.json"; +export const GRADLE_FACTS_FILE_NAME = "nativescript-gradle.json"; +export const ANDROID_PLUGIN_MIRRORS_DIR_NAME = ".ns-plugins"; export const APK_EXTENSION_NAME = ".apk"; export const AAB_EXTENSION_NAME = ".aab"; export const APKS_EXTENSION_NAME = ".apks"; diff --git a/lib/definitions/android-plugin-migrator.d.ts b/lib/definitions/android-plugin-migrator.d.ts index f5ad873307..084db0e617 100644 --- a/lib/definitions/android-plugin-migrator.d.ts +++ b/lib/definitions/android-plugin-migrator.d.ts @@ -3,6 +3,20 @@ import { IProjectDir } from "../common/declarations"; interface IPluginBuildOptions extends IAndroidBuildOptions { projectDir?: string; + /** + * File name (without extension) of the built aar and of its temp build dir. + * Defaults to the plugin's short name. + */ + aarName?: string; + /** + * The project keeps node_modules read-only, so `aarOutputDir` is a CLI-owned + * copy of the plugin's platforms/android. + */ + readOnlyNodeModules?: boolean; + /** + * The project's nativescript-gradle.json, passed to the plugin's Gradle build when it exists. + */ + gradleFactsFile?: string; } interface IAndroidBuildOptions { @@ -17,6 +31,33 @@ interface IAndroidBuildOptions { interface IAndroidPluginBuildService { buildAar(options: IPluginBuildOptions): Promise; migrateIncludeGradle(options: IPluginBuildOptions): boolean; + /** + * Whether the plugin has sources to build but `aarOutputDir` lacks its aar + * or still holds one under another of its candidate names. + */ + hasOutdatedAar(options: IPluginBuildOptions): boolean; +} + +interface IAndroidPluginMirror { + pluginName: string; + /** + * The installed npm package. + */ + packageDir: string; + mirrorDir: string; + /** + * Paths relative to the package's platforms/android that are not copied. + */ + excludedNativeFiles?: string[]; +} + +interface IAndroidPluginMirrorService { + /** + * Brings `/` in line with each plugin's package.json and + * platforms/android, and removes mirrors of plugins not in `mirrors`. + * @returns The names of the plugins whose mirror changed. + */ + syncMirrors(mirrorsRoot: string, mirrors: IAndroidPluginMirror[]): string[]; } /** @@ -49,4 +90,14 @@ interface IBuildAndroidPluginData extends Partial { * Optional custom Gradle arguments. */ gradleArgs?: string; + + /** + * The project keeps node_modules read-only (`android.readOnlyNodeModules`). + */ + readOnlyNodeModules?: boolean; + + /** + * Absolute path of the project's nativescript-gradle.json, when it exists. + */ + gradleFactsFile?: string; } diff --git a/lib/definitions/gradle-facts.d.ts b/lib/definitions/gradle-facts.d.ts new file mode 100644 index 0000000000..d6f7f99670 --- /dev/null +++ b/lib/definitions/gradle-facts.d.ts @@ -0,0 +1,35 @@ +/** + * Contents of platforms/android/nativescript-gradle.json, read by + * org.nativescript:gradle-api. Mirrors gradle-api/schema/nativescript-gradle.schema.json: + * fields are only ever added, and every path is relative to the file's + * directory with forward slashes. + */ +export interface INativeScriptGradleFacts { + schemaVersion: number; + cli?: { version?: string }; + runtime?: { name?: string; version?: string }; + capabilities?: string[]; + /** + * Identifies the dependencies.json written together with these facts, so a + * reader can tell when a CLI that does not write facts has replaced it since. + */ + dependenciesJson: { sha256: string }; + project: INativeScriptGradleFactsProject; + plugins: INativeScriptGradleFactsPlugin[]; +} + +export interface INativeScriptGradleFactsProject { + root: string; + appDir: string; + appResourcesDir: string; +} + +export interface INativeScriptGradleFactsPlugin { + name: string; + packageDir: string; + nativeDir: string; + aarName: string; + aar: string; + dependencies: string[]; + options?: Record; +} diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index cd2a931617..559c5bdce6 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -179,6 +179,12 @@ interface INsConfigAndroid extends INsConfigPlaform { * Custom runtime package name */ runtimePackageName?: string; + + /** + * Never write into node_modules: each plugin's Android files are copied to + * `platforms/android/.ns-plugins/` and its aar is built there. + */ + readOnlyNodeModules?: boolean; } interface INsConfigHooks { diff --git a/lib/services/android-plugin-build-service.ts b/lib/services/android-plugin-build-service.ts index 88098d55b0..19a25ad082 100644 --- a/lib/services/android-plugin-build-service.ts +++ b/lib/services/android-plugin-build-service.ts @@ -8,7 +8,12 @@ import { PLUGIN_BUILD_DATA_FILENAME, SCOPED_ANDROID_RUNTIME_NAME, } from "../constants"; -import { getShortPluginName, hook, quoteString } from "../common/helpers"; +import { + getCandidateAarNames, + getShortPluginName, + hook, + quoteString, +} from "../common/helpers"; import { Builder, parseString } from "xml2js"; import { IRuntimeGradleVersions, @@ -226,19 +231,27 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { const androidSourceDirectories = this.getAndroidSourceDirectories( options.platformsAndroidDirPath, ); - const shortPluginName = getShortPluginName(options.pluginName); - const pluginTempDir = path.join(options.tempPluginDirPath, shortPluginName); + const aarName = this.getAarName(options); + const pluginTempDir = path.join(options.tempPluginDirPath, aarName); const pluginSourceFileHashesInfo = await this.getSourceFilesHashes( options.platformsAndroidDirPath, - shortPluginName, + options.pluginName, + aarName, ); + if ( + options.aarName && + options.aarOutputDir && + (manifestFilePath || androidSourceDirectories.length) + ) { + this.removeStaleAars(options, aarName); + } + const shouldBuildAar = await this.shouldBuildAar({ manifestFilePath, androidSourceDirectories, pluginTempDir, - pluginSourceDir: options.platformsAndroidDirPath, - shortPluginName, + aarPath: path.join(this.getAarOutputDir(options), `${aarName}.aar`), fileHashesInfo: pluginSourceFileHashesInfo, }); @@ -249,7 +262,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { await this.updateManifest( manifestFilePath, pluginTempMainSrcDir, - shortPluginName, + aarName, ); this.copySourceSetDirectories( androidSourceDirectories, @@ -260,24 +273,112 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { options.platformsAndroidDirPath, options.projectDir, options.pluginName, + aarName, ); - await this.buildPlugin({ + const pluginBuildData: IBuildAndroidPluginData = { gradlePath: options.gradlePath, gradleArgs: options.gradleArgs, pluginDir: pluginTempDir, pluginName: options.pluginName, projectDir: options.projectDir, - }); - this.$watchIgnoreListService.addFileToIgnoreList( - path.join(options.aarOutputDir, `${shortPluginName}.aar`), - ); - this.copyAar(shortPluginName, pluginTempDir, options.aarOutputDir); + }; + if (options.readOnlyNodeModules) { + pluginBuildData.readOnlyNodeModules = true; + } + if (options.gradleFactsFile && this.$fs.exists(options.gradleFactsFile)) { + pluginBuildData.gradleFactsFile = path.resolve(options.gradleFactsFile); + } + await this.buildPlugin(pluginBuildData); + if (!options.readOnlyNodeModules) { + this.$watchIgnoreListService.addFileToIgnoreList( + path.join(options.aarOutputDir, `${aarName}.aar`), + ); + } + this.copyAar(aarName, pluginTempDir, options.aarOutputDir); this.writePluginHashInfo(pluginSourceFileHashesInfo, pluginTempDir); } return shouldBuildAar; } + public hasOutdatedAar(options: IPluginBuildOptions): boolean { + const aarOutputDir = this.getAarOutputDir(options); + if ( + !this.$fs.exists(options.platformsAndroidDirPath) || + (!this.getManifest(options.platformsAndroidDirPath) && + !this.getAndroidSourceDirectories(options.platformsAndroidDirPath) + .length) + ) { + return false; + } + + const aarName = this.getAarName(options); + return ( + !this.$fs.exists(path.join(aarOutputDir, `${aarName}.aar`)) || + this.getStaleAarPaths(options, aarName).length > 0 + ); + } + + private getAarName(options: IPluginBuildOptions): string { + return options.aarName ?? getShortPluginName(options.pluginName); + } + + private getAarOutputDir(options: IPluginBuildOptions): string { + return options.aarOutputDir || options.platformsAndroidDirPath; + } + + private getStaleAarPaths( + options: IPluginBuildOptions, + aarName: string, + ): string[] { + const aarOutputDir = this.getAarOutputDir(options); + return getCandidateAarNames(options.pluginName) + .filter((candidate) => candidate !== aarName) + .map((candidate) => path.join(aarOutputDir, `${candidate}.aar`)) + .filter((candidatePath) => this.$fs.exists(candidatePath)); + } + + /** + * Gradle templates add every aar in a plugin's platforms/android to the app, + * so an aar the CLI built for this plugin under another candidate name would + * duplicate its classes. + */ + private removeStaleAars(options: IPluginBuildOptions, aarName: string): void { + for (const staleAarPath of this.getStaleAarPaths(options, aarName)) { + if (!options.readOnlyNodeModules) { + const staleName = path.basename(staleAarPath, ".aar"); + const previousOutput = path.join( + options.tempPluginDirPath, + staleName, + "build", + "outputs", + "aar", + `${staleName}-release.aar`, + ); + if (!this.isSameFileContent(staleAarPath, previousOutput)) { + this.$errors.fail( + `${staleAarPath} looks like an aar built for ${options.pluginName} under an older name and would add duplicate classes to the app. Delete it or run \`ns clean\`.`, + ); + } + + this.$watchIgnoreListService.addFileToIgnoreList(staleAarPath); + } + + this.$logger.trace(`Removing stale plugin aar ${staleAarPath}`); + this.$fs.deleteFile(staleAarPath); + } + } + + private isSameFileContent(first: string, second: string): boolean { + if (!this.$fs.exists(first) || !this.$fs.exists(second)) { + return false; + } + + const firstContent = this.$fs.readFile(first) as Buffer; + const secondContent = this.$fs.readFile(second) as Buffer; + return firstContent.equals(secondContent); + } + private cleanPluginDir(pluginTempDir: string): void { // In case plugin was already built in the current process, we need to clean the old sources as they may break the new build. this.$fs.deleteDirectory(pluginTempDir); @@ -286,15 +387,17 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { private getSourceFilesHashes( pluginTempPlatformsAndroidDir: string, - shortPluginName: string, + pluginName: string, + aarName: string, ): Promise { - const pathToAar = path.join( - pluginTempPlatformsAndroidDir, - `${shortPluginName}.aar`, + const aarPaths = new Set( + _.uniq([aarName, ...getCandidateAarNames(pluginName)]).map((name) => + path.join(pluginTempPlatformsAndroidDir, `${name}.aar`), + ), ); const pluginNativeDataFiles = this.$fs.enumerateFilesInDirectorySync( pluginTempPlatformsAndroidDir, - (file: string, stat: IFsStats) => file !== pathToAar, + (file: string, stat: IFsStats) => !aarPaths.has(file), ); return this.$filesHashService.generateHashes(pluginNativeDataFiles); } @@ -311,8 +414,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { manifestFilePath: string; androidSourceDirectories: string[]; pluginTempDir: string; - pluginSourceDir: string; - shortPluginName: string; + aarPath: string; fileHashesInfo: IStringDictionary; }): Promise { let shouldBuildAar = @@ -321,9 +423,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { if ( shouldBuildAar && this.$fs.exists(opts.pluginTempDir) && - this.$fs.exists( - path.join(opts.pluginSourceDir, `${opts.shortPluginName}.aar`), - ) + this.$fs.exists(opts.aarPath) ) { const buildDataFile = this.getPathToPluginBuildDataFile( opts.pluginTempDir, @@ -401,6 +501,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { platformsAndroidDirPath: string, projectDir: string, pluginName: string, + aarName: string, ): Promise { const gradleTemplatePath = path.resolve( path.join(__dirname, "../../vendor/gradle-plugin"), @@ -424,9 +525,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { this.replaceFileContent(buildGradlePath, "{{pluginName}}", pluginName); this.replaceFileContent(settingsGradlePath, "{{pluginName}}", pluginName); - // gets the package from the AndroidManifest to use as the namespace or fallback to the `org.nativescript.${shortPluginName}` - const shortPluginName = getShortPluginName(pluginName); - + // gets the package from the AndroidManifest to use as the namespace or fallback to the `org.nativescript.${aarName}` const manifestPath = path.join( pluginTempDir, "src", @@ -435,7 +534,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { ); const manifestContent = this.$fs.readText(manifestPath); - let packageName = `org.nativescript.${shortPluginName}`; + let packageName = `org.nativescript.${aarName}`; const xml = await this.getXml(manifestContent); if (xml["manifest"]) { if (xml["manifest"]["$"]["package"]) { @@ -817,6 +916,16 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { `-PappResourcesPath=${this.$projectData.getAppResourcesDirectoryPath()}`, ]; + if (pluginBuildSettings.gradleFactsFile) { + localArgs.push( + `-Pnativescript.factsFile=${pluginBuildSettings.gradleFactsFile}`, + ); + } + + if (pluginBuildSettings.readOnlyNodeModules) { + localArgs.push("-Pnativescript.readOnlyNodeModules=true"); + } + if (pluginBuildSettings.gradleArgs) { localArgs.push(pluginBuildSettings.gradleArgs); } diff --git a/lib/services/android-project-service.ts b/lib/services/android-project-service.ts index d4ba573a08..99f05725c9 100644 --- a/lib/services/android-project-service.ts +++ b/lib/services/android-project-service.ts @@ -6,7 +6,11 @@ import * as semver from "semver"; import * as projectServiceBaseLib from "./platform-project-service-base"; import { DeviceAndroidDebugBridge } from "../common/mobile/android/device-android-debug-bridge"; import { Configurations, LiveSyncPaths } from "../common/constants"; -import { hook } from "../common/helpers"; +import { + getCandidateAarNames, + getUniqueShortPluginNames, + hook, +} from "../common/helpers"; import { performanceLog } from ".././common/decorators"; import { IProjectData, @@ -37,8 +41,15 @@ import { } from "../common/declarations"; import { IAndroidPluginBuildService, + IAndroidPluginMirrorService, IPluginBuildOptions, } from "../definitions/android-plugin-migrator"; +import { INodeModulesDependenciesBuilder } from "../definitions/platform"; +import { + createGradleFacts, + getGradleFactsFilePath, + serializeGradleFacts, +} from "./android/gradle-facts"; import { IFilesHashService } from "../definitions/files-hash-service"; import { IGradleCommandService, @@ -54,6 +65,30 @@ interface NativeDependency { dependencies: string[]; } +interface IAndroidPluginLocation { + aarName: string; + packageDir: string; + /** + * Where Gradle reads the plugin's Android files and where its aar is built to. + */ + nativeDir: string; + /** + * Set when node_modules is read-only. + */ + mirrorDir?: string; +} + +interface IAndroidPluginLayout { + key: string; + platformDir: string; + readOnlyNodeModules: boolean; + plugins: Map; + /** + * Plugins whose mirror changed since they were last prepared. + */ + changedMirrors: Set; +} + // // we sort the native dependencies topologically to make sure they are processed in the right order // native dependenciess need to be sorted so the deepst dependencies are built before it's parents @@ -155,10 +190,14 @@ export class AndroidProjectService private $gradleCommandService: IGradleCommandService, private $gradleBuildService: IGradleBuildService, private $analyticsService: IAnalyticsService, + private $androidPluginMirrorService: IAndroidPluginMirrorService, + private $staticConfig: Config.IStaticConfig, ) { super($fs, $projectDataService); } + private pluginLayout: IAndroidPluginLayout = null; + private _platformData: IPlatformData = null; public getPlatformData(projectData: IProjectData): IPlatformData { if (!projectData && !this._platformData) { @@ -694,23 +733,210 @@ export class AndroidProjectService pluginData, AndroidProjectService.ANDROID_PLATFORM_NAME, ); - if (this.$fs.exists(pluginPlatformsFolderPath)) { - const options: IPluginBuildOptions = { - gradlePath: this.$options.gradlePath, - gradleArgs: this.$options.gradleArgs, - projectDir: projectData.projectDir, - pluginName: pluginData.name, - platformsAndroidDirPath: pluginPlatformsFolderPath, - aarOutputDir: pluginPlatformsFolderPath, - tempPluginDirPath: path.join(projectData.platformsDir, "tempPlugin"), - }; + if (!this.$fs.exists(pluginPlatformsFolderPath)) { + return; + } - if (await this.$androidPluginBuildService.buildAar(options)) { - this.$logger.info(`Built aar for ${options.pluginName}`); - } + const layout = this.getPluginLayout(projectData); + const plugin = layout.plugins.get(pluginData.name); + if (!plugin && layout.readOnlyNodeModules) { + // Gradle only reads the plugins listed in dependencies.json, and with + // read-only node_modules there is nowhere else to build this one to. + this.$logger.trace( + `Skipping ${pluginData.name}: it does not declare Android support in its package.json.`, + ); + return; + } + + layout.changedMirrors.delete(pluginData.name); + const options = this.getPluginBuildOptions( + pluginData, + pluginPlatformsFolderPath, + projectData, + layout, + ); + + if (await this.$androidPluginBuildService.buildAar(options)) { + this.$logger.info(`Built aar for ${options.pluginName}`); + } + + this.$androidPluginBuildService.migrateIncludeGradle({ + ...options, + platformsAndroidDirPath: options.aarOutputDir, + }); + } + + public shouldRepreparePlugin( + pluginData: IPluginData, + projectData: IProjectData, + ): boolean { + const pluginPlatformsFolderPath = this.getPluginPlatformsFolderPath( + pluginData, + AndroidProjectService.ANDROID_PLATFORM_NAME, + ); + const layout = this.getPluginLayout(projectData); + if (!layout.plugins.has(pluginData.name)) { + return false; + } + + return ( + layout.changedMirrors.has(pluginData.name) || + this.$androidPluginBuildService.hasOutdatedAar( + this.getPluginBuildOptions( + pluginData, + pluginPlatformsFolderPath, + projectData, + layout, + ), + ) + ); + } + + private getPluginBuildOptions( + pluginData: IPluginData, + pluginPlatformsFolderPath: string, + projectData: IProjectData, + layout: IAndroidPluginLayout, + ): IPluginBuildOptions { + const plugin = layout.plugins.get(pluginData.name); + const options: IPluginBuildOptions = { + gradlePath: this.$options.gradlePath, + gradleArgs: this.$options.gradleArgs, + projectDir: projectData.projectDir, + pluginName: pluginData.name, + platformsAndroidDirPath: pluginPlatformsFolderPath, + aarOutputDir: layout.readOnlyNodeModules + ? plugin.nativeDir + : pluginPlatformsFolderPath, + tempPluginDirPath: path.join(projectData.platformsDir, "tempPlugin"), + gradleFactsFile: getGradleFactsFilePath(layout.platformDir), + }; + + if (plugin) { + options.aarName = plugin.aarName; + } + + if (layout.readOnlyNodeModules) { + options.readOnlyNodeModules = true; + } + + return options; + } + + private getAndroidPlatformDir(projectData: IProjectData): string { + return this.$options.hostProjectPath + ? this.$options.hostProjectPath + : path.join( + projectData.platformsDir, + AndroidProjectService.ANDROID_PLATFORM_NAME, + ); + } + + private isReadOnlyNodeModules(projectData: IProjectData): boolean { + return projectData.nsConfig?.android?.readOnlyNodeModules === true; + } + + private getPluginLayoutKey(projectData: IProjectData): string { + return JSON.stringify([ + projectData.projectDir, + this.getAndroidPlatformDir(projectData), + this.isReadOnlyNodeModules(projectData), + ]); + } + + /** + * Normally computed by beforePrepareAllPlugins; computed here from the + * project's production dependencies when a plugin is prepared without it. + */ + private getPluginLayout(projectData: IProjectData): IAndroidPluginLayout { + if ( + !this.pluginLayout || + this.pluginLayout.key !== this.getPluginLayoutKey(projectData) + ) { + const dependencies = this.$injector + .resolve( + "nodeModulesDependenciesBuilder", + ) + .getProductionDependencies( + projectData.projectDir, + projectData.ignoredDependencies, + ); + this.pluginLayout = this.createPluginLayout( + projectData, + this.filterUniqueDependencies(dependencies).filter( + AndroidProjectService.isNativeAndroidDependency, + ), + ); + } + + return this.pluginLayout; + } + + private createPluginLayout( + projectData: IProjectData, + nativeDependencyData: IDependencyData[], + ): IAndroidPluginLayout { + const platformDir = this.getAndroidPlatformDir(projectData); + const readOnlyNodeModules = this.isReadOnlyNodeModules(projectData); + const mirrorsRoot = path.join( + platformDir, + constants.ANDROID_PLUGIN_MIRRORS_DIR_NAME, + ); + + let aarNames: Map; + try { + aarNames = getUniqueShortPluginNames( + nativeDependencyData.map((dep) => dep.name), + ); + } catch (err) { + this.$errors.fail(err.message); + } + + const plugins = new Map(); + for (const dep of nativeDependencyData) { + const aarName = aarNames.get(dep.name); + const mirrorDir = readOnlyNodeModules + ? path.join(mirrorsRoot, aarName) + : undefined; + plugins.set(dep.name, { + aarName, + packageDir: dep.directory, + mirrorDir, + nativeDir: path.join( + mirrorDir ?? dep.directory, + constants.PLATFORMS_DIR_NAME, + AndroidProjectService.ANDROID_PLATFORM_NAME, + ), + }); + } - this.$androidPluginBuildService.migrateIncludeGradle(options); + let changedMirrors: string[] = []; + if (readOnlyNodeModules) { + changedMirrors = this.$androidPluginMirrorService.syncMirrors( + mirrorsRoot, + nativeDependencyData.map((dep) => { + const aarName = aarNames.get(dep.name); + return { + pluginName: dep.name, + packageDir: dep.directory, + mirrorDir: plugins.get(dep.name).mirrorDir, + excludedNativeFiles: getCandidateAarNames(dep.name) + .filter((candidate) => candidate !== aarName) + .map((candidate) => `${candidate}.aar`), + }; + }), + ); + } else if (this.$fs.exists(mirrorsRoot)) { + this.$fs.deleteDirectory(mirrorsRoot); } + + return { + key: this.getPluginLayoutKey(projectData), + platformDir, + readOnlyNodeModules, + plugins, + changedMirrors: new Set(changedMirrors), + }; } public async processConfigurationFilesFromAppResources(): Promise { @@ -762,12 +988,7 @@ export class AndroidProjectService projectData: IProjectData, dependencies: IDependencyData[], ): IDependencyData[] { - const platformDir = this.$options.hostProjectPath - ? this.$options.hostProjectPath - : path.join( - projectData.platformsDir, - AndroidProjectService.ANDROID_PLATFORM_NAME, - ); + const platformDir = this.getAndroidPlatformDir(projectData); const dependenciesJsonPath = path.join( platformDir, constants.DEPENDENCIES_JSON_NAME, @@ -776,11 +997,17 @@ export class AndroidProjectService AndroidProjectService.isNativeAndroidDependency, ); + const layout = this.createPluginLayout(projectData, nativeDependencyData); + this.pluginLayout = layout; + let nativeDependencies = nativeDependencyData.map( ({ name, directory, dependencies }) => { return { name, - directory: path.relative(platformDir, directory), + directory: path.relative( + platformDir, + layout.plugins.get(name).mirrorDir ?? directory, + ), dependencies: dependencies.filter((dep) => { // filter out transient dependencies that don't have native dependencies return ( @@ -795,6 +1022,7 @@ export class AndroidProjectService nativeDependencies = topologicalSortNativeDependencies(nativeDependencies); const jsonContent = JSON.stringify(nativeDependencies, null, 4); this.$fs.writeFile(dependenciesJsonPath, jsonContent); + this.writeGradleFacts(projectData, layout, nativeDependencies, jsonContent); // we sort all the dependencies to respect the topological sorting of the native dependencies return dependencies.sort(function (a, b) { @@ -805,6 +1033,47 @@ export class AndroidProjectService }); } + private writeGradleFacts( + projectData: IProjectData, + layout: IAndroidPluginLayout, + sortedNativeDependencies: NativeDependency[], + dependenciesJsonContent: string, + ): void { + const runtimePackage = this.$projectDataService.getRuntimePackage( + projectData.projectDir, + constants.PlatformTypes.android, + ); + const facts = createGradleFacts({ + factsDir: path.resolve(layout.platformDir), + cliVersion: this.$staticConfig.version, + runtime: { name: runtimePackage?.name, version: runtimePackage?.version }, + projectDir: path.resolve(projectData.projectDir), + appDir: path.resolve(projectData.getAppDirectoryPath()), + appResourcesDir: path.resolve(projectData.getAppResourcesDirectoryPath()), + dependenciesJsonContent, + plugins: sortedNativeDependencies.map((dep) => { + const plugin = layout.plugins.get(dep.name); + return { + name: dep.name, + packageDir: path.resolve(plugin.packageDir), + nativeDir: path.resolve(plugin.nativeDir), + aarName: plugin.aarName, + dependencies: dep.dependencies, + }; + }), + }); + + // Rewritten only on change so Gradle tasks that take it as an input stay up to date. + const factsPath = getGradleFactsFilePath(layout.platformDir); + const content = serializeGradleFacts(facts); + if ( + !this.$fs.exists(factsPath) || + this.$fs.readText(factsPath) !== content + ) { + this.$fs.writeFile(factsPath, content); + } + } + private static isNativeAndroidDependency({ nativescript, }: IDependencyData): boolean { diff --git a/lib/services/android/android-plugin-mirror-service.ts b/lib/services/android/android-plugin-mirror-service.ts new file mode 100644 index 0000000000..f2f19e3c19 --- /dev/null +++ b/lib/services/android/android-plugin-mirror-service.ts @@ -0,0 +1,184 @@ +import * as path from "path"; +import { createHash } from "crypto"; +import { IFileSystem } from "../../common/declarations"; +import { injector } from "../../common/yok"; +import { + IAndroidPluginMirror, + IAndroidPluginMirrorService, +} from "../../definitions/android-plugin-migrator"; +import { PACKAGE_JSON_FILE_NAME, PLATFORMS_DIR_NAME } from "../../constants"; + +interface IMirroredFile { + sha1: string; +} + +interface IMirrorManifest { + files: { [relativePath: string]: IMirroredFile }; +} + +const MIRROR_MANIFEST_FILE_NAME = ".ns-mirror.json"; +const NATIVE_DIR = `${PLATFORMS_DIR_NAME}/android`; + +/** + * Keeps CLI-owned copies of plugins' Android files so prepare and build never + * write into node_modules. + * + * The manifest records the upstream content of every copied file. Mirror + * files are compared against it rather than against their own content because + * the CLI rewrites some of them (include.gradle) and adds its own (the built + * aar). Copies keep the upstream mtime, and files whose upstream content is + * unchanged are never rewritten, so Gradle's path and mtime based inputs stay + * up to date across syncs. + */ +export class AndroidPluginMirrorService implements IAndroidPluginMirrorService { + constructor( + private $fs: IFileSystem, + private $logger: ILogger, + ) {} + + public syncMirrors( + mirrorsRoot: string, + mirrors: IAndroidPluginMirror[], + ): string[] { + this.removeUnknownMirrors(mirrorsRoot, mirrors); + + return mirrors + .filter((mirror) => this.syncMirror(mirror)) + .map((mirror) => mirror.pluginName); + } + + private removeUnknownMirrors( + mirrorsRoot: string, + mirrors: IAndroidPluginMirror[], + ): void { + if (!this.$fs.exists(mirrorsRoot)) { + return; + } + + const knownDirs = new Set( + mirrors.map((mirror) => path.resolve(mirror.mirrorDir)), + ); + for (const entry of this.$fs.readDirectory(mirrorsRoot)) { + const entryPath = path.join(mirrorsRoot, entry); + if (!knownDirs.has(path.resolve(entryPath))) { + this.$logger.trace(`Removing unused plugin mirror ${entryPath}`); + this.$fs.deleteDirectory(entryPath); + } + } + } + + private syncMirror(mirror: IAndroidPluginMirror): boolean { + const manifestPath = path.join(mirror.mirrorDir, MIRROR_MANIFEST_FILE_NAME); + const previous = this.readManifest(manifestPath); + const next: IMirrorManifest = { files: {} }; + const excluded = new Set( + (mirror.excludedNativeFiles ?? []).map( + (file) => `${NATIVE_DIR}/${toPosix(file)}`, + ), + ); + let changed = false; + + for (const relativePath of this.getUpstreamFiles(mirror.packageDir)) { + if (excluded.has(relativePath)) { + continue; + } + + const source = path.join(mirror.packageDir, relativePath); + const destination = path.join(mirror.mirrorDir, relativePath); + const stat = this.$fs.getFsStats(source); + const known = previous.files[relativePath]; + // Always hashed: some installers give every file the same fixed mtime, so a + // same-size edit (a version bump in include.gradle) would look unchanged. + const sha1 = this.getSha1(source); + + if (!known || known.sha1 !== sha1 || !this.$fs.exists(destination)) { + this.$fs.deleteFile(destination); + this.$fs.copyFile(source, destination); + // Read-only stores (pnpm, Nix, Bazel) ship 0444 files; the CLI rewrites + // some mirrored files and replaces others on the next sync. + this.$fs.chmod(destination, stat.mode | 0o200); + this.$fs.utimes(destination, stat.atime, stat.mtime); + changed = true; + } + + next.files[relativePath] = { sha1 }; + } + + for (const relativePath of Object.keys(previous.files)) { + if (!next.files[relativePath]) { + this.deleteMirroredFile(mirror.mirrorDir, relativePath); + changed = true; + } + } + + const manifestContent = JSON.stringify(next, null, "\t"); + if ( + !this.$fs.exists(manifestPath) || + this.$fs.readText(manifestPath) !== manifestContent + ) { + this.$fs.writeFile(manifestPath, manifestContent); + } + + return changed; + } + + private getUpstreamFiles(packageDir: string): string[] { + const files: string[] = []; + if (this.$fs.exists(path.join(packageDir, PACKAGE_JSON_FILE_NAME))) { + files.push(PACKAGE_JSON_FILE_NAME); + } + + const nativeDir = path.join(packageDir, NATIVE_DIR); + if (this.$fs.exists(nativeDir)) { + for (const file of this.$fs.enumerateFilesInDirectorySync(nativeDir)) { + files.push(toPosix(path.relative(packageDir, file))); + } + } + + return files.sort(); + } + + private deleteMirroredFile(mirrorDir: string, relativePath: string): void { + const filePath = path.join(mirrorDir, relativePath); + this.$fs.deleteFile(filePath); + + const root = path.resolve(mirrorDir); + let dir = path.dirname(path.resolve(filePath)); + while ( + dir !== root && + dir.startsWith(root) && + this.$fs.exists(dir) && + this.$fs.isEmptyDir(dir) + ) { + this.$fs.deleteDirectory(dir); + dir = path.dirname(dir); + } + } + + private readManifest(manifestPath: string): IMirrorManifest { + try { + if (this.$fs.exists(manifestPath)) { + const manifest = this.$fs.readJson(manifestPath); + if (manifest && manifest.files) { + return manifest; + } + } + } catch (err) { + this.$logger.trace( + `Ignoring unreadable plugin mirror manifest ${manifestPath}: ${err}`, + ); + } + + return { files: {} }; + } + + private getSha1(filePath: string): string { + return createHash("sha1").update(this.$fs.readFile(filePath)).digest("hex"); + } +} + +function toPosix(filePath: string): string { + return filePath.split(path.sep).join("/"); +} + +injector.register("androidPluginMirrorService", AndroidPluginMirrorService); diff --git a/lib/services/android/gradle-build-args-service.ts b/lib/services/android/gradle-build-args-service.ts index eef6461fd8..9dcdb15356 100644 --- a/lib/services/android/gradle-build-args-service.ts +++ b/lib/services/android/gradle-build-args-service.ts @@ -2,9 +2,16 @@ import * as path from "path"; import { Configurations } from "../../common/constants"; import { IGradleBuildArgsService } from "../../definitions/gradle"; import { IAndroidBuildData } from "../../definitions/build"; -import { IHooksService, IAnalyticsService } from "../../common/declarations"; +import { + IHooksService, + IAnalyticsService, + IFileSystem, +} from "../../common/declarations"; import { injector } from "../../common/yok"; -import { IProjectDataService } from "../../definitions/project"; +import { IProjectData, IProjectDataService } from "../../definitions/project"; +import { IOptions } from "../../declarations"; +import { PlatformTypes } from "../../constants"; +import { getGradleFactsFilePath } from "./gradle-facts"; export class GradleBuildArgsService implements IGradleBuildArgsService { constructor( @@ -13,6 +20,8 @@ export class GradleBuildArgsService implements IGradleBuildArgsService { private $staticConfig: Config.IStaticConfig, private $projectDataService: IProjectDataService, private $logger: ILogger, + private $fs: IFileSystem, + private $options: IOptions, ) {} public async getBuildTaskArgs( @@ -55,6 +64,12 @@ export class GradleBuildArgsService implements IGradleBuildArgsService { `-PappPath=${projectData.getAppDirectoryPath()}`, `-PappResourcesPath=${projectData.getAppResourcesDirectoryPath()}`, ); + + const factsFile = this.getGradleFactsFile(projectData); + if (factsFile) { + args.push(`-Pnativescript.factsFile=${factsFile}`); + } + if (buildData.gradleArgs) { args.push(buildData.gradleArgs); } @@ -72,6 +87,19 @@ export class GradleBuildArgsService implements IGradleBuildArgsService { return args; } + private getGradleFactsFile(projectData: IProjectData): string | null { + const platformDir = + this.$options.hostProjectPath || + (projectData.platformsDir && + path.join(projectData.platformsDir, PlatformTypes.android)); + if (!platformDir) { + return null; + } + + const factsFile = getGradleFactsFilePath(platformDir); + return this.$fs.exists(factsFile) ? factsFile : null; + } + private getBuildLoggingArgs(): string[] { const args = []; diff --git a/lib/services/android/gradle-facts.ts b/lib/services/android/gradle-facts.ts new file mode 100644 index 0000000000..8df23c25bb --- /dev/null +++ b/lib/services/android/gradle-facts.ts @@ -0,0 +1,89 @@ +import * as path from "path"; +import { createHash } from "crypto"; +import { GRADLE_FACTS_FILE_NAME } from "../../constants"; +import { + INativeScriptGradleFacts, + INativeScriptGradleFactsPlugin, +} from "../../definitions/gradle-facts"; + +export const GRADLE_FACTS_SCHEMA_VERSION = 1; + +export interface IGradleFactsPluginInput { + name: string; + packageDir: string; + nativeDir: string; + aarName: string; + dependencies: string[]; +} + +export interface IGradleFactsInput { + /** + * Directory of the facts file; every path in the facts is relative to it. + */ + factsDir: string; + cliVersion: string; + runtime: { name?: string; version?: string }; + projectDir: string; + appDir: string; + appResourcesDir: string; + /** + * The exact text written to dependencies.json alongside these facts. + */ + dependenciesJsonContent: string; + /** + * In dependency order. + */ + plugins: IGradleFactsPluginInput[]; +} + +export function toFactsPath(factsDir: string, target: string): string { + return path.relative(factsDir, target).split(path.sep).join("/") || "."; +} + +export function createGradleFacts( + input: IGradleFactsInput, +): INativeScriptGradleFacts { + const rel = (target: string) => toFactsPath(input.factsDir, target); + + const runtime: INativeScriptGradleFacts["runtime"] = {}; + if (input.runtime?.name) { + runtime.name = input.runtime.name; + } + if (input.runtime?.version) { + runtime.version = input.runtime.version; + } + + return { + schemaVersion: GRADLE_FACTS_SCHEMA_VERSION, + cli: { version: input.cliVersion }, + runtime, + capabilities: [], + dependenciesJson: { + sha256: createHash("sha256") + .update(input.dependenciesJsonContent, "utf8") + .digest("hex"), + }, + project: { + root: rel(input.projectDir), + appDir: rel(input.appDir), + appResourcesDir: rel(input.appResourcesDir), + }, + plugins: input.plugins.map((plugin): INativeScriptGradleFactsPlugin => ({ + name: plugin.name, + packageDir: rel(plugin.packageDir), + nativeDir: rel(plugin.nativeDir), + aarName: plugin.aarName, + aar: rel(path.join(plugin.nativeDir, `${plugin.aarName}.aar`)), + dependencies: plugin.dependencies, + options: {}, + })), + }; +} + +export function serializeGradleFacts(facts: INativeScriptGradleFacts): string { + return JSON.stringify(facts, null, "\t") + "\n"; +} + +export function getGradleFactsFilePath(androidPlatformDir: string): string { + return path.resolve(androidPlatformDir, GRADLE_FACTS_FILE_NAME); +} diff --git a/package-lock.json b/package-lock.json index 7c7063478f..d01ff023a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,6 +96,7 @@ "@types/xml2js": "0.4.14", "@types/yargs": "17.0.33", "@types/yazl": "^3.3.0", + "ajv": "8.20.0", "braces": ">=3.0.3", "chai": "5.3.3", "chai-as-promised": "8.0.2", @@ -2136,6 +2137,23 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -3657,6 +3675,13 @@ "node": ">=8.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3673,6 +3698,23 @@ "node": ">=8.6.0" } }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -4704,6 +4746,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stringify-nice": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz", @@ -6827,6 +6876,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", diff --git a/package.json b/package.json index fbc3f41dd6..d8556adf77 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "@types/xml2js": "0.4.14", "@types/yargs": "17.0.33", "@types/yazl": "^3.3.0", + "ajv": "8.20.0", "braces": ">=3.0.3", "chai": "5.3.3", "chai-as-promised": "8.0.2", diff --git a/test/plugins-service.ts b/test/plugins-service.ts index edf62e97b0..5b944691a3 100644 --- a/test/plugins-service.ts +++ b/test/plugins-service.ts @@ -180,6 +180,7 @@ function createTestInjector() { "androidPluginBuildService", stubs.AndroidPluginBuildServiceStub, ); + testInjector.register("androidPluginMirrorService", {}); testInjector.register("analyticsSettingsService", {}); testInjector.register( "androidResourcesMigrationService", diff --git a/test/services/android-gradle-facts.ts b/test/services/android-gradle-facts.ts new file mode 100644 index 0000000000..3a39ed0cb5 --- /dev/null +++ b/test/services/android-gradle-facts.ts @@ -0,0 +1,524 @@ +import { assert } from "chai"; +import * as path from "path"; +import { mkdtempSync, readFileSync, realpathSync, statSync } from "fs"; +import { tmpdir } from "os"; +import { createHash } from "crypto"; +import Ajv2020, { ValidateFunction } from "ajv/dist/2020"; +import { Yok } from "../../lib/common/yok"; +import { FileSystem } from "../../lib/common/file-system"; +import { AndroidProjectService } from "../../lib/services/android-project-service"; +import { AndroidPluginMirrorService } from "../../lib/services/android/android-plugin-mirror-service"; +import { IFileSystem } from "../../lib/common/declarations"; +import { IDependencyData } from "../../lib/declarations"; +import { IPluginData } from "../../lib/definitions/plugins"; +import { IProjectData } from "../../lib/definitions/project"; +import { IPluginBuildOptions } from "../../lib/definitions/android-plugin-migrator"; +import { INativeScriptGradleFacts } from "../../lib/definitions/gradle-facts"; +import * as stubs from "../stubs"; + +const schemaPath = path.resolve( + __dirname, + "../../../gradle-api/schema/nativescript-gradle.schema.json", +); + +interface IFixturePlugin { + name: string; + dependencies?: string[]; + nativeFiles?: { [relativePath: string]: string }; + native?: boolean; +} + +describe("android plugin layout and Gradle facts", () => { + let fs: IFileSystem; + let service: AndroidProjectService; + let projectDir: string; + let platformDir: string; + let dependencies: IDependencyData[]; + let buildAarCalls: IPluginBuildOptions[]; + let migrateIncludeGradleCalls: IPluginBuildOptions[]; + let outdatedAar: boolean; + + const validateFacts = (() => { + let validate: ValidateFunction; + return (facts: unknown) => { + if (!validate) { + const ajv = new Ajv2020({ allErrors: true }); + validate = ajv.compile(JSON.parse(readFileSync(schemaPath, "utf8"))); + } + const valid = validate(facts); + assert.isTrue(valid, JSON.stringify(validate.errors, null, 2)); + }; + })(); + + function createProjectData(readOnlyNodeModules?: boolean): IProjectData { + return ({ + projectDir, + platformsDir: path.join(projectDir, "platforms"), + nsConfig: + readOnlyNodeModules === undefined + ? {} + : { android: { readOnlyNodeModules } }, + ignoredDependencies: [], + getAppDirectoryPath: () => path.join(projectDir, "src"), + getAppResourcesDirectoryPath: () => + path.join(projectDir, "App_Resources"), + }); + } + + function addPlugin(plugin: IFixturePlugin): IDependencyData { + const directory = path.join(projectDir, "node_modules", plugin.name); + const nativescript = + plugin.native === false ? {} : { platforms: { android: "8.0.0" } }; + fs.writeJson(path.join(directory, "package.json"), { + name: plugin.name, + version: "1.0.0", + nativescript, + }); + for (const [file, content] of Object.entries(plugin.nativeFiles ?? {})) { + fs.writeFile( + path.join(directory, "platforms", "android", ...file.split("/")), + content, + ); + } + + const dependency = { + name: plugin.name, + directory, + depth: 0, + version: "1.0.0", + nativescript, + dependencies: plugin.dependencies ?? [], + }; + dependencies.push(dependency); + return dependency; + } + + function pluginData(name: string): IPluginData { + const directory = path.join(projectDir, "node_modules", name); + return ({ + name, + fullPath: directory, + pluginPlatformsFolderPath: (platform: string) => + path.join(directory, "platforms", platform), + }); + } + + const readFacts = (): INativeScriptGradleFacts => + fs.readJson(path.join(platformDir, "nativescript-gradle.json")); + const readDependenciesJson = () => + fs.readText(path.join(platformDir, "dependencies.json")); + const dependenciesJsonSha256 = () => + createHash("sha256") + .update(readFileSync(path.join(platformDir, "dependencies.json"))) + .digest("hex"); + + beforeEach(() => { + projectDir = realpathSync( + mkdtempSync(path.join(tmpdir(), "gradle-facts-")), + ); + platformDir = path.join(projectDir, "platforms", "android"); + dependencies = []; + buildAarCalls = []; + migrateIncludeGradleCalls = []; + outdatedAar = false; + + const injector = new Yok(); + injector.register("injector", injector); + injector.register("fs", FileSystem); + injector.register("logger", stubs.LoggerStub); + injector.register("errors", stubs.ErrorsStub); + injector.register("options", {}); + injector.register("staticConfig", { version: "9.9.9" }); + injector.register("projectDataService", { + getRuntimePackage: () => ({ + name: "@nativescript/android", + version: "9.0.0", + }), + }); + injector.register("nodeModulesDependenciesBuilder", { + getProductionDependencies: () => dependencies, + }); + injector.register("androidPluginBuildService", { + buildAar: async (options: IPluginBuildOptions) => { + buildAarCalls.push(options); + return false; + }, + migrateIncludeGradle: (options: IPluginBuildOptions) => { + migrateIncludeGradleCalls.push(options); + return false; + }, + hasOutdatedAar: () => outdatedAar, + }); + injector.register("androidPluginMirrorService", AndroidPluginMirrorService); + for (const unused of [ + "androidToolsInfo", + "devicePlatformsConstants", + "platformEnvironmentRequirements", + "androidResourcesMigrationService", + "filesHashService", + "gradleCommandService", + "gradleBuildService", + "analyticsService", + ]) { + injector.register(unused, {}); + } + injector.register("androidProjectService", AndroidProjectService); + + fs = injector.resolve("fs"); + service = injector.resolve("androidProjectService"); + }); + + function addFixturePlugins() { + addPlugin({ + name: "@nativescript/core", + nativeFiles: { "include.gradle": "android {}" }, + }); + addPlugin({ + name: "@foo/plugin-x", + dependencies: ["@nativescript/core", "lodash"], + nativeFiles: { + "AndroidManifest.xml": "", + "libs/x.jar": "jar", + }, + }); + addPlugin({ + name: "@bar/plugin-x", + nativeFiles: { "res/values/strings.xml": "" }, + }); + addPlugin({ name: "lodash", native: false }); + } + + describe("default mode", () => { + it("writes the same dependencies.json as before", async () => { + addPlugin({ + name: "nativescript-plugin", + dependencies: ["@nativescript/core", "lodash"], + nativeFiles: { "AndroidManifest.xml": "" }, + }); + addPlugin({ + name: "@nativescript/core", + nativeFiles: { "include.gradle": "android {}" }, + }); + addPlugin({ name: "lodash", native: false }); + + await service.beforePrepareAllPlugins(createProjectData(), dependencies); + + const relative = (name: string) => + path.relative(platformDir, path.join(projectDir, "node_modules", name)); + assert.equal( + readDependenciesJson(), + JSON.stringify( + [ + { + name: "@nativescript/core", + directory: relative("@nativescript/core"), + dependencies: [], + }, + { + name: "nativescript-plugin", + directory: relative("nativescript-plugin"), + dependencies: ["@nativescript/core"], + }, + ], + null, + 4, + ), + ); + assert.isFalse(fs.exists(path.join(platformDir, ".ns-plugins"))); + }); + + it("writes facts that point at the packages and match the schema", async () => { + addFixturePlugins(); + + await service.beforePrepareAllPlugins(createProjectData(), dependencies); + + const facts = readFacts(); + validateFacts(facts); + assert.deepStrictEqual(Object.keys(facts), [ + "schemaVersion", + "cli", + "runtime", + "capabilities", + "dependenciesJson", + "project", + "plugins", + ]); + assert.deepStrictEqual(facts, { + schemaVersion: 1, + cli: { version: "9.9.9" }, + runtime: { name: "@nativescript/android", version: "9.0.0" }, + capabilities: [], + dependenciesJson: { sha256: dependenciesJsonSha256() }, + project: { + root: "../..", + appDir: "../../src", + appResourcesDir: "../../App_Resources", + }, + plugins: [ + { + name: "@nativescript/core", + packageDir: "../../node_modules/@nativescript/core", + nativeDir: + "../../node_modules/@nativescript/core/platforms/android", + aarName: "core", + aar: "../../node_modules/@nativescript/core/platforms/android/core.aar", + dependencies: [], + options: {}, + }, + { + name: "@foo/plugin-x", + packageDir: "../../node_modules/@foo/plugin-x", + nativeDir: "../../node_modules/@foo/plugin-x/platforms/android", + aarName: "foo_plugin_x", + aar: "../../node_modules/@foo/plugin-x/platforms/android/foo_plugin_x.aar", + dependencies: ["@nativescript/core"], + options: {}, + }, + { + name: "@bar/plugin-x", + packageDir: "../../node_modules/@bar/plugin-x", + nativeDir: "../../node_modules/@bar/plugin-x/platforms/android", + aarName: "bar_plugin_x", + aar: "../../node_modules/@bar/plugin-x/platforms/android/bar_plugin_x.aar", + dependencies: [], + options: {}, + }, + ], + }); + assert.deepStrictEqual( + JSON.parse(readDependenciesJson()).map((dep: any) => dep.name), + facts.plugins.map((plugin) => plugin.name), + ); + }); + + it("does not rewrite unchanged facts", async () => { + addFixturePlugins(); + const factsPath = path.join(platformDir, "nativescript-gradle.json"); + + await service.beforePrepareAllPlugins(createProjectData(), dependencies); + fs.utimes(factsPath, new Date(1_000_000), new Date(1_000_000)); + await service.beforePrepareAllPlugins(createProjectData(), dependencies); + + assert.equal(statSync(factsPath).mtime.getTime(), 1_000_000); + }); + + it("builds each plugin into its own package under its unique aar name", async () => { + addFixturePlugins(); + const projectData = createProjectData(); + await service.beforePrepareAllPlugins(projectData, dependencies); + + await service.preparePluginNativeCode( + pluginData("@foo/plugin-x"), + projectData, + ); + + const nativeDir = path.join( + projectDir, + "node_modules", + "@foo", + "plugin-x", + "platforms", + "android", + ); + assert.deepStrictEqual(buildAarCalls, [ + { + gradlePath: undefined, + gradleArgs: undefined, + projectDir, + pluginName: "@foo/plugin-x", + platformsAndroidDirPath: nativeDir, + aarOutputDir: nativeDir, + tempPluginDirPath: path.join(projectDir, "platforms", "tempPlugin"), + gradleFactsFile: path.join(platformDir, "nativescript-gradle.json"), + aarName: "foo_plugin_x", + }, + ]); + assert.equal( + migrateIncludeGradleCalls[0].platformsAndroidDirPath, + nativeDir, + ); + }); + + it("works out the aar names when plugins are prepared on their own", async () => { + addFixturePlugins(); + + await service.preparePluginNativeCode( + pluginData("@bar/plugin-x"), + createProjectData(), + ); + + assert.equal(buildAarCalls[0].aarName, "bar_plugin_x"); + }); + + it("asks for a reprepare when the plugin's aar is outdated", async () => { + addFixturePlugins(); + const projectData = createProjectData(); + await service.beforePrepareAllPlugins(projectData, dependencies); + + assert.isFalse( + service.shouldRepreparePlugin(pluginData("@foo/plugin-x"), projectData), + ); + outdatedAar = true; + assert.isTrue( + service.shouldRepreparePlugin(pluginData("@foo/plugin-x"), projectData), + ); + }); + + it("fails when two plugins cannot get distinct aar names", async () => { + addPlugin({ name: "@foo/plugin-x", nativeFiles: { "a.txt": "" } }); + addPlugin({ name: "@foo/plugin_x", nativeFiles: { "a.txt": "" } }); + + await assert.isRejected( + service.beforePrepareAllPlugins(createProjectData(), dependencies), + /"@foo\/plugin-x" and "@foo\/plugin_x"/, + ); + }); + }); + + describe("read-only node_modules", () => { + it("points dependencies.json and the facts at CLI-owned copies", async () => { + addFixturePlugins(); + + await service.beforePrepareAllPlugins( + createProjectData(true), + dependencies, + ); + + assert.deepStrictEqual(JSON.parse(readDependenciesJson()), [ + { + name: "@nativescript/core", + directory: path.join(".ns-plugins", "core"), + dependencies: [], + }, + { + name: "@foo/plugin-x", + directory: path.join(".ns-plugins", "foo_plugin_x"), + dependencies: ["@nativescript/core"], + }, + { + name: "@bar/plugin-x", + directory: path.join(".ns-plugins", "bar_plugin_x"), + dependencies: [], + }, + ]); + + const facts = readFacts(); + validateFacts(facts); + assert.equal(facts.dependenciesJson.sha256, dependenciesJsonSha256()); + assert.deepStrictEqual(facts.plugins[1], { + name: "@foo/plugin-x", + packageDir: "../../node_modules/@foo/plugin-x", + nativeDir: ".ns-plugins/foo_plugin_x/platforms/android", + aarName: "foo_plugin_x", + aar: ".ns-plugins/foo_plugin_x/platforms/android/foo_plugin_x.aar", + dependencies: ["@nativescript/core"], + options: {}, + }); + + const mirrorDir = path.join(platformDir, ".ns-plugins", "foo_plugin_x"); + assert.equal( + fs.readJson(path.join(mirrorDir, "package.json")).name, + "@foo/plugin-x", + ); + assert.equal( + fs.readText( + path.join(mirrorDir, "platforms", "android", "libs", "x.jar"), + ), + "jar", + ); + }); + + it("builds from the package into the copy", async () => { + addFixturePlugins(); + const projectData = createProjectData(true); + await service.beforePrepareAllPlugins(projectData, dependencies); + + await service.preparePluginNativeCode( + pluginData("@foo/plugin-x"), + projectData, + ); + + const mirrorNativeDir = path.join( + platformDir, + ".ns-plugins", + "foo_plugin_x", + "platforms", + "android", + ); + assert.deepInclude(buildAarCalls[0], { + platformsAndroidDirPath: path.join( + projectDir, + "node_modules", + "@foo", + "plugin-x", + "platforms", + "android", + ), + aarOutputDir: mirrorNativeDir, + aarName: "foo_plugin_x", + readOnlyNodeModules: true, + }); + assert.equal( + migrateIncludeGradleCalls[0].platformsAndroidDirPath, + mirrorNativeDir, + ); + }); + + it("asks for a reprepare of plugins whose copy changed until they are prepared", async () => { + addFixturePlugins(); + const projectData = createProjectData(true); + const foo = pluginData("@foo/plugin-x"); + + await service.beforePrepareAllPlugins(projectData, dependencies); + assert.isTrue(service.shouldRepreparePlugin(foo, projectData)); + + await service.preparePluginNativeCode(foo, projectData); + assert.isFalse(service.shouldRepreparePlugin(foo, projectData)); + + await service.beforePrepareAllPlugins(projectData, dependencies); + assert.isFalse(service.shouldRepreparePlugin(foo, projectData)); + }); + + it("skips plugins that Gradle does not read", async () => { + addFixturePlugins(); + fs.writeFile( + path.join( + projectDir, + "node_modules", + "lodash", + "platforms", + "android", + "AndroidManifest.xml", + ), + "", + ); + const projectData = createProjectData(true); + await service.beforePrepareAllPlugins(projectData, dependencies); + + await service.preparePluginNativeCode(pluginData("lodash"), projectData); + + assert.deepStrictEqual(buildAarCalls, []); + }); + + it("drops the copies when switched off", async () => { + addFixturePlugins(); + await service.beforePrepareAllPlugins( + createProjectData(true), + dependencies, + ); + assert.isTrue(fs.exists(path.join(platformDir, ".ns-plugins"))); + + await service.beforePrepareAllPlugins( + createProjectData(false), + dependencies, + ); + + assert.isFalse(fs.exists(path.join(platformDir, ".ns-plugins"))); + validateFacts(readFacts()); + assert.equal( + readFacts().plugins[0].nativeDir, + "../../node_modules/@nativescript/core/platforms/android", + ); + }); + }); +}); diff --git a/test/services/android-plugin-build-service.ts b/test/services/android-plugin-build-service.ts index c2107cf4c1..e434506f85 100644 --- a/test/services/android-plugin-build-service.ts +++ b/test/services/android-plugin-build-service.ts @@ -24,6 +24,10 @@ describe("androidPluginBuildService", () => { const pluginName = "my-plugin"; const shortPluginName = getShortPluginName(pluginName); let spawnFromEventCalled = false; + let lastGradleArgs: string[] = []; + let builtAarContent = ""; + let ignoredFiles: string[] = []; + let hashedFiles: string[] = []; let fs: IFileSystem; let androidBuildPluginService: AndroidPluginBuildService; let tempFolder: string; @@ -46,6 +50,8 @@ describe("androidPluginBuildService", () => { }): IPluginBuildOptions { options = options || {}; spawnFromEventCalled = false; + lastGradleArgs = []; + builtAarContent = ""; tempFolder = mkdtempSync( path.join(tmpdir(), "androidPluginBuildService-temp-"), ); @@ -75,27 +81,31 @@ describe("androidPluginBuildService", () => { const testInjector: IInjector = new stubs.InjectorStub(); testInjector.register("fs", FsLib.FileSystem); testInjector.register("childProcess", { - spawnFromEvent: async (command: string): Promise => { - const finalAarName = `${shortPluginName}-release.aar`; + spawnFromEvent: async ( + command: string, + args: string[], + ): Promise => { + const pluginDir = args[args.indexOf("-p") + 1]; const aar = path.join( - tempFolder, - shortPluginName, + pluginDir, "build", "outputs", "aar", - finalAarName, + `${path.basename(pluginDir)}-release.aar`, ); - fs.writeFile(aar, ""); + fs.writeFile(aar, builtAarContent); spawnFromEventCalled = command.indexOf("gradlew") !== -1; + lastGradleArgs = args; return null; }, }); testInjector.register("packageManager", setupNpm(options)); testInjector.register("projectData", stubs.ProjectDataStub); testInjector.register("filesHashService", { - generateHashes: async ( - files: string[], - ): Promise => ({}), + generateHashes: async (files: string[]): Promise => { + hashedFiles = files; + return {}; + }, getChanges: async ( files: string[], oldHashes: IStringDictionary, @@ -106,8 +116,9 @@ describe("androidPluginBuildService", () => { ): boolean => !!options.hasChangesInShasums, }); + ignoredFiles = []; testInjector.register("watchIgnoreListService", { - addFileToIgnoreList: () => ({}), + addFileToIgnoreList: (file: string) => ignoredFiles.push(file), }); fs = testInjector.resolve("fs"); @@ -415,6 +426,206 @@ dependencies { }); }); + describe("aar names", () => { + const scopedPluginName = "@foo/my-plugin"; + const qualifiedName = "foo_my_plugin"; + + function setupScoped( + overrides?: Partial, + ): IPluginBuildOptions { + return { + ...setup({ addManifest: true }), + pluginName: scopedPluginName, + aarName: qualifiedName, + ...overrides, + }; + } + + function writePreviousOutput(name: string, content: string) { + fs.writeFile( + path.join( + tempFolder, + name, + "build", + "outputs", + "aar", + `${name}-release.aar`, + ), + content, + ); + } + + it("uses aarName for the temp project, the aar and the namespace fallback", async () => { + const config = setupScoped(); + + assert.isTrue(await androidBuildPluginService.buildAar(config)); + + const buildGradle = fs.readText( + path.join(tempFolder, qualifiedName, "build.gradle"), + ); + assert.include( + buildGradle, + `namespace "org.nativescript.${qualifiedName}"`, + ); + assert.isTrue(fs.exists(path.join(pluginFolder, `${qualifiedName}.aar`))); + assert.isFalse(fs.exists(path.join(tempFolder, shortPluginName))); + assert.isFalse( + fs.exists(path.join(pluginFolder, `${shortPluginName}.aar`)), + ); + assert.deepStrictEqual(ignoredFiles, [ + path.join(pluginFolder, `${qualifiedName}.aar`), + ]); + }); + + it("looks for the previous aar in aarOutputDir", async () => { + const outputDir = mkdtempSync(path.join(tmpdir(), "aar-output-")); + const config: IPluginBuildOptions = { + ...setup({ addManifest: true, addPreviousBuildInfo: true }), + aarOutputDir: outputDir, + }; + + await androidBuildPluginService.buildAar(config); + assert.isTrue(spawnFromEventCalled); + assert.isTrue(fs.exists(path.join(outputDir, `${shortPluginName}.aar`))); + + spawnFromEventCalled = false; + await androidBuildPluginService.buildAar(config); + assert.isFalse(spawnFromEventCalled); + }); + + it("does not hash any aar the plugin could have been built to", async () => { + const config = setupScoped(); + for (const name of [shortPluginName, qualifiedName, "unrelated"]) { + fs.writeFile(path.join(pluginFolder, `${name}.aar`), name); + } + writePreviousOutput(shortPluginName, shortPluginName); + + await androidBuildPluginService.buildAar(config); + + assert.include(hashedFiles, path.join(pluginFolder, "unrelated.aar")); + assert.notInclude( + hashedFiles, + path.join(pluginFolder, `${shortPluginName}.aar`), + ); + assert.notInclude( + hashedFiles, + path.join(pluginFolder, `${qualifiedName}.aar`), + ); + }); + + it("deletes an aar the CLI built under the plugin's other name", async () => { + const config = setupScoped(); + const staleAar = path.join(pluginFolder, `${shortPluginName}.aar`); + fs.writeFile(staleAar, "previous build"); + writePreviousOutput(shortPluginName, "previous build"); + + await androidBuildPluginService.buildAar(config); + + assert.isFalse(fs.exists(staleAar)); + assert.include(ignoredFiles, staleAar); + assert.isTrue(fs.exists(path.join(pluginFolder, `${qualifiedName}.aar`))); + }); + + it("fails on an aar under the plugin's other name that the CLI did not build", async () => { + const config = setupScoped(); + const staleAar = path.join(pluginFolder, `${shortPluginName}.aar`); + fs.writeFile(staleAar, "shipped"); + writePreviousOutput(shortPluginName, "something else"); + + let error: Error = null; + try { + await androidBuildPluginService.buildAar(config); + } catch (err) { + error = err; + } + + assert.include(error?.message, staleAar); + assert.include(error?.message, "Delete it or run `ns clean`"); + assert.isTrue(fs.exists(staleAar)); + assert.isFalse(spawnFromEventCalled); + }); + + it("leaves other candidate aars alone when no aarName is given", async () => { + const config: IPluginBuildOptions = { + ...setup({ addManifest: true }), + pluginName: scopedPluginName, + }; + const otherAar = path.join(pluginFolder, `${qualifiedName}.aar`); + fs.writeFile(otherAar, "kept"); + + await androidBuildPluginService.buildAar(config); + + assert.isTrue(fs.exists(otherAar)); + assert.isTrue( + fs.exists(path.join(pluginFolder, `${shortPluginName}.aar`)), + ); + }); + + it("builds into the CLI-owned output dir when node_modules is read-only", async () => { + const mirrorDir = mkdtempSync(path.join(tmpdir(), "aar-mirror-")); + const config = setupScoped({ + aarOutputDir: mirrorDir, + readOnlyNodeModules: true, + }); + const staleAar = path.join(mirrorDir, `${shortPluginName}.aar`); + fs.writeFile(staleAar, "not ours"); + + await androidBuildPluginService.buildAar(config); + + assert.isFalse(fs.exists(staleAar)); + assert.isTrue(fs.exists(path.join(mirrorDir, `${qualifiedName}.aar`))); + assert.deepStrictEqual(fs.readDirectory(pluginFolder), [ + "AndroidManifest.xml", + ]); + assert.include(lastGradleArgs, "-Pnativescript.readOnlyNodeModules=true"); + assert.deepStrictEqual(ignoredFiles, []); + }); + + it("passes the facts file to Gradle only when it exists", async () => { + const factsFile = path.join( + mkdtempSync(path.join(tmpdir(), "facts-")), + "nativescript-gradle.json", + ); + const config = setupScoped({ gradleFactsFile: factsFile }); + + await androidBuildPluginService.buildAar(config); + assert.isTrue(spawnFromEventCalled); + assert.isFalse( + lastGradleArgs.some((arg) => arg.startsWith("-Pnativescript.")), + ); + + fs.writeFile(factsFile, "{}"); + fs.deleteDirectory(path.join(tempFolder, qualifiedName)); + await androidBuildPluginService.buildAar(config); + assert.include(lastGradleArgs, `-Pnativescript.factsFile=${factsFile}`); + }); + + describe("hasOutdatedAar", () => { + it("is false for a plugin without sources to build", () => { + const config: IPluginBuildOptions = { + ...setup({ addIncludeGradle: true }), + aarName: shortPluginName, + }; + assert.isFalse(androidBuildPluginService.hasOutdatedAar(config)); + }); + + it("is true while the aar is missing", () => { + const config = setupScoped(); + assert.isTrue(androidBuildPluginService.hasOutdatedAar(config)); + + fs.writeFile(path.join(pluginFolder, `${qualifiedName}.aar`), ""); + assert.isFalse(androidBuildPluginService.hasOutdatedAar(config)); + }); + + it("is true while an aar under another candidate name is there", () => { + const config = setupScoped(); + fs.writeFile(path.join(pluginFolder, `${qualifiedName}.aar`), ""); + fs.writeFile(path.join(pluginFolder, `${shortPluginName}.aar`), ""); + assert.isTrue(androidBuildPluginService.hasOutdatedAar(config)); + }); + }); + }); + describe("migrateIncludeGradle", () => { it("if there is a legacy include.gradle file", async () => { const config: IPluginBuildOptions = setup({ diff --git a/test/services/android-project-service.ts b/test/services/android-project-service.ts index 1961548012..1389666200 100644 --- a/test/services/android-project-service.ts +++ b/test/services/android-project-service.ts @@ -62,6 +62,7 @@ const createTestInjector = (): IInjector => { testInjector.register("staticConfig", { TRACK_FEATURE_USAGE_SETTING_NAME: "TrackFeatureUsage", }); + testInjector.register("androidPluginMirrorService", {}); return testInjector; }; @@ -102,7 +103,7 @@ describe("androidProjectService", () => { childProcess = injector.resolve("childProcess"); const getPlatformDataStub: sinon.SinonStub = sandbox.stub( androidProjectService, - "getPlatformData" + "getPlatformData", ); getPlatformDataStub.callsFake(() => { return { @@ -120,7 +121,7 @@ describe("androidProjectService", () => { await androidProjectService.buildProject( "local/local", projectData, - buildConfig + buildConfig, ); //assert @@ -136,7 +137,7 @@ describe("androidProjectService", () => { await androidProjectService.buildProject( "local/local", projectData, - buildConfig + buildConfig, ); //assert @@ -152,7 +153,7 @@ describe("androidProjectService", () => { await androidProjectService.buildProject( "local/local", projectData, - buildConfig + buildConfig, ); //assert @@ -169,7 +170,7 @@ describe("androidProjectService", () => { await androidProjectService.buildProject( "local/local", projectData, - buildConfig + buildConfig, ); //assert @@ -182,30 +183,30 @@ describe("androidProjectService", () => { const pathToAppResourcesDir = path.join(projectDir, "app", "App_Resources"); const pathToAppResourcesAndroid = path.join( pathToAppResourcesDir, - "Android" + "Android", ); const pathToPlatformsAndroid = path.join( projectDir, "platforms", - "android" + "android", ); const pathToResDirInPlatforms = path.join( pathToPlatformsAndroid, "app", "src", "main", - "res" + "res", ); const valuesV27Path = path.join(pathToResDirInPlatforms, "values-v27"); const valuesV28Path = path.join(pathToResDirInPlatforms, "values-v28"); const libsPath = path.join(pathToResDirInPlatforms, "libs"); const drawableHdpiPath = path.join( pathToResDirInPlatforms, - "drawable-hdpi" + "drawable-hdpi", ); const drawableLdpiPath = path.join( pathToResDirInPlatforms, - "drawable-ldpi" + "drawable-ldpi", ); let deletedDirs: string[] = []; let copiedFiles: { @@ -232,7 +233,7 @@ describe("androidProjectService", () => { }; fs.copyFile = ( sourceFileName: string, - destinationFileName: string + destinationFileName: string, ): void => { copiedFiles.push({ sourceFileName, destinationFileName }); }; @@ -245,7 +246,7 @@ describe("androidProjectService", () => { const androidToolsInfo = injector.resolve("androidToolsInfo"); androidToolsInfo.getToolsInfo = ( - config?: IProjectDir + config?: IProjectDir, ): IAndroidToolsInfoData => { return { compileSdkVersion, @@ -256,12 +257,12 @@ describe("androidProjectService", () => { describe("when new Android App_Resources structure is detected (post {N} 4.0 structure)", () => { const pathToSrcDirInAppResources = path.join( pathToAppResourcesAndroid, - "src" + "src", ); beforeEach(() => { const androidResourcesMigrationService = injector.resolve( - "androidResourcesMigrationService" + "androidResourcesMigrationService", ); androidResourcesMigrationService.hasMigrated = () => true; }); @@ -277,7 +278,7 @@ describe("androidProjectService", () => { "platforms", "android", "app", - "src" + "src", ), }, ]); @@ -333,7 +334,7 @@ describe("androidProjectService", () => { beforeEach(() => { const androidResourcesMigrationService = injector.resolve( - "androidResourcesMigrationService" + "androidResourcesMigrationService", ); androidResourcesMigrationService.hasMigrated = () => false; }); diff --git a/test/services/android/android-plugin-mirror-service.ts b/test/services/android/android-plugin-mirror-service.ts new file mode 100644 index 0000000000..0988810cf3 --- /dev/null +++ b/test/services/android/android-plugin-mirror-service.ts @@ -0,0 +1,194 @@ +import { assert } from "chai"; +import * as path from "path"; +import { mkdtempSync, statSync, utimesSync } from "fs"; +import { tmpdir } from "os"; +import { Yok } from "../../../lib/common/yok"; +import { FileSystem } from "../../../lib/common/file-system"; +import { AndroidPluginMirrorService } from "../../../lib/services/android/android-plugin-mirror-service"; +import { IFileSystem } from "../../../lib/common/declarations"; +import { IAndroidPluginMirror } from "../../../lib/definitions/android-plugin-migrator"; +import * as stubs from "../../stubs"; + +describe("androidPluginMirrorService", () => { + let fs: IFileSystem; + let mirrorService: AndroidPluginMirrorService; + let packageDir: string; + let mirrorsRoot: string; + let mirror: IAndroidPluginMirror; + + const upstream = (...parts: string[]) => path.join(packageDir, ...parts); + const mirrored = (...parts: string[]) => + path.join(mirror.mirrorDir, ...parts); + const mtimeOf = (file: string) => statSync(file).mtime.getTime(); + const setMtime = (file: string, seconds: number) => + utimesSync(file, seconds, seconds); + + beforeEach(() => { + const injector = new Yok(); + injector.register("injector", injector); + injector.register("fs", FileSystem); + injector.register("logger", stubs.LoggerStub); + injector.register("androidPluginMirrorService", AndroidPluginMirrorService); + fs = injector.resolve("fs"); + mirrorService = injector.resolve("androidPluginMirrorService"); + + const root = mkdtempSync(path.join(tmpdir(), "plugin-mirror-")); + packageDir = path.join(root, "node_modules", "@foo", "plugin-x"); + mirrorsRoot = path.join(root, "platforms", "android", ".ns-plugins"); + mirror = { + pluginName: "@foo/plugin-x", + packageDir, + mirrorDir: path.join(mirrorsRoot, "foo_plugin_x"), + excludedNativeFiles: ["plugin_x.aar"], + }; + + fs.writeJson(upstream("package.json"), { name: "@foo/plugin-x" }); + fs.writeFile(upstream("README.md"), "not mirrored"); + fs.writeFile(upstream("platforms", "android", "include.gradle"), "a"); + fs.writeFile(upstream("platforms", "android", "libs", "x.jar"), "jar"); + fs.writeFile(upstream("platforms", "android", "plugin_x.aar"), "stale"); + fs.writeFile(upstream("platforms", "ios", "Podfile"), "not mirrored"); + for (const file of [ + upstream("package.json"), + upstream("platforms", "android", "include.gradle"), + upstream("platforms", "android", "libs", "x.jar"), + ]) { + setMtime(file, 1_000_000); + } + }); + + it("copies package.json and platforms/android, keeping upstream mtimes", () => { + assert.deepStrictEqual(mirrorService.syncMirrors(mirrorsRoot, [mirror]), [ + "@foo/plugin-x", + ]); + + assert.deepStrictEqual(fs.readJson(mirrored("package.json")), { + name: "@foo/plugin-x", + }); + assert.equal( + fs.readText(mirrored("platforms", "android", "include.gradle")), + "a", + ); + assert.equal( + fs.readText(mirrored("platforms", "android", "libs", "x.jar")), + "jar", + ); + assert.equal( + mtimeOf(mirrored("platforms", "android", "libs", "x.jar")), + 1_000_000_000, + ); + assert.isFalse(fs.exists(mirrored("README.md"))); + assert.isFalse(fs.exists(mirrored("platforms", "ios"))); + assert.isFalse(fs.exists(mirrored("platforms", "android", "plugin_x.aar"))); + }); + + it("only rewrites files whose upstream content changed", () => { + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + fs.writeFile( + mirrored("platforms", "android", "include.gradle"), + "rewritten by the CLI", + ); + setMtime(upstream("package.json"), 2_000_000); + + assert.deepStrictEqual( + mirrorService.syncMirrors(mirrorsRoot, [mirror]), + [], + ); + assert.equal(mtimeOf(mirrored("package.json")), 1_000_000_000); + assert.equal( + fs.readText(mirrored("platforms", "android", "include.gradle")), + "rewritten by the CLI", + ); + + fs.writeFile(upstream("platforms", "android", "include.gradle"), "b"); + setMtime(upstream("platforms", "android", "include.gradle"), 3_000_000); + + assert.deepStrictEqual(mirrorService.syncMirrors(mirrorsRoot, [mirror]), [ + "@foo/plugin-x", + ]); + assert.equal( + fs.readText(mirrored("platforms", "android", "include.gradle")), + "b", + ); + assert.equal( + mtimeOf(mirrored("platforms", "android", "include.gradle")), + 3_000_000_000, + ); + assert.equal( + mtimeOf(mirrored("platforms", "android", "libs", "x.jar")), + 1_000_000_000, + ); + }); + + it("restores a mirrored file that was deleted", () => { + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + fs.deleteFile(mirrored("platforms", "android", "libs", "x.jar")); + + assert.deepStrictEqual(mirrorService.syncMirrors(mirrorsRoot, [mirror]), [ + "@foo/plugin-x", + ]); + assert.equal( + fs.readText(mirrored("platforms", "android", "libs", "x.jar")), + "jar", + ); + }); + + it("deletes files removed upstream but keeps files the CLI added", () => { + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + fs.writeFile(mirrored("platforms", "android", "foo_plugin_x.aar"), "built"); + fs.deleteDirectory(upstream("platforms", "android", "libs")); + + assert.deepStrictEqual(mirrorService.syncMirrors(mirrorsRoot, [mirror]), [ + "@foo/plugin-x", + ]); + assert.isFalse(fs.exists(mirrored("platforms", "android", "libs"))); + assert.equal( + fs.readText(mirrored("platforms", "android", "foo_plugin_x.aar")), + "built", + ); + }); + + it("recopies a file whose content changed without changing its size or mtime", () => { + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + const includeGradle = upstream("platforms", "android", "include.gradle"); + fs.writeFile(includeGradle, "b"); + setMtime(includeGradle, 1_000_000); + + const changed = mirrorService.syncMirrors(mirrorsRoot, [mirror]); + + assert.deepStrictEqual(changed, ["@foo/plugin-x"]); + assert.equal( + fs.readText(mirrored("platforms", "android", "include.gradle")), + "b", + ); + }); + + it("removes the mirrors of plugins that are gone", () => { + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + assert.isTrue(fs.exists(mirror.mirrorDir)); + + mirrorService.syncMirrors(mirrorsRoot, []); + + assert.isFalse(fs.exists(mirror.mirrorDir)); + assert.isTrue(fs.exists(packageDir)); + }); + + it("never writes into the package", () => { + const before = fs.enumerateFilesInDirectorySync(packageDir).map((file) => ({ + file, + content: fs.readText(file), + mtime: mtimeOf(file), + })); + + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + fs.writeFile(mirrored("platforms", "android", "include.gradle"), "x"); + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + + const after = fs.enumerateFilesInDirectorySync(packageDir).map((file) => ({ + file, + content: fs.readText(file), + mtime: mtimeOf(file), + })); + assert.deepStrictEqual(after, before); + }); +}); diff --git a/test/services/android/gradle-build-args-service.ts b/test/services/android/gradle-build-args-service.ts index ab87f5bbb2..8bb7c5764c 100644 --- a/test/services/android/gradle-build-args-service.ts +++ b/test/services/android/gradle-build-args-service.ts @@ -27,6 +27,8 @@ function createTestInjector(): IInjector { getProjectData: () => projectData, }); injector.register("hooksService", stubs.HooksServiceStub); + injector.register("fs", { exists: () => false }); + injector.register("options", {}); injector.register("gradleBuildArgsService", GradleBuildArgsService); injector.register("analyticsService", stubs.AnalyticsService); injector.register("staticConfig", { @@ -216,6 +218,51 @@ describe("GradleBuildArgsService", () => { } }); + describe("Gradle facts", () => { + const factsFile = path.resolve( + "/path/to/projectDir/platforms/android/nativescript-gradle.json", + ); + + const getArgs = async (existing: string[], options: object = {}) => { + const injector = createTestInjector(); + injector.resolve("logger").getLevel = (): string => "INFO"; + injector.register("fs", { + exists: (file: string) => existing.includes(file), + }); + injector.register("options", options); + const service: IGradleBuildArgsService = injector.resolve( + "gradleBuildArgsService", + ); + return service.getBuildTaskArgs({ release: false }); + }; + + it("passes the facts file when it exists", async () => { + const args = await getArgs([factsFile]); + assert.deepStrictEqual( + args, + ["assembleDebug"] + .concat(expectedInfoLoggingArgs) + .concat(expectedDebugBuildArgs) + .concat(`-Pnativescript.factsFile=${factsFile}`), + ); + }); + + it("does not pass a facts file that does not exist", async () => { + const args = await getArgs([]); + assert.isFalse(args.some((arg) => arg.includes("factsFile"))); + }); + + it("looks for the facts file in the host project", async () => { + const hostProjectPath = path.resolve("/path/to/host"); + const hostFactsFile = path.join( + hostProjectPath, + "nativescript-gradle.json", + ); + const args = await getArgs([hostFactsFile], { hostProjectPath }); + assert.include(args, `-Pnativescript.factsFile=${hostFactsFile}`); + }); + }); + describe("getCleanTaskArgs", async () => { const testCases = [ { diff --git a/test/services/gradle-api-include-gradle-extraction.ts b/test/services/gradle-api-include-gradle-extraction.ts new file mode 100644 index 0000000000..f71f379710 --- /dev/null +++ b/test/services/gradle-api-include-gradle-extraction.ts @@ -0,0 +1,42 @@ +import { assert } from "chai"; +import { readFileSync } from "fs"; +import * as path from "path"; +import { AndroidPluginBuildService } from "../../lib/services/android-plugin-build-service"; + +// gradle-api's plugin-build functional tests run this golden file in place of the CLI's +// extraction, so the two must not drift. +describe("include.gradle extraction used by gradle-api's functional tests", () => { + // Tests run compiled from dist/test, three levels below the repo root. + const gradleApiDir = path.join(__dirname, "..", "..", "..", "gradle-api"); + + it("extracts exactly the golden blocks from the sample plugin's include.gradle", () => { + const includeGradle = readFileSync( + path.join( + gradleApiDir, + "samples", + "sample-plugin", + "platforms", + "android", + "include.gradle", + ), + "utf8", + ); + const golden = readFileSync( + path.join( + gradleApiDir, + "src", + "functionalTest", + "resources", + "extracted", + "sample-plugin-include.gradle", + ), + "utf8", + ); + + const service = Object.create(AndroidPluginBuildService.prototype); + const blocks: string[] = + service.getIncludeGradleCompileDependenciesScope(includeGradle); + + assert.equal(blocks.join("\n"), golden); + }); +}); diff --git a/test/stubs.ts b/test/stubs.ts index c21b07ce22..35baf6cc3a 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -750,6 +750,10 @@ export class AndroidPluginBuildServiceStub implements IAndroidPluginBuildService migrateIncludeGradle(options: IPluginBuildOptions): boolean { return true; } + + hasOutdatedAar(options: IPluginBuildOptions): boolean { + return false; + } } export class PlatformProjectServiceStub diff --git a/vendor/gradle-plugin/build.gradle b/vendor/gradle-plugin/build.gradle index ee6de65efd..9687767509 100644 --- a/vendor/gradle-plugin/build.gradle +++ b/vendor/gradle-plugin/build.gradle @@ -120,6 +120,16 @@ buildscript { } } + // Opt-in: published plugins' buildscript.gradle files were written for the app build + // and may not expect to run in their own aar build. + if (project.findProperty("nativescript.readOnlyNodeModules") == "true") { + def ownBuildScriptGradle = file("${getDepPlatformDir(pluginData)}/buildscript.gradle") + if (ownBuildScriptGradle.exists()) { + outLogger.withStyle(Style.SuccessHeader).println "\t + applying the plugin's own buildscript from ${ownBuildScriptGradle}" + apply from: ownBuildScriptGradle, to: buildscript + } + } + def pathToPluginBuildScriptGradle = "$rootDir/buildscript.gradle" def pluginBuildScriptGradle = file(pathToPluginBuildScriptGradle) if (pluginBuildScriptGradle.exists()) { @@ -131,6 +141,16 @@ buildscript { } +// Only the dependencies/repositories blocks of the plugin's include.gradle are copied into this +// build, so its top-level `apply plugin: "org.nativescript.api"` never runs here. Applying the +// library whenever a buildscript.gradle put it on the classpath keeps those blocks able to use it. +if (project.findProperty("nativescript.readOnlyNodeModules") == "true") { + try { + apply plugin: Class.forName("org.nativescript.gradle.NativeScriptPlugin", true, buildscript.classLoader) + } catch (ClassNotFoundException ignored) { + } +} + def pluginDependencies allprojects {