diff --git a/src/boot_animation.py b/src/boot_animation.py index 7efd9f78..84b8e4fc 100644 --- a/src/boot_animation.py +++ b/src/boot_animation.py @@ -28,6 +28,7 @@ MAX_COMPRESSION_RATIO = 200 MAX_DESCRIPTION_BYTES = 4096 PAYLOAD_ENVIRONMENT = "PIXENEOS_BOOT_ANIMATION_PATH" +DARK_PAYLOAD_ENVIRONMENT = "PIXENEOS_BOOT_ANIMATION_DARK_PATH" BOOT_ANIMATION_TARGETS = ( ("product", "/media/bootanimation.zip"), ("product", "/media/bootanimation-dark.zip"), @@ -232,8 +233,19 @@ def build_runtime_payload(path: str | os.PathLike[str]) -> bytes: return output.getvalue() -def install_runtime_payload(ext_fs: dict[str, Any], payload: bytes) -> None: - """Install through ExtFs so AFSR metadata and SELinux labels stay in sync.""" +def install_runtime_payload( + ext_fs: dict[str, Any], + light_payload: bytes, + dark_payload: bytes | None = None, +) -> None: + """Install theme payloads through ExtFs with single-file fallback.""" + + if dark_payload is None: + dark_payload = light_payload + payload_by_target = { + "/media/bootanimation.zip": light_payload, + "/media/bootanimation-dark.zip": dark_payload, + } for partition, raw_target in BOOT_ANIMATION_TARGETS: fs = ext_fs.get(partition) @@ -244,20 +256,41 @@ def install_runtime_payload(ext_fs: dict[str, Any], payload: bytes) -> None: target = PurePosixPath(raw_target) fs.mkdir(str(target.parent), mode=0o755, parents=True, exist_ok=True) with fs.open(str(target), "wb", mode=0o644) as stream: - stream.write(payload) + stream.write(payload_by_target[raw_target]) + +def resolve_runtime_payloads( + light_path: str | os.PathLike[str] | None, + dark_path: str | os.PathLike[str] | None, +) -> tuple[bytes, bytes]: + """Resolve one or two source archives into light/dark runtime payloads.""" + if not light_path and not dark_path: + raise RuntimeError("no boot animation payload is configured") + light_source = light_path or dark_path + dark_source = dark_path or light_path + assert light_source is not None + assert dark_source is not None + return build_runtime_payload(light_source), build_runtime_payload(dark_source) def verify_runtime_installation( - source_path: str | os.PathLike[str], + light_source_path: str | os.PathLike[str], + dark_source_path: str | os.PathLike[str], light_path: str | os.PathLike[str], dark_path: str | os.PathLike[str], ) -> None: - """Verify the custom animation extracted from a finished product image.""" + """Verify theme payloads extracted from a finished product image.""" - expected = build_runtime_payload(source_path) - for runtime_path in (Path(light_path), Path(dark_path)): + expected_light, expected_dark = resolve_runtime_payloads( + light_source_path, + dark_source_path, + ) + actual = ( + (Path(light_path), expected_light), + (Path(dark_path), expected_dark), + ) + for runtime_path, expected in actual: if runtime_path.read_bytes() != expected: raise RuntimeError( f"finished OTA boot animation does not match payload: {runtime_path}" @@ -317,10 +350,12 @@ def inject( ) -> None: del boot_fs, sepolicies, compatible_sepolicy payload_path = os.environ.get(PAYLOAD_ENVIRONMENT) - if not payload_path: - raise RuntimeError(f"{PAYLOAD_ENVIRONMENT} is not set") - payload = build_runtime_payload(payload_path) - install_runtime_payload(ext_fs, payload) + dark_payload_path = os.environ.get(DARK_PAYLOAD_ENVIRONMENT) + light_payload, dark_payload = resolve_runtime_payloads( + payload_path, + dark_payload_path, + ) + install_runtime_payload(ext_fs, light_payload, dark_payload) return BootAnimationMod @@ -340,8 +375,8 @@ def main(argv: list[str]) -> int: if len(argv) == 3 and argv[1] in {"validate", "digest"}: print(validate_payload(argv[2])) return 0 - if len(argv) == 5 and argv[1] == "verify-runtime": - verify_runtime_installation(argv[2], argv[3], argv[4]) + if len(argv) == 6 and argv[1] == "verify-runtime": + verify_runtime_installation(argv[2], argv[3], argv[4], argv[5]) return 0 except (BootAnimationError, OSError, RuntimeError) as exc: print(f"Error: {exc}", file=sys.stderr) @@ -349,7 +384,7 @@ def main(argv: list[str]) -> int: print( f"usage: {argv[0]} validate | " - "verify-runtime ", + "verify-runtime ", file=sys.stderr, ) return 2 diff --git a/src/ci/selection_variant.sh b/src/ci/selection_variant.sh index c53b0390..d714fff2 100755 --- a/src/ci/selection_variant.sh +++ b/src/ci/selection_variant.sh @@ -7,7 +7,7 @@ # this helper owns the byte-for-byte representation that is hashed by both the # build and release preflight paths. -BOOT_ANIMATION_SELECTION_CONTRACT="product-image-root-stored-v4" +BOOT_ANIMATION_SELECTION_CONTRACT="product-image-root-theme-fallback-stored-v5" function selection_variant_manifest() { local field value diff --git a/src/rom_profiles.sh b/src/rom_profiles.sh index ea555680..c1e1e365 100644 --- a/src/rom_profiles.sh +++ b/src/rom_profiles.sh @@ -134,16 +134,40 @@ function _locked_input_digest() { printf '%s\n' "${digest}" } -function _boot_animation_payload_path() { +function _resolve_boot_animation_payloads() { local repository_root repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" || return 1 - printf '%s\n' "${repository_root}/custom/boot-animation/bootanimation.zip" + + BOOT_ANIMATION_LIGHT_PAYLOAD="${repository_root}/custom/boot-animation/bootanimation.zip" + BOOT_ANIMATION_DARK_PAYLOAD="${repository_root}/custom/boot-animation/bootanimation-dark.zip" + + if [[ ! -e "${BOOT_ANIMATION_LIGHT_PAYLOAD}" && ! -L "${BOOT_ANIMATION_LIGHT_PAYLOAD}" ]]; then + BOOT_ANIMATION_LIGHT_PAYLOAD='' + fi + if [[ ! -e "${BOOT_ANIMATION_DARK_PAYLOAD}" && ! -L "${BOOT_ANIMATION_DARK_PAYLOAD}" ]]; then + BOOT_ANIMATION_DARK_PAYLOAD='' + fi + if [[ -z "${BOOT_ANIMATION_LIGHT_PAYLOAD}" && -z "${BOOT_ANIMATION_DARK_PAYLOAD}" ]]; then + echo "Error: enabled boot animation requires bootanimation.zip or bootanimation-dark.zip." >&2 + return 1 + fi + + BOOT_ANIMATION_LIGHT_PAYLOAD="${BOOT_ANIMATION_LIGHT_PAYLOAD:-${BOOT_ANIMATION_DARK_PAYLOAD}}" + BOOT_ANIMATION_DARK_PAYLOAD="${BOOT_ANIMATION_DARK_PAYLOAD:-${BOOT_ANIMATION_LIGHT_PAYLOAD}}" +} + +function _boot_animation_payload_path() { + _resolve_boot_animation_payloads || return 1 + printf '%s\n' "${BOOT_ANIMATION_LIGHT_PAYLOAD}" } function _boot_animation_payload_digest() { - local payload_path - payload_path="$(_boot_animation_payload_path)" || return 1 - python3 src/boot_animation.py digest "${payload_path}" + local light_digest dark_digest + _resolve_boot_animation_payloads || return 1 + light_digest="$(python3 src/boot_animation.py digest "${BOOT_ANIMATION_LIGHT_PAYLOAD}")" || return 1 + dark_digest="$(python3 src/boot_animation.py digest "${BOOT_ANIMATION_DARK_PAYLOAD}")" || return 1 + printf 'light=%s\ndark=%s\n' "${light_digest}" "${dark_digest}" | + sha256sum | awk '{print $1}' } function module_selection_fingerprint() { diff --git a/src/util_functions.sh b/src/util_functions.sh index 646e3d40..1d019837 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -244,15 +244,18 @@ function append_enabled_module_arguments() { # API used by src/debugmod.py at the pinned helper revision. function prepare_boot_animation_module() { local helper_root="${1}" - local repository_root payload_path init_file registry_file module_source - repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" || return 1 - payload_path="${repository_root}/custom/boot-animation/bootanimation.zip" + local payload_path dark_payload_path init_file registry_file module_source if [[ "${ADDITIONALS[BOOT_ANIMATION]}" != 'true' ]]; then return 0 fi - if ! python3 src/boot_animation.py validate "${payload_path}" >/dev/null; then + _resolve_boot_animation_payloads || return 1 + payload_path="${BOOT_ANIMATION_LIGHT_PAYLOAD}" + dark_payload_path="${BOOT_ANIMATION_DARK_PAYLOAD}" + + if ! python3 src/boot_animation.py validate "${payload_path}" >/dev/null || + ! python3 src/boot_animation.py validate "${dark_payload_path}" >/dev/null; then echo "Error: boot animation validation failed; refusing to patch." >&2 return 1 fi @@ -310,6 +313,7 @@ function prepare_boot_animation_module() { : >"${WORKDIR}/modules/boot-animation.zip" : >"${WORKDIR}/signatures/boot-animation.zip.sig" export PIXENEOS_BOOT_ANIMATION_PATH="${payload_path}" + export PIXENEOS_BOOT_ANIMATION_DARK_PATH="${dark_payload_path}" } # Resolve and acquire the locked F-Droid inputs before exposing them to the @@ -665,7 +669,7 @@ function extract_ota_boot_target() { function verify_boot_animation_ota() { local ota_path="${1}" - local temp_dir payload_path avbroot_bin afsr_bin ota_abs + local temp_dir payload_path dark_payload_path avbroot_bin afsr_bin ota_abs local extract_dir unpack_dir image_path raw_image [[ -f "${ota_path}" ]] || { @@ -673,7 +677,9 @@ function verify_boot_animation_ota() { return 1 } - payload_path="$(_boot_animation_payload_path)" || return 1 + _resolve_boot_animation_payloads || return 1 + payload_path="${BOOT_ANIMATION_LIGHT_PAYLOAD}" + dark_payload_path="${BOOT_ANIMATION_DARK_PAYLOAD}" temp_dir="$(mktemp -d "${WORKDIR}/boot-animation-verify.XXXXXX")" || return 1 temp_dir="$(realpath -- "${temp_dir}")" || return 1 ota_abs="$(realpath -- "${ota_path}")" || { @@ -704,7 +710,10 @@ function verify_boot_animation_ota() { return 1 } - if ! "${avbroot_bin}" ota extract --input "${ota_abs}" --directory "${extract_dir}" --partition product >/dev/null; then + if ! "${avbroot_bin}" ota extract \ + --input "${ota_abs}" \ + --directory "${extract_dir}" \ + --partition product >/dev/null; then rm -rf -- "${temp_dir}" return 1 fi @@ -730,7 +739,11 @@ function verify_boot_animation_ota() { return 1 fi - if ! python3 src/boot_animation.py verify-runtime "${payload_path}" "${unpack_dir}/fs_tree/media/bootanimation.zip" "${unpack_dir}/fs_tree/media/bootanimation-dark.zip"; then + if ! python3 src/boot_animation.py verify-runtime \ + "${payload_path}" \ + "${dark_payload_path}" \ + "${unpack_dir}/fs_tree/media/bootanimation.zip" \ + "${unpack_dir}/fs_tree/media/bootanimation-dark.zip"; then rm -rf -- "${temp_dir}" return 1 fi diff --git a/tests/boot_animation_test.py b/tests/boot_animation_test.py index c8bfbad7..c9e741ac 100644 --- a/tests/boot_animation_test.py +++ b/tests/boot_animation_test.py @@ -18,6 +18,7 @@ BootAnimationError, build_runtime_payload, install_runtime_payload, + resolve_runtime_payloads, validate_payload, verify_runtime_installation, ) @@ -97,12 +98,50 @@ def test_runtime_payload_installation() -> None: # therefore contains /media, not another nested /product directory. assert not (product.root / "product").exists() + light_runtime, dark_runtime = resolve_runtime_payloads(source, None) + assert light_runtime == dark_runtime == runtime verify_runtime_installation( + source, source, product.root / "media/bootanimation.zip", product.root / "media/bootanimation-dark.zip", ) + dark_source = root / "dark-source.zip" + write_valid(dark_source, frame=b"dark-frame") + dark_runtime = build_runtime_payload(dark_source) + themed_product = FakeExtFs(root / "themed-product-fs") + resolved_light, resolved_dark = resolve_runtime_payloads(source, dark_source) + assert resolved_light == runtime + assert resolved_dark == dark_runtime + install_runtime_payload( + {"product": themed_product}, + resolved_light, + resolved_dark, + ) + assert ( + themed_product.root / "media/bootanimation.zip" + ).read_bytes() == runtime + assert ( + themed_product.root / "media/bootanimation-dark.zip" + ).read_bytes() == dark_runtime + verify_runtime_installation( + source, + dark_source, + themed_product.root / "media/bootanimation.zip", + themed_product.root / "media/bootanimation-dark.zip", + ) + + dark_only_light, dark_only_dark = resolve_runtime_payloads(None, dark_source) + assert dark_only_light == dark_only_dark == dark_runtime + + try: + resolve_runtime_payloads(None, None) + except RuntimeError: + pass + else: + raise AssertionError("missing light and dark payloads were accepted") + try: install_runtime_payload({}, runtime) except RuntimeError: diff --git a/tests/rom_contract_test.sh b/tests/rom_contract_test.sh index a9522290..f9524b8c 100644 --- a/tests/rom_contract_test.sh +++ b/tests/rom_contract_test.sh @@ -218,7 +218,10 @@ with zipfile.ZipFile(sys.argv[1], "w") as archive: archive.writestr("desc.txt", "1 1 1\np 1 0 part0\n") archive.writestr("part0/frame.png", b"frame") PY - _boot_animation_payload_path() { printf '%s\n' "${TEST_ROOT}/custom/boot-animation/bootanimation.zip"; } + _resolve_boot_animation_payloads() { + BOOT_ANIMATION_LIGHT_PAYLOAD="${TEST_ROOT}/custom/boot-animation/bootanimation.zip" + BOOT_ANIMATION_DARK_PAYLOAD="${BOOT_ANIMATION_LIGHT_PAYLOAD}" + } boot_changed="$(fingerprint)" [[ "${boot_changed}" != "${module_changed}" ]] || fail "boot animation selection did not change the fingerprint" @@ -234,6 +237,23 @@ PY second_boot_changed="$(fingerprint)" [[ "${second_boot_changed}" != "${boot_changed}" ]] || fail "enabled boot-animation payload change did not change the fingerprint" + + python3 - "${TEST_ROOT}/custom/boot-animation/bootanimation-dark.zip" <<'PY' +import sys +import zipfile + +with zipfile.ZipFile(sys.argv[1], "w") as archive: + archive.writestr("desc.txt", "1 1 1\np 1 0 part0\n") + archive.writestr("part0/frame.png", b"dark-frame") +PY + _resolve_boot_animation_payloads() { + BOOT_ANIMATION_LIGHT_PAYLOAD="${TEST_ROOT}/custom/boot-animation/bootanimation.zip" + BOOT_ANIMATION_DARK_PAYLOAD="${TEST_ROOT}/custom/boot-animation/bootanimation-dark.zip" + } + local dark_boot_changed + dark_boot_changed="$(fingerprint)" + [[ "${dark_boot_changed}" != "${second_boot_changed}" ]] || + fail "dark boot-animation payload did not change the fingerprint" ) test_output_filename_contains_fingerprint() (