diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 24d50c88..d76eb08e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,8 @@ on: # Usage: gh workflow run build.yml --repo ActivityWatch/aw-android --ref refs/tags/v env: - NDK_VERSION: '25.2.9519653' + # r28+: links native libs with 16 KB page alignment by default (Play requirement since Nov 2025) + NDK_VERSION: '28.2.13676358' NODE_VERSION: '16' JAVA_VERSION: '17' diff --git a/mobile/build.gradle b/mobile/build.gradle index b67c87ea..184729a9 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -3,7 +3,7 @@ apply plugin: 'kotlin-android' android { compileSdk 35 - ndkVersion "25.2.9519653" + ndkVersion "28.2.13676358" defaultConfig { applicationId "net.activitywatch.android" diff --git a/scripts/check-jnilibs.py b/scripts/check-jnilibs.py index 3f349ddf..9e0cc486 100755 --- a/scripts/check-jnilibs.py +++ b/scripts/check-jnilibs.py @@ -1,60 +1,145 @@ #!/usr/bin/env python3 +import glob import os import struct import sys ABIS = ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"] -LIB_NAME = "libaw_server.so" +# Libs the Makefile always packages; MISSING is an error for these. Any other +# *.so found in an ABI dir is validated too (ELF sanity + alignment). +REQUIRED_LIBS = ["libaw_server.so", "libaw_sync.so"] +# Google Play requires 16 KB page-size support for 64-bit native libs +# (enforced for updates since Nov 2025). NDK r28+ links with 16 KB +# max-page-size by default; this guards against regressing to an older NDK. +PAGE_ALIGNED_ABIS = {"arm64-v8a", "x86_64"} +REQUIRED_LOAD_ALIGN = 0x4000 -def check_lib(abi): - path = os.path.join("mobile", "src", "main", "jniLibs", abi, LIB_NAME) - if not os.path.exists(path): - print(f"MISSING {path}", file=sys.stderr) - return False +def min_load_align(f, path, fsize, header, e_class): + """Return (ok, min p_align across PT_LOAD headers or None if none).""" + if e_class == 1: # 32-bit + e_phoff = struct.unpack(" fsize: + print( + f"CORRUPT {path}: program headers out of bounds " + f"(e_phoff=0x{e_phoff:x}, e_phentsize={e_phentsize}, " + f"e_phnum={e_phnum}, size={fsize})", + file=sys.stderr, + ) + return False, None + + align = None + for i in range(e_phnum): + f.seek(e_phoff + i * e_phentsize) + phdr = f.read(e_phentsize) + if len(phdr) < e_phentsize: + print( + f"CORRUPT {path}: short read of program header {i}", + file=sys.stderr, + ) + return False, None + p_type = struct.unpack("= fsize: - print( - f"CORRUPT {path}: section header past EOF " - f"(e_shoff=0x{e_shoff:x}, size={fsize})", - file=sys.stderr, - ) - return False + if len(header) < 64: + print( + f"CORRUPT {path}: file too small for ELF header " + f"({len(header)} bytes)", + file=sys.stderr, + ) + return False + + e_class = header[4] # 1=32-bit, 2=64-bit + if e_class == 1: + e_shoff = struct.unpack("= fsize: + print( + f"CORRUPT {path}: section header past EOF " + f"(e_shoff=0x{e_shoff:x}, size={fsize})", + file=sys.stderr, + ) + return False + + ok, align = min_load_align(f, path, fsize, header, e_class) + if not ok: + return False + if abi in PAGE_ALIGNED_ABIS: + if align is None or align < REQUIRED_LOAD_ALIGN: + print( + f"BAD_ALIGN {path}: LOAD align " + f"{'none' if align is None else hex(align)} < " + f"{hex(REQUIRED_LOAD_ALIGN)} (16 KB pages required; " + f"build with NDK r28+)", + file=sys.stderr, + ) + return False + + align_str = "none" if align is None else hex(align) + print(f"OK {abi}/{lib_name} ({fsize:,} bytes, LOAD align {align_str})") return True +def check_abi(abi): + abi_dir = os.path.join("mobile", "src", "main", "jniLibs", abi) + ok = True + + seen = set() + for lib_name in REQUIRED_LIBS: + path = os.path.join(abi_dir, lib_name) + if not os.path.exists(path): + print(f"MISSING {path}", file=sys.stderr) + ok = False + continue + seen.add(lib_name) + ok = check_lib(abi, path) and ok + + # Validate any additional packaged libs as well; every 64-bit .so that + # ships in the APK/AAB is subject to the Play 16 KB requirement. + for path in sorted(glob.glob(os.path.join(abi_dir, "*.so"))): + if os.path.basename(path) in seen: + continue + ok = check_lib(abi, path) and ok + + return ok + + def main(): ok = True for abi in ABIS: - ok = check_lib(abi) and ok + ok = check_abi(abi) and ok return 0 if ok else 1