diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44d564d..da99c9f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,10 @@
# native_toolchain_cmake
-## 0.2.8
+## 0.3.0
- fix: properly resolve user-defines for android_home on Windows, [#37](https://github.com/rainyl/native_toolchain_cmake/issues/37)
+- feat: align android SDK and NDK resolver with Flutter's implementation, [#31](https://github.com/rainyl/native_toolchain_cmake/issues/31)
+- Breaking change: the Android SDK and NDK resolving logic may be a breaking change
## 0.2.7
diff --git a/README.md b/README.md
index 2e34d28..b9bf47d 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,46 @@ hooks:
ninja_version: null # e.g., "1.10.2"
```
+## NDK Discovery
+
+When targeting Android, the NDK root directory is resolved in the following
+order (aligned with the Flutter tool's
+[AndroidSdk.locateAndroidSdk](https://github.com/flutter/flutter/blob/master/packages/flutter_tools/lib/src/android/android_sdk.dart)):
+
+### SDK Root
+
+1. `user_defines.android.android_home` (if set in `pubspec.yaml`)
+2. `ANDROID_HOME` environment variable
+3. Platform-default install location:
+ - Linux: `$HOME/Android/Sdk`
+ - macOS: `$HOME/Library/Android/sdk`
+ - Windows: `%USERPROFILE%\AppData\Local\Android\Sdk`
+4. `
/sdk` fallback (for each hit above: if the directory itself is not a
+ valid SDK but `/sdk` is, that subdirectory is used).
+5. `aapt` on PATH → `parent.parent.parent` if it looks like an SDK root.
+6. `adb` on PATH → `parent.parent` if it looks like an SDK root.
+
+A directory qualifies as an SDK root if it contains a `platform-tools/` or
+`licenses/` subdirectory.
+
+### NDK Root
+
+For each SDK root found above:
+
+1. `ANDROID_NDK_HOME` (exact NDK root — highest priority)
+2. `ANDROID_NDK_PATH` (fallback when `ANDROID_NDK_HOME` is unset)
+3. `ANDROID_NDK_ROOT` (additional fallback)
+4. `/ndk//` directories sorted by `Version.parse` in descending
+ order (latest first; entries that cannot be parsed as a [Version] are
+ skipped).
+5. Linux-only legacy `/ndk-bundle/` (single-NDK layout).
+6. `ndk-build` on PATH → parent directory (lowest priority).
+
+If `user_defines.android.ndk_version` is specified, only NDK roots whose
+basename parses to the requested version are kept; candidates without a
+parseable basename (e.g. `ANDROID_NDK_HOME` pointing at a custom-named
+directory, or `ndk-bundle`) are skipped.
+
## Acknowledgements
- [native_toolchain_c](https://pub.dev/packages/native_toolchain_c)
diff --git a/lib/src/native_toolchain/android_ndk.dart b/lib/src/native_toolchain/android_ndk.dart
index 22aa42d..f6bb628 100644
--- a/lib/src/native_toolchain/android_ndk.dart
+++ b/lib/src/native_toolchain/android_ndk.dart
@@ -2,10 +2,15 @@
// Apache-2.0 license that can be found in the LICENSE file.
//
// This file is adapted from https://github.com/dart-lang/native/tree/main/pkgs/native_toolchain_c
-// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
-// for details. All rights reserved. Use of this source code is governed by a
-// BSD-style license that can be found in the LICENSE file.
+// Copyright (c) 2024, the Dart project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// The SDK / NDK root discovery order is aligned with the Flutter tool's
+// `AndroidSdk.locateAndroidSdk()` and `AndroidSdk.getNdkDirectoriesInResolutionOrder()`
+// (see https://github.com/flutter/flutter/blob/master/packages/flutter_tools/lib/src/android/android_sdk.dart).
+import 'dart:convert';
import 'dart:io';
import 'package:code_assets/code_assets.dart';
@@ -16,6 +21,7 @@ import '../builder/user_config.dart';
import '../tool/tool.dart';
import '../tool/tool_instance.dart';
import '../tool/tool_resolver.dart';
+import '../utils/run_process.dart';
import 'clang.dart';
final androidNdk = Tool(name: 'Android NDK', defaultResolver: _AndroidNdkResolver());
@@ -29,6 +35,27 @@ final androidNdkLlvmAr = Tool(name: llvmAr.name, defaultResolver: _AndroidNdkRes
/// [lld] with [Tool.defaultResolver] for the [OS.android] NDK.
final androidNdkLld = Tool(name: lld.name, defaultResolver: _AndroidNdkResolver());
+/// Environment variable documented at
+/// https://developer.android.com/studio/command-line/variables.html#envar
+const kAndroidHome = 'ANDROID_HOME';
+
+/// No official environment variable for the NDK root is documented:
+/// https://developer.android.com/tools/variables#envar
+/// The following three are the most commonly used.
+const kAndroidNdkHome = 'ANDROID_NDK_HOME';
+const kAndroidNdkPath = 'ANDROID_NDK_PATH';
+const kAndroidNdkRoot = 'ANDROID_NDK_ROOT';
+
+/// Host directory name under `/toolchains/llvm/prebuilt/` for each
+/// supported host OS.
+///
+/// From https://developer.android.com/ndk/guides/other_build_systems.
+const _llvmHostDirectoryName = {
+ 'macos': 'darwin-x86_64',
+ 'linux': 'linux-x86_64',
+ 'windows': 'windows-x86_64',
+};
+
class _AndroidNdkResolver implements ToolResolver {
@override
Future> resolve({
@@ -36,95 +63,349 @@ class _AndroidNdkResolver implements ToolResolver {
UserConfig? userConfig,
Map? environment,
}) async {
- final installLocationResolver = PathVersionResolver(
- wrappedResolver: ToolResolvers([
- RelativeToolResolver(
- toolName: 'Android NDK',
- wrappedResolver: PathToolResolver(
- toolName: 'ndk-build',
- executableName: Platform.isWindows ? 'ndk-build.cmd' : 'ndk-build',
- ),
- relativePath: Uri(path: ''),
- ),
- InstallLocationResolver(
- toolName: 'Android NDK',
- paths: [
- if (userConfig?.androidHome != null) ...[
- '${userConfig?.androidHome}/ndk/*/',
- if (Platform.isLinux) '${userConfig?.androidHome}/ndk-bundle/',
- ],
- if (Platform.isLinux) ...[r'$HOME/Android/Sdk/ndk/*/', r'$HOME/Android/Sdk/ndk-bundle/'],
- if (Platform.isMacOS) r'$HOME/Library/Android/sdk/ndk/*/',
- if (Platform.isWindows) r'$HOME/AppData/Local/Android/Sdk/ndk/*/',
- ],
- ),
- ]),
+ final env = environment ?? Platform.environment;
+
+ final sdkRoots = await _collectSdkRootCandidates(
+ environment: env,
+ userConfig: userConfig,
+ logger: logger,
);
+ if (sdkRoots.isEmpty) {
+ logger?.fine('No Android SDK root candidates found.');
+ } else {
+ logger?.fine('Android SDK root candidates: $sdkRoots');
+ }
- final ndkInstances = await installLocationResolver.resolve(logger: logger, environment: environment);
- // sort latest version first
- ndkInstances.sort(
- (a, b) => switch ((a.version, b.version)) {
- (null, null) => 0,
- (null, _) => 1,
- (_, null) => -1,
- (_, _) => -a.version!.compareTo(b.version!),
- },
+ final ndkCandidates = await _collectNdkRootCandidates(
+ sdkRoots: sdkRoots,
+ environment: env,
+ logger: logger,
);
- if (userConfig?.ndkVersion != null) {
- final ndkVer = Version.parse(userConfig!.ndkVersion!);
- ndkInstances.removeWhere((ndkInstance) => ndkInstance.version != ndkVer);
- if (ndkInstances.isEmpty) {
- logger?.severe('Failed to find NDK version: ${userConfig.ndkVersion}');
- throw Exception('Failed to find NDK version: ${userConfig.ndkVersion}');
+ if (ndkCandidates.isEmpty) {
+ logger?.fine('No Android NDK candidates found.');
+ return const [];
+ }
+ logger?.fine('Android NDK candidates (priority order): $ndkCandidates');
+
+ // Optional user-defined version filter.
+ var filtered = ndkCandidates;
+ final requestedNdkVersion = userConfig?.ndkVersion;
+ if (requestedNdkVersion != null) {
+ final target = Version.parse(requestedNdkVersion);
+ final kept = <({Uri uri, Version? version})>[];
+ for (final c in ndkCandidates) {
+ if (c.version == target) {
+ kept.add(c);
+ } else {
+ logger?.warning(
+ 'Skipping NDK candidate ${c.uri} (version=${c.version}) '
+ 'because it does not match requested ndk_version=$target.',
+ );
+ }
+ }
+ if (kept.isEmpty) {
+ logger?.severe('Failed to find NDK version: $requestedNdkVersion');
+ throw Exception('Failed to find NDK version: $requestedNdkVersion');
}
+ filtered = kept;
+ }
+
+ final ndkInstances = [];
+ var toolInstances = const [];
+ for (final c in filtered) {
+ ndkInstances.add(ToolInstance(tool: androidNdk, uri: c.uri, version: c.version));
+ if (toolInstances.isEmpty) {
+ // `getNdkBinaryPath` semantics: tools come from the first NDK that
+ // actually contains them. Stop probing once any tools are found.
+ final tools = await _resolveToolsForNdk(c.uri, logger: logger);
+ if (tools.isNotEmpty) {
+ toolInstances = tools;
+ }
+ }
+ }
+
+ return [...ndkInstances, ...toolInstances];
+ }
+
+ /// Builds the ordered, de-duplicated list of Android SDK root candidates.
+ ///
+ /// Resolution order (matches Flutter `AndroidSdk.locateAndroidSdk`):
+ /// 1. [UserConfig.androidHome]
+ /// 2. `ANDROID_HOME` environment variable
+ /// 3. Platform-default install location
+ /// 4. For each hit above also try `/sdk` (legacy layout)
+ /// 5. `aapt` on PATH -> parent.parent.parent if it passes
+ /// [_validSdkDirectory]
+ /// 6. `adb` on PATH -> parent.parent if it passes [_validSdkDirectory]
+ Future> _collectSdkRootCandidates({
+ required Map environment,
+ UserConfig? userConfig,
+ Logger? logger,
+ }) async {
+ final candidates = [];
+ final seen = {};
+
+ void addIfValid(String raw) {
+ if (raw.isEmpty) return;
+ final dir = Directory(raw).absolute.uri.normalizePath();
+ if (_tryAdd(dir, candidates, seen)) return;
+ // Legacy `/sdk` fallback used when ANDROID_HOME points at the
+ // user home rather than the SDK itself.
+ _tryAdd(dir.resolve('sdk/'), candidates, seen);
}
- return [
- for (final ndkInstance in ndkInstances) ...[
- ndkInstance,
- ...await tryResolveClang(ndkInstance, logger: logger),
- ],
+ // 1. userConfig.androidHome (already normalised to forward slashes).
+ if (userConfig?.androidHome != null) {
+ addIfValid(userConfig!.androidHome!);
+ }
+
+ // 2. ANDROID_HOME env var.
+ final envHome = environment[kAndroidHome];
+ if (envHome != null && envHome.isNotEmpty) {
+ addIfValid(envHome);
+ }
+
+ // 3. Platform-default install location.
+ final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
+ if (home != null && home.isNotEmpty) {
+ final defaultPath = switch (Platform.operatingSystem) {
+ 'linux' => '$home/Android/Sdk',
+ 'macos' => '$home/Library/Android/sdk',
+ 'windows' => '$home/AppData/Local/Android/Sdk',
+ _ => null,
+ };
+ if (defaultPath != null) addIfValid(defaultPath);
+ }
+
+ // 5/6. PATH aapt / adb reverse discovery.
+ // SDK layout: $SDK/build-tools//aapt -> parent.parent.parent == SDK
+ // $SDK/platform-tools/adb -> parent.parent == SDK
+ for (final aaptBin in await _whichAll('aapt', logger: logger, environment: environment)) {
+ addIfValid(aaptBin.resolve('../../..').toFilePath());
+ }
+ for (final adbBin in await _whichAll('adb', logger: logger, environment: environment)) {
+ addIfValid(adbBin.resolve('../..').toFilePath());
+ }
+
+ return candidates;
+ }
+
+ /// Inserts [dir] into [candidates] if it is a valid SDK root and has not
+ /// been added yet. Returns `true` when the directory is either freshly
+ /// added or was already present in [seen] (so callers can use its return
+ /// value to skip the legacy `/sdk` fallback without adding a
+ /// redundant second entry).
+ static bool _tryAdd(Uri dir, List candidates, Set seen) {
+ if (!_validSdkDirectory(dir)) return false;
+ final key = dir.toString();
+ if (seen.contains(key)) return true;
+ seen.add(key);
+ candidates.add(dir);
+ return true;
+ }
+
+ /// Whether [dir] looks like an Android SDK root. Mirrors Flutter
+ /// `AndroidSdk.validSdkDirectory` (must contain `platform-tools/` or
+ /// `licenses/`).
+ static bool _validSdkDirectory(Uri dir) {
+ return Directory.fromUri(dir.resolve('platform-tools/')).existsSync() ||
+ Directory.fromUri(dir.resolve('licenses/')).existsSync();
+ }
+
+ /// Returns every executable named [name] found on `PATH`.
+ ///
+ /// Uses `where` on Windows and `which -a` on POSIX, then resolves symlinks.
+ /// Failures (binary not installed) return an empty list so callers can chain
+ /// without try/catch.
+ Future> _whichAll(String name, {Logger? logger, Map? environment}) async {
+ final executable = Uri.file(Platform.isWindows ? 'where' : 'which');
+ final arguments = [
+ if (!Platform.isWindows) '-a',
+ name,
];
+ try {
+ final result = await runProcess(
+ executable: executable,
+ arguments: arguments,
+ logger: logger,
+ environment: environment,
+ captureOutput: true,
+ throwOnUnexpectedExitCode: false,
+ );
+ if (result.exitCode != 0) return const [];
+ final out = [];
+ for (final raw in LineSplitter.split(result.stdout)) {
+ final path = raw.trim();
+ if (path.isEmpty) continue;
+ final file = File(path);
+ try {
+ if (await file.exists()) {
+ out.add(File(await file.resolveSymbolicLinks()).uri);
+ }
+ } on FileSystemException {
+ // Stale entry / disappeared between `which` and the existence check.
+ }
+ }
+ return out;
+ } on ProcessException {
+ return const [];
+ }
}
- Future> tryResolveClang(
- ToolInstance androidNdkInstance, {
- required Logger? logger,
+ /// Builds the ordered, de-duplicated list of NDK root candidates.
+ ///
+ /// Resolution order (matches Flutter `getNdkDirectoriesInResolutionOrder`):
+ /// 1. `ANDROID_NDK_HOME`
+ /// 2. `ANDROID_NDK_PATH`
+ /// 3. `ANDROID_NDK_ROOT`
+ /// 4. For each entry in [sdkRoots]: `/ndk//` enumerated in
+ /// descending `Version.parse` order (entries that don't parse are
+ /// skipped). Linux-only legacy `/ndk-bundle/` is appended
+ /// afterwards.
+ /// 5. `ndk-build` on PATH (lowest priority fallback).
+ Future> _collectNdkRootCandidates({
+ required List sdkRoots,
+ required Map environment,
+ Logger? logger,
}) async {
- final result = [];
- final prebuiltUri = androidNdkInstance.uri.resolve('toolchains/llvm/prebuilt/');
- final prebuiltDir = Directory.fromUri(prebuiltUri);
- final hostArchDirs = (await prebuiltDir.list().toList()).whereType().toList();
- for (final hostArchDir in hostArchDirs) {
- final clangUri = hostArchDir.uri.resolve('bin/').resolve(OS.current.executableFileName('clang'));
- if (await File.fromUri(clangUri).exists()) {
- result.add(
- await CliVersionResolver.lookupVersion(
- ToolInstance(tool: androidNdkClang, uri: clangUri),
- logger: logger,
- ),
- );
+ final out = <({Uri uri, Version? version})>[];
+ final seen = {};
+
+ void addIfNew(Uri uri, {Version? version}) {
+ final key = uri.toString();
+ if (seen.contains(key)) return;
+ seen.add(key);
+ out.add((uri: uri, version: version));
+ }
+
+ // 1-3. NDK env vars (verbatim NDK root paths).
+ for (final v in const [kAndroidNdkHome, kAndroidNdkPath, kAndroidNdkRoot]) {
+ final path = environment[v];
+ if (path == null || path.isEmpty) continue;
+ final uri = Directory(path).absolute.uri.normalizePath();
+ if (Directory.fromUri(uri).existsSync()) {
+ addIfNew(uri, version: _tryParseVersion(uri));
+ } else {
+ logger?.warning('$v=$path does not exist, ignoring.');
}
- final arUri = hostArchDir.uri.resolve('bin/').resolve(OS.current.executableFileName('llvm-ar'));
- if (await File.fromUri(arUri).exists()) {
- result.add(
- await CliVersionResolver.lookupVersion(
- ToolInstance(tool: androidNdkLlvmAr, uri: arUri),
- logger: logger,
- ),
- );
+ }
+
+ // 4. /ndk// discovery + Linux ndk-bundle.
+ for (final sdkDir in sdkRoots) {
+ final ndkDir = sdkDir.resolve('ndk/');
+ final ndkDirectory = Directory.fromUri(ndkDir);
+ if (ndkDirectory.existsSync()) {
+ final versions = [];
+ for (final entity in ndkDirectory.listSync()) {
+ if (entity is! Directory) continue;
+ try {
+ versions.add(Version.parse(_basename(entity.uri)));
+ } on Exception {
+ // Mirror Flutter `getNdkDirectoriesInResolutionOrder`:
+ // entries whose dirname is not a parseable Version are skipped.
+ }
+ }
+ versions.sort((a, b) => -a.compareTo(b));
+ for (final v in versions) {
+ addIfNew(ndkDir.resolve('$v/'), version: v);
+ }
}
- final ldUri = hostArchDir.uri.resolve('bin/').resolve(OS.current.executableFileName('ld.lld'));
- if (await File.fromUri(arUri).exists()) {
- result.add(
- await CliVersionResolver.lookupVersion(
- ToolInstance(tool: androidNdkLld, uri: ldUri),
- logger: logger,
- ),
- );
+ if (Platform.isLinux) {
+ // Pre-`ndk//` layout where the single NDK lived under
+ // `/ndk-bundle/`.
+ final ndkBundle = sdkDir.resolve('ndk-bundle/');
+ if (Directory.fromUri(ndkBundle).existsSync()) {
+ addIfNew(ndkBundle, version: null);
+ }
}
}
+
+ // 5. ndk-build on PATH (lowest priority).
+ final ndkBuildResolver = PathToolResolver(
+ toolName: 'ndk-build',
+ executableName: Platform.isWindows ? 'ndk-build.cmd' : 'ndk-build',
+ );
+ final ndkBuildInstances = await ndkBuildResolver.resolve(
+ logger: logger,
+ environment: environment,
+ );
+ for (final instance in ndkBuildInstances) {
+ // `/ndk-build(.cmd)` -> NDK root is parent.
+ addIfNew(instance.uri.resolve('..').normalizePath(), version: null);
+ }
+
+ return out;
+ }
+
+ /// Resolves `clang`, `llvm-ar`, `ld.lld` binaries bundled with [ndkRoot].
+ ///
+ /// Probes the single host directory documented by the NDK for the current
+ /// operating system first (see [_llvmHostDirectoryName]). If that directory
+ /// does not exist falls back to listing every entry under `prebuilt/` so
+ /// future host triples (e.g. `darwin-arm64`) keep working. The previous
+ /// implementation had a copy-paste bug where the `ld.lld` existence check
+ /// used the `llvm-ar` path; this is fixed here.
+ Future> _resolveToolsForNdk(Uri ndkRoot, {Logger? logger}) async {
+ final prebuiltBase = ndkRoot.resolve('toolchains/llvm/prebuilt/');
+
+ final probes = [];
+ final hostName = _llvmHostDirectoryName[Platform.operatingSystem];
+ if (hostName != null) {
+ final known = prebuiltBase.resolve('$hostName/');
+ if (Directory.fromUri(known).existsSync()) {
+ probes.add(known);
+ }
+ }
+ if (probes.isEmpty) {
+ final prebuiltDir = Directory.fromUri(prebuiltBase);
+ if (prebuiltDir.existsSync()) {
+ for (final entity in prebuiltDir.listSync()) {
+ if (entity is Directory) probes.add(entity.uri.normalizePath());
+ }
+ }
+ }
+
+ final result = [];
+ for (final hostDir in probes) {
+ final bin = hostDir.resolve('bin/');
+ await _addVersionedTool(result, bin, 'clang', androidNdkClang, logger: logger);
+ await _addVersionedTool(result, bin, 'llvm-ar', androidNdkLlvmAr, logger: logger);
+ await _addVersionedTool(result, bin, 'ld.lld', androidNdkLld, logger: logger);
+ }
return result;
}
+
+ /// Adds the version-resolved [tool] under [binDir] to [result] if the
+ /// executable exists. No-op otherwise.
+ static Future _addVersionedTool(
+ List result,
+ Uri binDir,
+ String exeName,
+ Tool tool, {
+ Logger? logger,
+ }) async {
+ final uri = binDir.resolve(OS.current.executableFileName(exeName));
+ if (!await File.fromUri(uri).exists()) return;
+ result.add(
+ await CliVersionResolver.lookupVersion(
+ ToolInstance(tool: tool, uri: uri),
+ logger: logger,
+ ),
+ );
+ }
+
+ /// Parses the last non-empty path segment of [uri] as a [Version].
+ static Version? _tryParseVersion(Uri uri) {
+ try {
+ return Version.parse(_basename(uri));
+ } on Exception {
+ return null;
+ }
+ }
+
+ /// Returns the last non-empty path segment of [uri].
+ static String _basename(Uri uri) {
+ final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList();
+ return segments.isEmpty ? '' : segments.last;
+ }
}
diff --git a/pubspec.yaml b/pubspec.yaml
index 66e807b..5b09cb5 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: native_toolchain_cmake
description: >-
A library to invoke and build CMake projects for Dart Native Assets.
-version: 0.2.8
+version: 0.3.0
repository: https://github.com/rainyl/native_toolchain_cmake
topics:
diff --git a/test/native_toolchain/ndk_test.dart b/test/native_toolchain/ndk_test.dart
index 4e4632a..425d7df 100644
--- a/test/native_toolchain/ndk_test.dart
+++ b/test/native_toolchain/ndk_test.dart
@@ -12,6 +12,23 @@ import 'package:test/test.dart';
import '../helpers.dart';
+/// Creates the minimal on-disk layout needed to pass `_validSdkDirectory`.
+///
+/// Returns the [Directory] backing [uri].
+Future _createFakeSdk(Uri uri) async {
+ final dir = await Directory.fromUri(uri).create(recursive: true);
+ await Directory.fromUri(uri.resolve('platform-tools/')).create();
+ return dir;
+}
+
+/// Creates a fake NDK version directory under [sdkUri]/ndk/.
+Future _createFakeNdk(Uri sdkUri, String version) async {
+ final ndkDir = Directory.fromUri(sdkUri.resolve('ndk/').resolve('$version/'));
+ await ndkDir.create(recursive: true);
+ await Directory.fromUri(ndkDir.uri.resolve('toolchains/llvm/prebuilt/')).create(recursive: true);
+ return ndkDir;
+}
+
void main() {
test('NDK smoke test', () async {
final requirement = RequireAll([
@@ -35,24 +52,12 @@ void main() {
// on-disk SDK tree via a backslash-laden path (on Windows) or a
// forward-slash path (other OSes), then asserts the NDK is discovered.
test('issue-37-windows-backslash', () async {
- // Build a fake SDK layout: /ndk//
final tempUri = await tempDirForTest();
- final sdkDir = await Directory.fromUri(tempUri.resolve("android_sdk")).create(recursive: true);
+ final sdkDir = await _createFakeSdk(tempUri.resolve('android_sdk/'));
- // Use an obviously-fake, very-high version so this fixture never collides
- // with any real NDK installed under $HOME/AppData/Local/Android/Sdk (which
- // the resolver also searches on Windows regardless of androidHome).
const ndkVersion = '99.99.99999';
- final ndkDir = Directory.fromUri(sdkDir.uri.resolve('ndk/').resolve('$ndkVersion/'));
- await ndkDir.create(recursive: true);
- // tryResolveClang lists `toolchains/llvm/prebuilt/`; create it empty so
- // the resolver returns no clang/ar/lld instances without throwing.
- await Directory.fromUri(ndkDir.uri.resolve('toolchains/llvm/prebuilt/')).create(recursive: true);
-
- // Use the OS-native path which on Windows contains backslashes; on
- // other platforms it is already forward-slash. Either way the test must
- // pass, demonstrating that UserConfig normalisation keeps the glob
- // pattern valid.
+ await _createFakeNdk(sdkDir.uri, ndkVersion);
+
final rawAndroidHome = sdkDir.absolute.path;
expect(
UserConfig(targetOS: OS.android, androidHome: rawAndroidHome).androidHome,
@@ -67,10 +72,6 @@ void main() {
envVarAndroidHomeAsDefault: false,
);
- // If the androidHome backslashes are NOT normalised before being fed to
- // `Glob('/ndk/*/')` package:glob treats `\` as an escape and
- // the pattern matches nothing under our fixture, so the resolver cannot
- // find an NDK with the requested version and throws.
final resolved = await androidNdk.defaultResolver!.resolve(logger: logger, userConfig: userConfig);
final ndkInstances = resolved.where((t) => t.tool == androidNdk).toList();
@@ -81,4 +82,112 @@ void main() {
reason: 'Resolved NDK URI must point at the fixture we created',
);
});
+
+ test('env var ANDROID_NDK_HOME is highest priority', () async {
+ final tempUri = await tempDirForTest();
+ final ndkDir = await _createFakeNdk(tempUri.resolve('custom_sdk/'), '99.99.99999');
+
+ final environment = {kAndroidNdkHome: ndkDir.absolute.path};
+ final userConfig = UserConfig(
+ targetOS: OS.android,
+ ndkVersion: '99.99.99999',
+ envVarAndroidHomeAsDefault: false,
+ );
+
+ final resolved = await androidNdk.defaultResolver!.resolve(
+ logger: logger,
+ userConfig: userConfig,
+ environment: environment,
+ );
+
+ final ndkInstances = resolved.where((t) => t.tool == androidNdk).toList();
+ expect(ndkInstances, isNotEmpty, reason: 'NDK must be found via ANDROID_NDK_HOME');
+ expect(
+ ndkInstances.first.uri.toFilePath().replaceAll(r'\', '/'),
+ ndkDir.absolute.uri.toFilePath().replaceAll(r'\', '/'),
+ );
+ });
+
+ test('ndkVersion filter skips env-var roots without parseable basename', () async {
+ final tempUri = await tempDirForTest();
+ // Give the NDK root a non-parseable name, e.g. not a Version.
+ final ndkRoot = await Directory.fromUri(tempUri.resolve('my_ndk_dir/')).create(recursive: true);
+
+ final environment = {kAndroidNdkHome: ndkRoot.absolute.path};
+ final userConfig = UserConfig(
+ targetOS: OS.android,
+ ndkVersion: '99.99.99999',
+ envVarAndroidHomeAsDefault: false,
+ );
+
+ // Skipping to avoid spamming logger output in the test.
+ // Expect an exception, as no candidate's basename matches 99.99.99999.
+ expect(
+ () => androidNdk.defaultResolver!.resolve(
+ logger: logger,
+ userConfig: userConfig,
+ environment: environment,
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ test('dir/sdk fallback when androidHome points at parent', () async {
+ final tempUri = await tempDirForTest();
+ // Layout: /sdk/ is the actual SDK.
+ // / itself does NOT contain platform-tools.
+ final sdkDir = await _createFakeSdk(tempUri.resolve('home/sdk/'));
+ await _createFakeNdk(sdkDir.uri, '99.99.99999');
+
+ // Point androidHome at (not /sdk).
+ final homeDir = Directory.fromUri(tempUri.resolve('home/'));
+
+ final userConfig = UserConfig(
+ targetOS: OS.android,
+ androidHome: homeDir.absolute.path,
+ ndkVersion: '99.99.99999',
+ envVarAndroidHomeAsDefault: false,
+ );
+
+ final resolved = await androidNdk.defaultResolver!.resolve(logger: logger, userConfig: userConfig);
+
+ final ndkInstances = resolved.where((t) => t.tool == androidNdk).toList();
+ expect(ndkInstances, hasLength(1), reason: 'NDK must be found via /sdk fallback');
+ expect(
+ ndkInstances.single.uri.toFilePath().replaceAll(r'\', '/'),
+ contains('/sdk/ndk/99.99.99999/'),
+ );
+ });
+
+ test('ANDROID_NDK_HOME is skipped when nonexistent, falls through to platform', () async {
+ final tempUri = await tempDirForTest();
+ final sdkDir = await _createFakeSdk(tempUri.resolve('android_sdk/'));
+ await _createFakeNdk(sdkDir.uri, '99.99.99999');
+
+ // ANDROID_NDK_HOME points to a non-existent directory; this should be
+ // ignored with a warning, and the platform-default discovery should still
+ // find the real SDK (or the fixture if ANDROID_HOME is set to it).
+ final environment = {
+ kAndroidNdkHome: tempUri.resolve('does_not_exist/').toFilePath(),
+ kAndroidHome: sdkDir.absolute.path,
+ };
+ final userConfig = UserConfig(
+ targetOS: OS.android,
+ ndkVersion: '99.99.99999',
+ envVarAndroidHomeAsDefault: false,
+ );
+
+ final resolved = await androidNdk.defaultResolver!.resolve(
+ logger: logger,
+ userConfig: userConfig,
+ environment: environment,
+ );
+
+ final ndkInstances = resolved.where((t) => t.tool == androidNdk).toList();
+ expect(ndkInstances, hasLength(1), reason: 'Should fall through to ANDROID_HOME');
+ expect(
+ ndkInstances.single.uri.toFilePath().replaceAll(r'\', '/'),
+ contains('/ndk/99.99.99999/'),
+ );
+ });
}