diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 538ea0b7..d104d5c3 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -64,6 +64,9 @@ jobs: chmod +x ./gradlew ./gradlew --no-daemon :app:testDebugUnitTest :app:lintDebug :app:assembleDebug + - name: Verify 16 KB native-library alignment + run: python tools/check_android_16kb_alignment.py android/app/build/outputs/apk/debug/app-debug.apk + - name: Normalize Android build assets run: | mkdir -p dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92ee6405..bff54980 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,6 +98,9 @@ jobs: cp android/app/build/outputs/apk/release/app-release.apk dist/openstream-android.apk (cd dist && sha256sum openstream-android.apk > openstream-android.apk.sha256) + - name: Verify 16 KB native-library alignment + run: python tools/check_android_16kb_alignment.py dist/openstream-android.apk + - name: Verify APK signature and versionName matches tag run: | TAG_NAME="${{ inputs.tag || github.ref_name }}" diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt index 9318ff67..b5a31dfb 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -3,6 +3,14 @@ project(openstream_srt) add_library(openstream_srt SHARED openstream_srt.cpp) +# Android 15 can run with 16 KB memory pages. NDK r27 needs these explicit +# linker options so the final shared library loads on both 4 KB and 16 KB +# devices. Keep both values in sync with tools/check_android_16kb_alignment.py. +target_link_options(openstream_srt PRIVATE + "-Wl,-z,max-page-size=16384" + "-Wl,-z,common-page-size=16384" +) + option(OPENSTREAM_ENABLE_LIBSRT "Link the Android native sender against Android ABI-compatible libsrt" ON) find_library(log-lib log) diff --git a/tests/test_android_16kb_page_contract.py b/tests/test_android_16kb_page_contract.py new file mode 100644 index 00000000..5cd10b50 --- /dev/null +++ b/tests/test_android_16kb_page_contract.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import importlib.util +import struct +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CHECKER_PATH = ROOT / "tools" / "check_android_16kb_alignment.py" +SPEC = importlib.util.spec_from_file_location("android_alignment", CHECKER_PATH) +assert SPEC is not None and SPEC.loader is not None +CHECKER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CHECKER) + + +def elf64_with_load_alignment(alignment: int) -> bytes: + image = bytearray(64 + 56) + image[:16] = b"\x7fELF\x02\x01\x01" + bytes(9) + struct.pack_into( + " None: + assert CHECKER.load_segment_alignments(elf64_with_load_alignment(4 * 1024)) == [0x1000] + assert CHECKER.load_segment_alignments(elf64_with_load_alignment(16 * 1024)) == [0x4000] + + +def test_android_build_and_ci_enforce_16kb_alignment() -> None: + cmake = (ROOT / "android/app/src/main/cpp/CMakeLists.txt").read_text() + android_workflow = (ROOT / ".github/workflows/android.yml").read_text() + release_workflow = (ROOT / ".github/workflows/release.yml").read_text() + + assert '"-Wl,-z,max-page-size=16384"' in cmake + assert '"-Wl,-z,common-page-size=16384"' in cmake + invocation = "python tools/check_android_16kb_alignment.py" + assert invocation in android_workflow + assert invocation in release_workflow diff --git a/tools/check_android_16kb_alignment.py b/tools/check_android_16kb_alignment.py new file mode 100644 index 00000000..c5e0f8d7 --- /dev/null +++ b/tools/check_android_16kb_alignment.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Verify that an APK's 64-bit native libraries support 16 KB pages.""" + +from __future__ import annotations + +import argparse +import struct +import sys +import zipfile +from pathlib import Path + + +PAGE_SIZE = 16 * 1024 +SUPPORTED_64_BIT_ABIS = {"arm64-v8a", "x86_64"} +PT_LOAD = 1 + + +def load_segment_alignments(library: bytes) -> list[int]: + if library[:4] != b"\x7fELF": + raise ValueError("not an ELF library") + + elf_class = library[4] + byte_order = library[5] + endian = {1: "<", 2: ">"}.get(byte_order) + if endian is None: + raise ValueError(f"unsupported ELF byte order {byte_order}") + + if elf_class == 1: + header_format = f"{endian}HHIIIIIHHHHHH" + program_header_format = f"{endian}IIIIIIII" + elif elf_class == 2: + header_format = f"{endian}HHIQQQIHHHHHH" + program_header_format = f"{endian}IIQQQQQQ" + else: + raise ValueError(f"unsupported ELF class {elf_class}") + + header = struct.unpack_from(header_format, library, 16) + program_header_offset = header[4] + program_header_size = header[8] + program_header_count = header[9] + expected_header_size = struct.calcsize(program_header_format) + if program_header_size < expected_header_size: + raise ValueError("truncated ELF program header") + + alignments: list[int] = [] + for index in range(program_header_count): + offset = program_header_offset + index * program_header_size + program_header = struct.unpack_from(program_header_format, library, offset) + if program_header[0] == PT_LOAD: + alignments.append(program_header[7]) + if not alignments: + raise ValueError("ELF library has no loadable segments") + return alignments + + +def zip_data_offset(apk, entry: zipfile.ZipInfo) -> int: + apk.seek(entry.header_offset) + local_header = apk.read(30) + if len(local_header) != 30: + raise ValueError("truncated ZIP local header") + fields = struct.unpack(" list[str]: + failures: list[str] = [] + checked = 0 + with apk_path.open("rb") as apk_file, zipfile.ZipFile(apk_file) as archive: + for entry in archive.infolist(): + parts = entry.filename.split("/") + if len(parts) != 3 or parts[0] != "lib" or parts[1] not in SUPPORTED_64_BIT_ABIS: + continue + if not entry.filename.endswith(".so"): + continue + + checked += 1 + try: + alignments = load_segment_alignments(archive.read(entry)) + except (IndexError, struct.error, ValueError) as error: + failures.append(f"{entry.filename}: cannot inspect ELF: {error}") + continue + + bad_alignments = [alignment for alignment in alignments if alignment < PAGE_SIZE] + if bad_alignments: + formatted = ", ".join(f"0x{alignment:x}" for alignment in bad_alignments) + failures.append(f"{entry.filename}: load segment alignment is {formatted}, need 0x4000") + + if entry.compress_type == zipfile.ZIP_STORED: + data_offset = zip_data_offset(apk_file, entry) + if data_offset % PAGE_SIZE != 0: + failures.append( + f"{entry.filename}: APK data offset 0x{data_offset:x} is not 16 KB aligned" + ) + + if checked == 0: + failures.append("APK contains no arm64-v8a or x86_64 native libraries") + return failures + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("apk", type=Path) + args = parser.parse_args() + + failures = check_apk(args.apk) + if failures: + for failure in failures: + print(f"UNALIGNED: {failure}") + return 1 + + print(f"ALIGNED: {args.apk} supports 16 KB native-library pages") + return 0 + + +if __name__ == "__main__": + sys.exit(main())