Add Android NDK API version (#49)

diff --git a/pkgs/c_compiler/lib/src/cbuilder/run_cbuilder.dart b/pkgs/c_compiler/lib/src/cbuilder/run_cbuilder.dart
index b535183..9a89459 100644
--- a/pkgs/c_compiler/lib/src/cbuilder/run_cbuilder.dart
+++ b/pkgs/c_compiler/lib/src/cbuilder/run_cbuilder.dart
@@ -95,7 +95,9 @@
           // The sysroot should be discovered automatically after NDK 22.
           // Workaround:
           if (dynamicLibrary != null) '-nostartfiles',
-          '--target=${androidNdkClangTargetFlags[target]!}',
+          '--target='
+              '${androidNdkClangTargetFlags[target]!}'
+              '${buildConfig.targetAndroidNdkApi!}',
         ],
         if (target.os == OS.macOS || target.os == OS.iOS)
           '--target=${appleClangTargetFlags[target]!}',
diff --git a/pkgs/c_compiler/test/cbuilder/cbuilder_cross_android_test.dart b/pkgs/c_compiler/test/cbuilder/cbuilder_cross_android_test.dart
index 967b98f..6ea3520 100644
--- a/pkgs/c_compiler/test/cbuilder/cbuilder_cross_android_test.dart
+++ b/pkgs/c_compiler/test/cbuilder/cbuilder_cross_android_test.dart
@@ -33,37 +33,22 @@
     Target.androidX64: 'elf64-x86-64',
   };
 
+  /// From https://docs.flutter.dev/reference/supported-platforms.
+  const flutterAndroidNdkVersionLowestSupported = 21;
+
+  /// From https://docs.flutter.dev/reference/supported-platforms.
+  const flutterAndroidNdkVersionHighestSupported = 30;
+
   for (final linkMode in LinkMode.values) {
     for (final target in targets) {
       test('Cbuilder $linkMode library $target', () async {
         await inTempDir((tempUri) async {
-          final addCUri =
-              packageUri.resolve('test/cbuilder/testfiles/add/src/add.c');
-          const name = 'add';
-
-          final buildConfig = BuildConfig(
-            outDir: tempUri,
-            packageRoot: tempUri,
-            target: target,
-            linkModePreference: linkMode == LinkMode.dynamic
-                ? LinkModePreference.dynamic
-                : LinkModePreference.static,
+          final libUri = await buildLib(
+            tempUri,
+            target,
+            flutterAndroidNdkVersionLowestSupported,
+            linkMode,
           );
-          final buildOutput = BuildOutput();
-
-          final cbuilder = CBuilder.library(
-            name: 'add',
-            assetName: 'add',
-            sources: [addCUri.toFilePath()],
-          );
-          await cbuilder.run(
-            buildConfig: buildConfig,
-            buildOutput: buildOutput,
-            logger: logger,
-          );
-
-          final libUri =
-              tempUri.resolve(target.os.libraryFileName(name, linkMode));
           if (Platform.isLinux) {
             final result = await runProcess(
               executable: Uri.file('readelf'),
@@ -91,4 +76,64 @@
       });
     }
   }
+
+  test('Cbuilder API levels binary difference', () async {
+    const target = Target.androidArm64;
+    const linkMode = LinkMode.dynamic;
+    const apiLevel1 = flutterAndroidNdkVersionLowestSupported;
+    const apiLevel2 = flutterAndroidNdkVersionHighestSupported;
+    await inTempDir((tempUri) async {
+      final out1Uri = tempUri.resolve('out1/');
+      final out2Uri = tempUri.resolve('out2/');
+      final out3Uri = tempUri.resolve('out3/');
+      await Directory.fromUri(out1Uri).create();
+      await Directory.fromUri(out2Uri).create();
+      await Directory.fromUri(out3Uri).create();
+      final lib1Uri = await buildLib(out1Uri, target, apiLevel1, linkMode);
+      final lib2Uri = await buildLib(out2Uri, target, apiLevel2, linkMode);
+      final lib3Uri = await buildLib(out3Uri, target, apiLevel2, linkMode);
+      final bytes1 = await File.fromUri(lib1Uri).readAsBytes();
+      final bytes2 = await File.fromUri(lib2Uri).readAsBytes();
+      final bytes3 = await File.fromUri(lib3Uri).readAsBytes();
+      // Different API levels should lead to a different binary.
+      expect(bytes1, isNot(bytes2));
+      // Identical API levels should lead to an identical binary.
+      expect(bytes2, bytes3);
+    });
+  });
+}
+
+Future<Uri> buildLib(
+  Uri tempUri,
+  Target target,
+  int androidNdkApi,
+  LinkMode linkMode,
+) async {
+  final addCUri = packageUri.resolve('test/cbuilder/testfiles/add/src/add.c');
+  const name = 'add';
+
+  final buildConfig = BuildConfig(
+    outDir: tempUri,
+    packageRoot: tempUri,
+    target: target,
+    targetAndroidNdkApi: androidNdkApi,
+    linkModePreference: linkMode == LinkMode.dynamic
+        ? LinkModePreference.dynamic
+        : LinkModePreference.static,
+  );
+  final buildOutput = BuildOutput();
+
+  final cbuilder = CBuilder.library(
+    name: name,
+    assetName: name,
+    sources: [addCUri.toFilePath()],
+  );
+  await cbuilder.run(
+    buildConfig: buildConfig,
+    buildOutput: buildOutput,
+    logger: logger,
+  );
+
+  final libUri = tempUri.resolve(target.os.libraryFileName(name, linkMode));
+  return libUri;
 }
diff --git a/pkgs/c_compiler/test/cbuilder/cbuilder_cross_ios_test.dart b/pkgs/c_compiler/test/cbuilder/cbuilder_cross_ios_test.dart
index e4dcf00..bbcb70d 100644
--- a/pkgs/c_compiler/test/cbuilder/cbuilder_cross_ios_test.dart
+++ b/pkgs/c_compiler/test/cbuilder/cbuilder_cross_ios_test.dart
@@ -53,8 +53,8 @@
             final buildOutput = BuildOutput();
 
             final cbuilder = CBuilder.library(
-              name: 'add',
-              assetName: 'add',
+              name: name,
+              assetName: name,
               sources: [addCUri.toFilePath()],
             );
             await cbuilder.run(
diff --git a/pkgs/c_compiler/test/cbuilder/cbuilder_cross_linux_host_test.dart b/pkgs/c_compiler/test/cbuilder/cbuilder_cross_linux_host_test.dart
index 9a4f34a..0142554 100644
--- a/pkgs/c_compiler/test/cbuilder/cbuilder_cross_linux_host_test.dart
+++ b/pkgs/c_compiler/test/cbuilder/cbuilder_cross_linux_host_test.dart
@@ -53,8 +53,8 @@
           final buildOutput = BuildOutput();
 
           final cbuilder = CBuilder.library(
-            name: 'add',
-            assetName: 'add',
+            name: name,
+            assetName: name,
             sources: [addCUri.toFilePath()],
           );
           await cbuilder.run(
diff --git a/pkgs/c_compiler/test/cbuilder/cbuilder_cross_macos_host_test.dart b/pkgs/c_compiler/test/cbuilder/cbuilder_cross_macos_host_test.dart
index 06f4ff1..b68e924 100644
--- a/pkgs/c_compiler/test/cbuilder/cbuilder_cross_macos_host_test.dart
+++ b/pkgs/c_compiler/test/cbuilder/cbuilder_cross_macos_host_test.dart
@@ -53,8 +53,8 @@
           final buildOutput = BuildOutput();
 
           final cbuilder = CBuilder.library(
-            name: 'add',
-            assetName: 'add',
+            name: name,
+            assetName: name,
             sources: [addCUri.toFilePath()],
           );
           await cbuilder.run(
diff --git a/pkgs/c_compiler/test/cbuilder/cbuilder_cross_windows_host_test.dart b/pkgs/c_compiler/test/cbuilder/cbuilder_cross_windows_host_test.dart
index 230c9af..f00feeb 100644
--- a/pkgs/c_compiler/test/cbuilder/cbuilder_cross_windows_host_test.dart
+++ b/pkgs/c_compiler/test/cbuilder/cbuilder_cross_windows_host_test.dart
@@ -65,8 +65,8 @@
           final buildOutput = BuildOutput();
 
           final cbuilder = CBuilder.library(
-            name: 'add',
-            assetName: 'add',
+            name: name,
+            assetName: name,
             sources: [addCUri.toFilePath()],
           );
           await cbuilder.run(
diff --git a/pkgs/native_assets_cli/lib/src/model/build_config.dart b/pkgs/native_assets_cli/lib/src/model/build_config.dart
index 5886efd..8e657cc 100644
--- a/pkgs/native_assets_cli/lib/src/model/build_config.dart
+++ b/pkgs/native_assets_cli/lib/src/model/build_config.dart
@@ -39,6 +39,12 @@
   IOSSdk? get targetIOSSdk => _targetIOSSdk;
   late final IOSSdk? _targetIOSSdk;
 
+  /// When compiling for Android, the API version to target.
+  ///
+  /// Required when [target.os] equals [OS.android].
+  int? get targetAndroidNdkApi => _targetAndroidNdkApi;
+  late final int? _targetAndroidNdkApi;
+
   /// Preferred linkMode method for library.
   LinkModePreference get linkModePreference => _linkModePreference;
   late final LinkModePreference _linkModePreference;
@@ -66,6 +72,7 @@
     required Uri packageRoot,
     required Target target,
     IOSSdk? targetIOSSdk,
+    int? targetAndroidNdkApi,
     CCompilerConfig? cCompiler,
     required LinkModePreference linkModePreference,
     Map<String, Metadata>? dependencyMetadata,
@@ -75,6 +82,7 @@
       .._packageRoot = packageRoot
       .._target = target
       .._targetIOSSdk = targetIOSSdk
+      .._targetAndroidNdkApi = targetAndroidNdkApi
       .._cCompiler = cCompiler ?? CCompilerConfig()
       .._linkModePreference = linkModePreference
       .._dependencyMetadata = dependencyMetadata;
@@ -94,6 +102,7 @@
     required Uri packageRoot,
     required Target target,
     IOSSdk? targetIOSSdk,
+    int? targetAndroidNdkApi,
     CCompilerConfig? cCompiler,
     required LinkModePreference linkModePreference,
     Map<String, Metadata>? dependencyMetadata,
@@ -103,6 +112,7 @@
       packageName,
       target.toString(),
       targetIOSSdk.toString(),
+      targetAndroidNdkApi.toString(),
       linkModePreference.toString(),
       cCompiler?.ar.toString(),
       cCompiler?.cc.toString(),
@@ -184,6 +194,7 @@
   static const packageRootConfigKey = 'package_root';
   static const dependencyMetadataConfigKey = 'dependency_metadata';
   static const _versionKey = 'version';
+  static const targetAndroidNdkApiConfigKey = 'target_android_ndk_api';
 
   List<void Function(Config)> _readFieldsFromConfig() {
     var targetSet = false;
@@ -227,6 +238,9 @@
               ),
             )
           : null,
+      (config) => _targetAndroidNdkApi = (targetSet && _target.os == OS.android)
+          ? config.int(targetAndroidNdkApiConfigKey)
+          : null,
       (config) => cCompiler._ar =
           config.optionalPath(CCompilerConfig.arConfigKeyFull, mustExist: true),
       (config) {
@@ -292,6 +306,8 @@
       packageRootConfigKey: _packageRoot.toFilePath(),
       Target.configKey: _target.toString(),
       if (_targetIOSSdk != null) IOSSdk.configKey: _targetIOSSdk.toString(),
+      if (_targetAndroidNdkApi != null)
+        targetAndroidNdkApiConfigKey: _targetAndroidNdkApi!,
       if (cCompilerYaml.isNotEmpty) CCompilerConfig.configKey: cCompilerYaml,
       LinkModePreference.configKey: _linkModePreference.toString(),
       if (_dependencyMetadata != null)
@@ -314,6 +330,7 @@
     if (other._packageRoot != _packageRoot) return false;
     if (other._target != _target) return false;
     if (other._targetIOSSdk != _targetIOSSdk) return false;
+    if (other._targetAndroidNdkApi != _targetAndroidNdkApi) return false;
     if (other._cCompiler != _cCompiler) return false;
     if (other._linkModePreference != _linkModePreference) return false;
     if (!DeepCollectionEquality()
@@ -327,6 +344,7 @@
         _packageRoot,
         _target,
         _targetIOSSdk,
+        _targetAndroidNdkApi,
         _cCompiler,
         _linkModePreference,
         DeepCollectionEquality().hash(_dependencyMetadata),
diff --git a/pkgs/native_assets_cli/test/model/asset_test.dart b/pkgs/native_assets_cli/test/model/asset_test.dart
index c542c5e..a4aa644 100644
--- a/pkgs/native_assets_cli/test/model/asset_test.dart
+++ b/pkgs/native_assets_cli/test/model/asset_test.dart
@@ -139,7 +139,12 @@
   });
 
   test('AssetPath factory', () async {
-    expect(() => AssetPath('wrong', null), throwsFormatException);
+    expect(
+      () => AssetPath('wrong', null),
+      throwsA(predicate(
+        (e) => e is FormatException && e.message.contains('Unknown pathType'),
+      )),
+    );
   });
 
   test('Asset hashCode copyWith', () async {
diff --git a/pkgs/native_assets_cli/test/model/build_config_test.dart b/pkgs/native_assets_cli/test/model/build_config_test.dart
index 9b3ecfa..780a65c 100644
--- a/pkgs/native_assets_cli/test/model/build_config_test.dart
+++ b/pkgs/native_assets_cli/test/model/build_config_test.dart
@@ -63,6 +63,7 @@
       outDir: outDir2Uri,
       packageRoot: tempUri,
       target: Target.androidArm64,
+      targetAndroidNdkApi: 30,
       linkModePreference: LinkModePreference.preferStatic,
     );
 
@@ -88,6 +89,7 @@
       outDir: outDirUri,
       packageRoot: packageRootUri,
       target: Target.androidArm64,
+      targetAndroidNdkApi: 30,
       linkModePreference: LinkModePreference.preferStatic,
     );
 
@@ -95,6 +97,7 @@
       'out_dir': outDirUri.toFilePath(),
       'package_root': packageRootUri.toFilePath(),
       'target': 'android_arm64',
+      'target_android_ndk_api': 30,
       'link_mode_preference': 'prefer-static',
       'version': BuildOutput.version.toString(),
     });
@@ -127,6 +130,7 @@
       outDir: outDirUri,
       packageRoot: tempUri,
       target: Target.androidArm64,
+      targetAndroidNdkApi: 30,
       linkModePreference: LinkModePreference.preferStatic,
       dependencyMetadata: {
         'bar': Metadata({
@@ -143,6 +147,7 @@
       outDir: outDirUri,
       packageRoot: tempUri,
       target: Target.androidArm64,
+      targetAndroidNdkApi: 30,
       linkModePreference: LinkModePreference.preferStatic,
       dependencyMetadata: {
         'bar': Metadata({
@@ -213,28 +218,67 @@
   test('BuildConfig FormatExceptions', () {
     expect(
       () => BuildConfig.fromConfig(Config(fileParsed: {})),
-      throwsFormatException,
+      throwsA(predicate(
+        (e) =>
+            e is FormatException &&
+            e.message.contains(
+              'No value was provided for required key: target',
+            ),
+      )),
     );
     expect(
       () => BuildConfig.fromConfig(Config(fileParsed: {
+        'version': BuildConfig.version.toString(),
         'package_root': packageRootUri.toFilePath(),
         'target': 'android_arm64',
+        'target_android_ndk_api': 30,
         'link_mode_preference': 'prefer-static',
       })),
-      throwsFormatException,
+      throwsA(predicate(
+        (e) =>
+            e is FormatException &&
+            e.message.contains(
+              'No value was provided for required key: out_dir',
+            ),
+      )),
     );
     expect(
       () => BuildConfig.fromConfig(Config(fileParsed: {
+        'version': BuildConfig.version.toString(),
         'out_dir': outDirUri.toFilePath(),
         'package_root': packageRootUri.toFilePath(),
         'target': 'android_arm64',
+        'target_android_ndk_api': 30,
         'link_mode_preference': 'prefer-static',
         'dependency_metadata': {
           'bar': {'key': 'value'},
           'foo': <int>[],
         },
       })),
-      throwsFormatException,
+      throwsA(predicate(
+        (e) =>
+            e is FormatException &&
+            e.message.contains(
+              "Unexpected value '[]' for key 'dependency_metadata.foo' in "
+              'config file. Expected a Map.',
+            ),
+      )),
+    );
+    expect(
+      () => BuildConfig.fromConfig(Config(fileParsed: {
+        'out_dir': outDirUri.toFilePath(),
+        'version': BuildConfig.version.toString(),
+        'package_root': packageRootUri.toFilePath(),
+        'target': 'android_arm64',
+        'link_mode_preference': 'prefer-static',
+      })),
+      throwsA(predicate(
+        (e) =>
+            e is FormatException &&
+            e.message.contains(
+              'No value was provided for required key: target_android_ndk_api',
+            ),
+      )),
     );
   });
 
@@ -271,6 +315,7 @@
       outDir: outDirUri,
       packageRoot: tempUri,
       target: Target.androidArm64,
+      targetAndroidNdkApi: 30,
       linkModePreference: LinkModePreference.preferStatic,
     );
     final configFileContents = buildConfig.toYamlString();
@@ -289,6 +334,7 @@
       outDir: outDirUri,
       packageRoot: tempUri,
       target: Target.androidArm64,
+      targetAndroidNdkApi: 30,
       linkModePreference: LinkModePreference.preferStatic,
       dependencyMetadata: {
         'bar': Metadata({
@@ -337,7 +383,15 @@
         'target': 'linux_x64',
         'version': version,
       });
-      expect(() => BuildConfig.fromConfig(config), throwsFormatException);
+      expect(
+        () => BuildConfig.fromConfig(config),
+        throwsA(predicate(
+          (e) =>
+              e is FormatException &&
+              e.message.contains(version) &&
+              e.message.contains(BuildConfig.version.toString()),
+        )),
+      );
     });
   }
 
@@ -354,7 +408,7 @@
       );
 
       // Using the checksum for a build folder should be stable.
-      expect(name1, '96819d83ae789cb65752986a4abb4071');
+      expect(name1, '02dce8b58210deaf9f278772e892d01f');
 
       // Build folder different due to metadata.
       final name2 = BuildConfig.checksum(
diff --git a/pkgs/native_assets_cli/test/model/build_output_test.dart b/pkgs/native_assets_cli/test/model/build_output_test.dart
index c99a7be..cf9a889 100644
--- a/pkgs/native_assets_cli/test/model/build_output_test.dart
+++ b/pkgs/native_assets_cli/test/model/build_output_test.dart
@@ -100,7 +100,12 @@
     test('BuildOutput version $version', () {
       expect(
         () => BuildOutput.fromYamlString('version: $version'),
-        throwsFormatException,
+        throwsA(predicate(
+          (e) =>
+              e is FormatException &&
+              e.message.contains(version) &&
+              e.message.contains(BuildConfig.version.toString()),
+        )),
       );
     });
   }
diff --git a/pkgs/native_assets_cli/test/model/target_test.dart b/pkgs/native_assets_cli/test/model/target_test.dart
index ce74672..1412c8a 100644
--- a/pkgs/native_assets_cli/test/model/target_test.dart
+++ b/pkgs/native_assets_cli/test/model/target_test.dart
@@ -27,11 +27,26 @@
   test('Target fromDartPlatform', () async {
     final current = Target.fromDartPlatform(Platform.version);
     expect(current.toString(), Abi.current().toString());
-    expect(() => Target.fromDartPlatform('bogus'), throwsFormatException);
     expect(
-        () => Target.fromDartPlatform(
-            '3.0.0 (be) (Wed Apr 5 14:19:42 2023 +0000) on "myfancyos_ia32"'),
-        throwsFormatException);
+      () => Target.fromDartPlatform('bogus'),
+      throwsA(predicate(
+        (e) =>
+            e is FormatException &&
+            e.message.contains('bogus') &&
+            e.message.contains('Unknown version'),
+      )),
+    );
+    expect(
+      () => Target.fromDartPlatform(
+        '3.0.0 (be) (Wed Apr 5 14:19:42 2023 +0000) on "myfancyos_ia32"',
+      ),
+      throwsA(predicate(
+        (e) =>
+            e is FormatException &&
+            e.message.contains('myfancyos_ia32') &&
+            e.message.contains('Unknown ABI'),
+      )),
+    );
   });
 
   test('Target cross compilation', () async {