Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 9 additions & 2 deletions hook/build.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import 'package:hooks/hooks.dart';
import 'package:native_toolchain_rust/native_toolchain_rust.dart';

void main(List<String> args) async {
Future<void> main(List<String> 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);
});
}
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
218 changes: 218 additions & 0 deletions scripts/check_android_elf_alignment.dart

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nit here: This handles .so and APK but not AAB. Not urgent since the hook job checks the .so directly, but if someone wants to sanity check a Play upload bundle down the road, that'd be a nice add.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I see what your saying, is the 6c06f6f commit I added doing what you were thinking you'd want?

@Johnosezele Johnosezele Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it does.

Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import 'dart:io';
import 'dart:typed_data';

const _libraryName = 'libbdk_dart_ffi.so';
const _minimumLoadAlignment = 0x4000;
const _ptLoad = 1;

Future<void> main(List<String> args) async {
if (args.isEmpty) {
_fail(
'Usage: dart scripts/check_android_elf_alignment.dart '
'<apk-aab-or-so> [...]',
);
}

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<void> _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<List<String>> _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<void> _extractLibraries(
File archive,
Directory destination,
List<String> 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<int> _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 = <int>[];
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);
}
Loading
Loading