diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d134a4..4fd1ff5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,88 @@ jobs: working-directory: bdk_demo run: flutter build apk --debug --target-platform android-arm64 + - name: Verify APK ELF alignment + run: dart scripts/check_android_elf_alignment.dart bdk_demo/build/app/outputs/flutter-apk/app-debug.apk + + android-alignment: + name: android-alignment (${{ matrix.rust_target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - rust_target: aarch64-linux-android + dart_arch: arm64 + - rust_target: x86_64-linux-android + dart_arch: x64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: "3.10.0" + + - name: Pub get + run: dart pub get + + - name: Install Android NDK + run: sdkmanager "ndk;27.1.12297006" + + - name: Build Android native library with NDK 27 + run: | + rustup toolchain install 1.85.1 --profile minimal --target "${{ matrix.rust_target }}" + NDK="$ANDROID_HOME/ndk/27.1.12297006" + TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64" + export ANDROID_NDK_HOME="$NDK" + export ANDROID_NDK_ROOT="$NDK" + + case "${{ matrix.rust_target }}" in + aarch64-linux-android) + export AR_aarch64_linux_android="$TOOLCHAIN/bin/llvm-ar" + export CC_aarch64_linux_android="$TOOLCHAIN/bin/aarch64-linux-android35-clang" + export CXX_aarch64_linux_android="$TOOLCHAIN/bin/aarch64-linux-android35-clang++" + export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TOOLCHAIN/bin/aarch64-linux-android35-clang" + export BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android="--sysroot=$TOOLCHAIN/sysroot -I$TOOLCHAIN/sysroot/usr/include/aarch64-linux-android" + ;; + x86_64-linux-android) + export AR_x86_64_linux_android="$TOOLCHAIN/bin/llvm-ar" + export CC_x86_64_linux_android="$TOOLCHAIN/bin/x86_64-linux-android35-clang" + export CXX_x86_64_linux_android="$TOOLCHAIN/bin/x86_64-linux-android35-clang++" + export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="$TOOLCHAIN/bin/x86_64-linux-android35-clang" + export BINDGEN_EXTRA_CLANG_ARGS_x86_64_linux_android="--sysroot=$TOOLCHAIN/sysroot -I$TOOLCHAIN/sysroot/usr/include/x86_64-linux-android" + ;; + *) + echo "Unsupported Android target: ${{ matrix.rust_target }}" + exit 1 + ;; + esac + + rustup run 1.85.1 cargo build \ + --release \ + --manifest-path native/Cargo.toml \ + --package bdk_dart_ffi \ + --target "${{ matrix.rust_target }}" \ + --target-dir /tmp/bdk-dart-android-target \ + --config native/.cargo/config.toml + + - name: Verify NDK 27 native library alignment + run: dart scripts/check_android_elf_alignment.dart /tmp/bdk-dart-android-target/${{ matrix.rust_target }}/release/libbdk_dart_ffi.so + + - name: Verify NDK 27 hook alignment + run: | + export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.1.12297006" + export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" + dart scripts/check_android_hook_alignment.dart --arch "${{ matrix.dart_arch }}" + ios-smoke: runs-on: macos-latest steps: diff --git a/hook/build.dart b/hook/build.dart index 7e7790e..1d97b29 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -1,10 +1,17 @@ import 'package:hooks/hooks.dart'; import 'package:native_toolchain_rust/native_toolchain_rust.dart'; -void main(List args) async { +Future main(List args) async { await build(args, (input, output) async { - await const RustBuilder( + final cargoConfigPath = input.packageRoot + .resolve('native/.cargo/config.toml') + .toFilePath(); + + // Native Assets invokes Cargo from the package root, so pass the crate-local + // config explicitly instead of relying on Cargo's working-directory lookup. + await RustBuilder( assetName: 'uniffi:bdk_dart_ffi', + extraCargoBuildArgs: ['--config', cargoConfigPath], ).run(input: input, output: output); }); } diff --git a/pubspec.yaml b/pubspec.yaml index c84e174..ac598f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: hooks: ^1.0.0 native_toolchain_rust: ^1.0.0 dev_dependencies: + code_assets: ^1.0.0 test: ^1.26.2 hooks: diff --git a/scripts/check_android_elf_alignment.dart b/scripts/check_android_elf_alignment.dart new file mode 100644 index 0000000..e86993c --- /dev/null +++ b/scripts/check_android_elf_alignment.dart @@ -0,0 +1,218 @@ +import 'dart:io'; +import 'dart:typed_data'; + +const _libraryName = 'libbdk_dart_ffi.so'; +const _minimumLoadAlignment = 0x4000; +const _ptLoad = 1; + +Future main(List args) async { + if (args.isEmpty) { + _fail( + 'Usage: dart scripts/check_android_elf_alignment.dart ' + ' [...]', + ); + } + + for (final path in args) { + final input = File(path); + if (!input.existsSync()) { + _fail('File not found: ${input.path}'); + } + + if (_isElf(input)) { + _checkLibrary(input, input.path); + } else { + await _checkArchive(input); + } + } +} + +Future _checkArchive(File archive) async { + final tempDir = await Directory.systemTemp.createTemp( + 'bdk_dart_elf_alignment_', + ); + + try { + final entries = await _findNativeLibraryEntries(archive); + if (entries.isEmpty) { + _fail('No $_libraryName entries found in ${archive.path}'); + } + + await _extractLibraries(archive, tempDir, entries); + + for (final entry in entries) { + final library = _extractedEntry(tempDir, entry); + if (!library.existsSync()) { + _fail('Extracted library not found: $entry'); + } + _checkLibrary(library, entry); + } + } finally { + await tempDir.delete(recursive: true); + } +} + +void _checkLibrary(File library, String label) { + final loadAlignments = _readLoadAlignments(library); + if (loadAlignments.isEmpty) { + _fail('No PT_LOAD segments found in $label'); + } + + final invalidAlignments = loadAlignments.where( + (alignment) => + alignment < _minimumLoadAlignment || + alignment % _minimumLoadAlignment != 0, + ); + + if (invalidAlignments.isNotEmpty) { + _fail( + '$label has invalid PT_LOAD alignment(s): ' + '${invalidAlignments.map(_hex).join(', ')}', + ); + } + + final minimumAlignment = loadAlignments.reduce((a, b) => a < b ? a : b); + stdout.writeln('OK $label minLOADalign=${_hex(minimumAlignment)}'); +} + +Future> _findNativeLibraryEntries(File archive) async { + final result = await Process.run('unzip', ['-Z', '-1', archive.path]); + if (result.exitCode != 0) { + _fail( + 'Failed to list $_libraryName entries in ${archive.path}.\n' + '${result.stderr}', + ); + } + + final entries = + (result.stdout as String) + .split('\n') + .where(_isTargetLibraryEntry) + .toList() + ..sort(); + return entries; +} + +bool _isTargetLibraryEntry(String entry) { + final segments = entry.split('/'); + if (segments.any((segment) => segment.isEmpty || segment == '.')) { + return false; + } + if (segments.any((segment) => segment == '..')) { + return false; + } + if (segments.last != _libraryName) { + return false; + } + + final isApkLibrary = segments.length == 3 && segments[0] == 'lib'; + final isAabLibrary = segments.length == 4 && segments[1] == 'lib'; + return isApkLibrary || isAabLibrary; +} + +Future _extractLibraries( + File archive, + Directory destination, + List entries, +) async { + final result = await Process.run('unzip', [ + '-q', + archive.path, + ...entries, + '-d', + destination.path, + ]); + + if (result.exitCode != 0) { + _fail( + 'Failed to extract $_libraryName from ${archive.path}.\n' + '${result.stderr}', + ); + } +} + +File _extractedEntry(Directory root, String entry) { + final path = entry + .split('/') + .fold(root.path, (parent, child) => _join(parent, child)); + return File(path); +} + +bool _isElf(File file) { + final reader = file.openSync(); + try { + final magic = reader.readSync(4); + return magic.length == 4 && + magic[0] == 0x7f && + magic[1] == 0x45 && + magic[2] == 0x4c && + magic[3] == 0x46; + } finally { + reader.closeSync(); + } +} + +List _readLoadAlignments(File elfFile) { + final bytes = elfFile.readAsBytesSync(); + if (bytes.length < 64 || !_isElf(elfFile)) { + _fail('${elfFile.path} is not an ELF file'); + } + + final elfClass = bytes[4]; + final endian = switch (bytes[5]) { + 1 => Endian.little, + 2 => Endian.big, + _ => throw FormatException('Unsupported ELF endianness in ${elfFile.path}'), + }; + final data = ByteData.sublistView(bytes); + + int uint16(int offset) => data.getUint16(offset, endian); + int uint32(int offset) => data.getUint32(offset, endian); + int uint64(int offset) => data.getUint64(offset, endian); + + final ( + programHeaderOffset, + programHeaderEntrySize, + programHeaderCount, + ) = switch (elfClass) { + 1 => (uint32(28), uint16(42), uint16(44)), + 2 => (uint64(32), uint16(54), uint16(56)), + _ => throw FormatException('Unsupported ELF class in ${elfFile.path}'), + }; + + final alignments = []; + for (var index = 0; index < programHeaderCount; index++) { + final headerOffset = programHeaderOffset + index * programHeaderEntrySize; + if (headerOffset + programHeaderEntrySize > bytes.length) { + _fail('${elfFile.path} has a truncated ELF program header table'); + } + + final type = uint32(headerOffset); + if (type != _ptLoad) { + continue; + } + + final alignment = switch (elfClass) { + 1 => uint32(headerOffset + 28), + 2 => uint64(headerOffset + 48), + _ => throw StateError('unreachable'), + }; + alignments.add(alignment); + } + + return alignments; +} + +String _hex(int value) => '0x${value.toRadixString(16)}'; + +String _join(String parent, String child) { + final separator = Platform.pathSeparator; + return parent.endsWith(separator) + ? '$parent$child' + : '$parent$separator$child'; +} + +Never _fail(String message) { + stderr.writeln(message); + exit(1); +} diff --git a/scripts/check_android_hook_alignment.dart b/scripts/check_android_hook_alignment.dart new file mode 100644 index 0000000..8b9a99d --- /dev/null +++ b/scripts/check_android_hook_alignment.dart @@ -0,0 +1,185 @@ +import 'dart:io'; + +import 'package:code_assets/code_assets.dart'; + +import '../hook/build.dart' as build_hook; +import 'check_android_elf_alignment.dart' as elf_alignment; + +const _androidNdkVersion = '27.1.12297006'; +const _androidApi = 35; +const _assetId = 'package:bdk_dart/uniffi:bdk_dart_ffi'; +const _defaultArchitectureName = 'arm64'; +const _targetArchitectures = { + 'arm64': Architecture.arm64, + 'x64': Architecture.x64, +}; +const _androidClangs = { + 'arm64': 'aarch64-linux-android35-clang', + 'x64': 'x86_64-linux-android35-clang', +}; + +Future main(List args) async { + final options = _parseOptions(args); + final targetArchitecture = _targetArchitectures[options.architectureName]!; + final androidClang = _androidClangs[options.architectureName]!; + final ndk = _findNdk(options.ndkPath); + final toolchain = _findLlvmToolchain(ndk, androidClang); + final clang = _tool(toolchain, androidClang); + + await testCodeBuildHook( + mainMethod: build_hook.main, + targetOS: OS.android, + targetArchitecture: targetArchitecture, + targetAndroidNdkApi: _androidApi, + cCompiler: CCompilerConfig( + archiver: _tool(toolchain, 'llvm-ar').uri, + compiler: clang.uri, + linker: clang.uri, + ), + check: (_, output) async { + final assets = output.assets.code + .where((asset) => asset.id == _assetId) + .toList(); + if (assets.length != 1) { + _fail( + 'Expected exactly one $_assetId asset, found ${assets.length}: ' + '${output.assets.code.map((asset) => asset.id).join(', ')}', + ); + } + + final file = assets.single.file; + if (file == null) { + _fail('$_assetId did not include a file path'); + } + + final library = File.fromUri(file); + if (!library.existsSync()) { + _fail('Hook output file does not exist: ${library.path}'); + } + + stdout.writeln('Hook emitted ${library.path}'); + await elf_alignment.main([library.path]); + }, + ); +} + +Directory _findNdk(String? explicitNdkPath) { + final androidHome = Platform.environment['ANDROID_HOME']; + final androidSdkRoot = Platform.environment['ANDROID_SDK_ROOT']; + final candidates = [ + explicitNdkPath, + Platform.environment['ANDROID_NDK_HOME'], + Platform.environment['ANDROID_NDK_ROOT'], + if (androidHome != null) + _join(_join(androidHome, 'ndk'), _androidNdkVersion), + if (androidSdkRoot != null) + _join(_join(androidSdkRoot, 'ndk'), _androidNdkVersion), + ]; + + for (final path in candidates.whereType()) { + final ndk = Directory(path); + if (ndk.existsSync()) { + return ndk; + } + } + + _fail( + 'Android NDK $_androidNdkVersion not found. Set ANDROID_NDK_HOME, ' + 'ANDROID_NDK_ROOT, ANDROID_HOME, ANDROID_SDK_ROOT, or pass --ndk .', + ); +} + +({String architectureName, String? ndkPath}) _parseOptions(List args) { + var architectureName = _defaultArchitectureName; + String? ndkPath; + + for (var index = 0; index < args.length; index++) { + final arg = args[index]; + if (arg == '--ndk') { + if (index + 1 >= args.length) { + _usage(); + } + ndkPath = args[++index]; + } else if (arg.startsWith('--ndk=')) { + ndkPath = arg.substring('--ndk='.length); + } else if (arg == '--arch') { + if (index + 1 >= args.length) { + _usage(); + } + architectureName = args[++index]; + } else if (arg.startsWith('--arch=')) { + architectureName = arg.substring('--arch='.length); + } else { + _usage(); + } + } + + if (!_targetArchitectures.containsKey(architectureName)) { + _usage(); + } + + return (architectureName: architectureName, ndkPath: ndkPath); +} + +Directory _findLlvmToolchain(Directory ndk, String androidClang) { + final prebuilt = Directory( + _join(_join(_join(ndk.path, 'toolchains'), 'llvm'), 'prebuilt'), + ); + if (!prebuilt.existsSync()) { + _fail('NDK LLVM prebuilt directory not found: ${prebuilt.path}'); + } + + final candidates = + prebuilt + .listSync() + .whereType() + .where( + (directory) => + _tool(directory, 'llvm-ar').existsSync() && + _tool(directory, androidClang).existsSync(), + ) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + if (candidates.isEmpty) { + _fail( + 'No NDK LLVM toolchain under ${prebuilt.path} contains llvm-ar and ' + '$androidClang', + ); + } + + return candidates.first; +} + +File _tool(Directory toolchain, String name) { + final bin = Directory(_join(toolchain.path, 'bin')); + final tool = File(_join(bin.path, name)); + if (tool.existsSync() || !Platform.isWindows) { + return tool; + } + + final cmdTool = File(_join(bin.path, '$name.cmd')); + if (cmdTool.existsSync()) { + return cmdTool; + } + + return File(_join(bin.path, '$name.exe')); +} + +String _join(String parent, String child) { + final separator = Platform.pathSeparator; + return parent.endsWith(separator) + ? '$parent$child' + : '$parent$separator$child'; +} + +Never _usage() { + _fail( + 'Usage: dart scripts/check_android_hook_alignment.dart ' + '[--arch ] [--ndk ]', + ); +} + +Never _fail(String message) { + stderr.writeln(message); + exit(1); +}