From 61802347bbcf0c14b22b6c63888cddce9edadd52 Mon Sep 17 00:00:00 2001 From: rainy liu Date: Tue, 21 Jul 2026 10:43:29 +0800 Subject: [PATCH 1/2] implement android SDK and NDK resolving to aligh with Flutter SDKBad state: No element in RunCMakeBuilder.androidToolchainCmake Fixes #31 --- README.md | 40 ++ lib/src/native_toolchain/android_ndk.dart | 437 ++++++++++++++++++---- test/native_toolchain/ndk_test.dart | 147 +++++++- 3 files changed, 527 insertions(+), 97 deletions(-) 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/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/'), + ); + }); } From f4ae885b6ded48adac38ecddcb64dd23c0bd9a62 Mon Sep 17 00:00:00 2001 From: rainy liu Date: Tue, 21 Jul 2026 11:06:17 +0800 Subject: [PATCH 2/2] bump version to 0.3.0 --- CHANGELOG.md | 4 +++- pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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: