diff --git a/.github/workflows/build-firmware-releases.yml b/.github/workflows/build-firmware-releases.yml new file mode 100644 index 0000000..2df62df --- /dev/null +++ b/.github/workflows/build-firmware-releases.yml @@ -0,0 +1,130 @@ +name: Build firmware releases + +on: + workflow_dispatch: + inputs: + force: + description: Rebuild even when upstream digest unchanged + type: boolean + default: false + source_repo: + description: Limit to one upstream repo (OWNER/NAME) + type: string + default: "" + schedule: + - cron: "0 6 * * 1" + push: + branches: [main] + paths: + - "apply.bash" + - "src/patches/**" + - "src/Y1Bridge/**" + - "src/su/**" + - ".github/workflows/build-firmware-releases.yml" + - ".github/workflows/workflow.md" + - "tools/ci/**" + +permissions: + contents: write + +jobs: + discover: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.wrap.outputs.matrix }} + has_matrix: ${{ steps.wrap.outputs.has_matrix }} + steps: + - uses: actions/checkout@v4 + + - name: Discover allowlisted upstream rom.zip releases + id: discover + env: + GH_TOKEN: ${{ github.token }} + run: | + chmod +x tools/ci/*.sh + args=() + if [[ -n "${{ github.event.inputs.source_repo }}" ]]; then + args+=(--source-repo "${{ github.event.inputs.source_repo }}") + fi + # Every push to main republishes firmware so patched com.innioasis.y1 APKs stay current. + if [[ "${{ github.event_name }}" == "push" ]]; then + args+=(--force) + elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.force }}" == "true" ]]; then + args+=(--force) + fi + ./tools/ci/discover-inputs.sh "${args[@]}" > matrix.json + echo "Discovered inputs:" + cat matrix.json + + - name: Wrap matrix for strategy + id: wrap + run: | + matrix="$(cat matrix.json)" + if [[ "$matrix" == "[]" ]]; then + echo "has_matrix=false" >> "$GITHUB_OUTPUT" + echo 'matrix={"include":[]}' >> "$GITHUB_OUTPUT" + else + echo "has_matrix=true" >> "$GITHUB_OUTPUT" + echo "matrix={\"include\":${matrix}}" >> "$GITHUB_OUTPUT" + fi + + build: + needs: discover + if: needs.discover.outputs.has_matrix == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 2 + matrix: ${{ fromJson(needs.discover.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + + - name: Install host packages + run: | + sudo apt-get update + sudo apt-get install -y \ + unzip zip curl \ + android-sdk-libsparse-utils \ + gcc-arm-linux-gnueabi binutils-arm-linux-gnueabi make \ + openjdk-17-jdk + + - name: Cache tooling + uses: actions/cache@v4 + with: + path: | + tools/mtkclient + tools/python-venv + tools/android-sdk + ~/.gradle/caches + ~/.gradle/wrapper + key: koensayr-tooling-${{ runner.os }}-${{ hashFiles('tools/setup.sh', 'tools/python-requirements.txt', 'tools/install-android-sdk.sh', 'src/Y1Bridge/**') }} + restore-keys: | + koensayr-tooling-${{ runner.os }}- + + - name: Build dependencies + run: | + chmod +x tools/ci/*.sh apply.bash tools/setup.sh tools/install-android-sdk.sh + ./tools/setup.sh + ( cd src/su && make ) + ./tools/install-android-sdk.sh + # shellcheck disable=SC1091 + source tools/android-sdk-env.sh + ( cd src/Y1Bridge && ./gradlew --stop && ./gradlew assembleDebug ) + + - name: Patch and publish release + env: + GH_TOKEN: ${{ github.token }} + JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 + run: | + args=( + --source-repo "${{ matrix.source_repo }}" + --source-tag "${{ matrix.source_tag }}" + --release-tag "${{ matrix.release_tag }}" + --download-url "${{ matrix.download_url }}" + --digest "${{ matrix.digest }}" + --slug "${{ matrix.slug }}" + ) + if [[ "${{ matrix.force }}" == "true" ]]; then + args+=(--force) + fi + ./tools/ci/build-one.sh "${args[@]}" diff --git a/.github/workflows/workflow.md b/.github/workflows/workflow.md new file mode 100644 index 0000000..75b7818 --- /dev/null +++ b/.github/workflows/workflow.md @@ -0,0 +1,7 @@ +Koensayr is a mod for the Y1's OS that makes the following improvements to the original OS: + +Browsing the albums of an artist from Main Menu > Artists > Albums + +Adds Bluetooth remote control support and track info displayed on your bluetooth receiver, perfect for using your Y1 in a car with the infotainment system. + +Devs: edit this file to describe new features; CI prepends it to every firmware release on GitHub. diff --git a/CHANGELOG.md b/CHANGELOG.md index 368d50a..16bc526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/spec/v2.0.0.html). For full prose detail on any entry, see `git log`. -## [Unreleased] +## [2.4.0] - 2026-05-22 ### Fixed - Broader head-unit coverage for live metadata. Continuous notifications across track, play-state, position, and repeat/shuffle edges; head units detect each new track even when titles repeat; clean subscription state on every fresh connection. - Metadata + play / pause indicators now work on the broad class of head units and speakers that strictly require their AVRCP transaction IDs be echoed back. Previously, those head units silently rejected every response Y1 sent and fell back to key-press-only mode — metadata panes stayed blank and play-state indicators drifted out of sync. diff --git a/README.md b/README.md index 1e3c4c3..59580f0 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,10 @@ Override bundled tooling with `--mtkclient-dir ` / `--python-venv ` | `--remove-apps` | Remove bloatware (`ApplicationGuide`, `BasicDreams`, …). | | `--root` | Install `src/su/build/su` at `/system/xbin/su` (mode 06755). Pre-requires `make` in `src/su/`. | | `--all` | All of the above. Pre-requires the `src/su/` + `src/Y1Bridge/` builds. | +| `--no-flash` | Patch only; write `system-*-devel.img` without MTKClient flash (CI / repack). | +| `--accept-any-firmware` | Skip `KNOWN_FIRMWARES` MD5 checks; use `--firmware-slug` when unknown. Implies `--skip-md5` on patchers. | +| `--firmware-slug ` | Output label when upstream `rom.zip` is not in the manifest (e.g. `y1-stock-rom-2.8.2`). | +| `--skip-md5` | Bypass stock-binary MD5 gates in `patch_*.py` (diagnostic / CI). | Run `./apply.bash --help` for full flag detail. Patchers can also be run standalone — see [`src/patches/README.md`](src/patches/README.md). @@ -88,11 +92,11 @@ Known stock firmwares recognised by `KNOWN_FIRMWARES` in the bash. Add a row (sa | Version | system.img (raw, extracted) | boot.img (in zip; not consumed since v1.7.0) | rom.zip (input) | Music APK basename in `app/` | |---|---|---|---|---| | **3.0.2** | `473991dadeb1a8c4d25902dee9ee362b` | `1f7920228a20c01ad274c61c94a8cf36` | `82657db82578a38c6f1877e02407127a` | `com.innioasis.y1_3.0.2.apk` | -| **3.0.7** | `663baf9f7f2a08caa82e3fba7a9baa28` | `83b946d1799b4f0281ba8e808ed7911b` | `02ae3ae89e20bde0a20e940f73e1ed1b` | `com.innioasis.y1_3.0.7.apk` | +| **3.0.7** | `663baf9f…` (Innioasis sparse→raw) or `017d62eb…` ([y1-community](https://github.com/y1-community/y1-stock-rom) pre-expanded) | `83b946d1799b4f0281ba8e808ed7911b` | `aa9847088859176c76d8e203970e7032` | `com.innioasis.y1_3.0.7.apk` | The MediaTek BT stack (`bin/mtkbt`, `lib/libextavrcp*.so`, `lib/libaudio.a2dp.default.so`, `app/MtkBt.odex`) is byte-identical between 3.0.2 and 3.0.7 — every native patch in `--avrcp` / `--bluetooth` applies unchanged. Only the music APK differs (resource-ID shifts + a few additions in `Y1Repository`), and `patch_y1_apk.py`'s smali anchors handle both builds. -Stock sizes: 3.0.2 `rom.zip` 259,502,414 bytes (raw `system.img` inside); 3.0.7 `rom.zip` 189,791,144 bytes (sparse `system.img` inside, auto-de-sparsed via `simg2img`). Both `system.img`s expand to 681,574,400 bytes raw ext4. `boot.img` 4,706,304 bytes on both. +Stock sizes: 3.0.2 `rom.zip` 259,502,414 bytes (raw `system.img` inside); 3.0.7 `rom.zip` from Innioasis may ship a sparse `system.img` (auto-de-sparsed via `simg2img`), while [y1-community/y1-stock-rom](https://github.com/y1-community/y1-stock-rom) releases use the same `rom.zip` MD5 but a pre-expanded raw `system.img`. Both raw `system.img`s are 681,574,400-byte ext4. `boot.img` 4,706,304 bytes on both. ## Requirements @@ -104,9 +108,42 @@ Stock sizes: 3.0.2 `rom.zip` 259,502,414 bytes (raw `system.img` inside); 3.0.7 - For `--root` only: prebuilt `src/su/build/su` (`cd src/su && make`). Toolchain: `dnf install -y epel-release && dnf install -y gcc-arm-linux-gnu binutils-arm-linux-gnu make` (Rocky/Alma/RHEL/Fedora) or `gcc-arm-linux-gnueabi` on Debian/Ubuntu. - For `--avrcp` only: Android SDK + JDK 17+. `tools/install-android-sdk.sh` auto-installs into `tools/android-sdk/` (~1.5 GB, idempotent). Manual instructions: [`docs/ANDROID-SDK.md`](docs/ANDROID-SDK.md). +## Automated releases (GitHub Actions) + +Workflow [`.github/workflows/build-firmware-releases.yml`](.github/workflows/build-firmware-releases.yml) builds an allowlisted set of [y1-stock-rom](https://github.com/y1-community/y1-stock-rom) **`rom.zip`** releases only: + +- Upstream tags **3.0.2** and **Latest-3.0.7** (published as `{koensayr-version}-koensayr-3.0.2` / `{koensayr-version}-koensayr-3.0.7`) + +For each input it downloads upstream `rom.zip`, verifies SHA256 (from the release asset) and MD5 against [`KNOWN_FIRMWARES`](apply.bash), runs `./apply.bash --all --no-flash`, repacks `rom.zip`, and publishes a release on this repo. + +**Release tag pattern:** `{koensayr-version}-koensayr-{firmware-version}` (e.g. `2.4.0-koensayr-3.0.7`) for Innioasis Updater compatibility. Release notes start with [`.github/workflows/workflow.md`](.github/workflows/workflow.md). Each release attaches **`rom.zip`** (patched) plus **`build-manifest.json`**. + +Download from **this repo’s release tag**, not from [y1-community/y1-stock-rom](https://github.com/y1-community/y1-stock-rom) — upstream `rom.zip` is stock (~238–259 MB). Patched builds are larger (~295–329 MB) and have a different SHA256 (listed in the release notes / manifest). + +| Release | Expected `rom.zip` size (approx.) | Patched SHA256 (May 2026 CI) | +|---------|-----------------------------------|------------------------------| +| *-koensayr-3.0.2 | 329,015,308 bytes (May 2026 CI) | `2371ac0970c0dbac318077373467859439aa0414caa15e29b90d8e879b8bbd80` | +| *-koensayr-3.0.7 | 309,073,126 bytes (May 2026 CI) | `2fa3fb7bf9ced11a21d0ce3bd0aec8a521dd3c6f22d9e0be8301b9ca3951dddb` | + +**Triggers:** weekly schedule, pushes to `main` that touch patcher/CI paths (always republish both firmware tags; existing releases are deleted and recreated), and manual `workflow_dispatch` (optional `force` / `source_repo` filter). + +**Confidence:** Stock **3.0.2** / **3.0.7** OTAs are hardware-verified. See [`docs/SUPPORTED-FIRMWARE-CI.md`](docs/SUPPORTED-FIRMWARE-CI.md). + +Local dry-run (no GitHub publish): + +```bash +KOENSAYR_SKIP_PUBLISH=1 ./tools/ci/build-one.sh \ + --source-repo y1-community/y1-stock-rom --source-tag 3.0.2 \ + --release-tag 2.4.0-koensayr-3.0.2 \ + --download-url "$(gh release view 3.0.2 --repo y1-community/y1-stock-rom --json assets -q '.assets[] | select(.name==\"rom.zip\") | .url')" \ + --digest "$(gh release view 3.0.2 --repo y1-community/y1-stock-rom --json assets -q '.assets[] | select(.name==\"rom.zip\") | .digest' | sed 's/sha256://')" \ + --slug y1-stock-rom-3.0.2 +``` + ## Documentation - [CHANGELOG.md](CHANGELOG.md) — version history (Keep a Changelog format) +- [docs/SUPPORTED-FIRMWARE-CI.md](docs/SUPPORTED-FIRMWARE-CI.md) — CI upstream mapping and build expectations - [docs/ANDROID-SDK.md](docs/ANDROID-SDK.md) — Android SDK install instructions (only needed for `--avrcp` / Y1Bridge build) - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — AVRCP metadata proxy architecture: data-path diagram, trampoline chain, response-builder calling conventions, ELF segment-extension technique, code-cave inventory. Read this first if working on the metadata pipeline. - [docs/BT-COMPLIANCE.md](docs/BT-COMPLIANCE.md) — current ICS Table 7 coverage scorecard (every Mandatory + every Optional row) diff --git a/apply.bash b/apply.bash index 33ad7cd..47764ff 100755 --- a/apply.bash +++ b/apply.bash @@ -6,7 +6,7 @@ # Add a row to support a new build. # # Author: Sean Halpin (github.com/SeanathanVT) -# Version: 2.3.0 +# Version: 2.4.0 # Changelog: see CHANGELOG.md # Patches: see docs/PATCHES.md # @@ -38,6 +38,16 @@ FLAGS: --all --adb + --avrcp + --bluetooth + --music-apk + --remove-apps + --root. Pre-requires the src/su/ and src/Y1Bridge/ builds (see those flags above). + --no-flash Patch and write system-*-devel.img only; skip MTKClient flash. + Used by CI / headless repack flows. + --accept-any-firmware + Do not require rom.zip / system.img MD5s in KNOWN_FIRMWARES. + Use --firmware-slug when rom.zip is not in the manifest. + --firmware-slug + Label for output naming (e.g. y1-stock-rom-3.0.2). Required + with --accept-any-firmware when rom.zip MD5 is unknown. + --skip-md5 Pass --skip-md5 to every patch_*.py (diagnostic / CI). + Implied by --accept-any-firmware. --debug Build patches with KOENSAYR_DEBUG=1. Build-time switch (reflash to toggle); zero runtime overhead when omitted. Surfaces three independent log streams: @@ -71,6 +81,10 @@ FLAG_DEBUG=false FLAG_MUSIC_APK=false FLAG_REMOVE_APPS=false FLAG_ROOT=false +FLAG_NO_FLASH=false +FLAG_ACCEPT_ANY_FIRMWARE=false +FLAG_SKIP_MD5=false +FIRMWARE_SLUG="" PATH_ARTIFACTS="" # Tooling overrides — explicit flag wins over the tools/ default. @@ -145,6 +159,24 @@ while [[ $# -gt 0 ]]; do FLAG_ANY_SPECIFIED=true shift ;; + --no-flash) + FLAG_NO_FLASH=true + shift + ;; + --accept-any-firmware) + FLAG_ACCEPT_ANY_FIRMWARE=true + FLAG_SKIP_MD5=true + shift + ;; + --firmware-slug) + require_value --firmware-slug "${2:-}" + FIRMWARE_SLUG="$2" + shift 2 + ;; + --skip-md5) + FLAG_SKIP_MD5=true + shift + ;; --mtkclient-dir) require_value --mtkclient-dir "${2:-}" OVERRIDE_MTKCLIENT_DIR="$2" @@ -232,11 +264,12 @@ VERSION_FIRMWARE="" FILENAME_SYSTEM_IMAGE_TARGET="" FILENAME_MUSIC_APK="" -# Schema: ||||. -# system.img md5 is for the RAW image (post-simg2img if input was sparse). +# Schema: ||||. +# system.img md5 is the RAW ext4 image used for patching (post-simg2img if the zip held a sparse image). +# Multiple system.img md5s are comma-separated (Innioasis sparse→raw vs y1-community pre-expanded zips). KNOWN_FIRMWARES=( "3.0.2|473991dadeb1a8c4d25902dee9ee362b|1f7920228a20c01ad274c61c94a8cf36|82657db82578a38c6f1877e02407127a|com.innioasis.y1_3.0.2.apk" - "3.0.7|663baf9f7f2a08caa82e3fba7a9baa28|83b946d1799b4f0281ba8e808ed7911b|02ae3ae89e20bde0a20e940f73e1ed1b|com.innioasis.y1_3.0.7.apk" + "3.0.7|663baf9f7f2a08caa82e3fba7a9baa28,017d62eb54fe18a4dc0b70d380763c22|83b946d1799b4f0281ba8e808ed7911b|aa9847088859176c76d8e203970e7032|com.innioasis.y1_3.0.7.apk" ) # (PATH_SCRIPT_DIR set earlier — used by the --artifacts-dir staging fallback.) @@ -295,6 +328,20 @@ md5_of() { fi } +# md5_matches_field +# Manifest fields may list comma-separated alternates (e.g. Innioasis vs y1-community system.img). +md5_matches_field() { + local field_value="$1" actual="$2" alt + IFS=',' read -ra alts <<< "$field_value" + for alt in "${alts[@]}"; do + alt="${alt//[[:space:]]/}" + if [[ "$actual" == "$alt" ]]; then + return 0 + fi + done + return 1 +} + # resolve_version — echos matching firmware # version on stdout, returns 1 on no match. resolve_version() { @@ -308,7 +355,7 @@ resolve_version() { local row parts for row in "${KNOWN_FIRMWARES[@]}"; do IFS='|' read -ra parts <<< "$row" - if [[ "${parts[$idx]}" == "$md5" ]]; then + if md5_matches_field "${parts[$idx]}" "$md5"; then echo "${parts[0]}" return 0 fi @@ -352,6 +399,32 @@ print_known_firmwares() { done } +# discover_music_apk — sets FILENAME_MUSIC_APK from /system/app/com.innioasis.y1*.apk +discover_music_apk() { + local matches=() + while IFS= read -r line; do + [[ -n "$line" ]] && matches+=("$line") + done < <(sudo find "${PATH_MOUNT}/app" -maxdepth 1 -name 'com.innioasis.y1*.apk' -printf '%f\n' 2>/dev/null | sort -u) + if [[ ${#matches[@]} -eq 0 ]]; then + echo "ERROR: no com.innioasis.y1*.apk under ${PATH_MOUNT}/app" >&2 + exit 1 + fi + if [[ ${#matches[@]} -gt 1 ]]; then + echo "ERROR: multiple music APKs under ${PATH_MOUNT}/app:" >&2 + printf ' %s\n' "${matches[@]}" >&2 + exit 1 + fi + FILENAME_MUSIC_APK="${matches[0]}" + echo " → music APK: app/${FILENAME_MUSIC_APK}" +} + +# patcher_extra_args — extra CLI flags for patch_*.py invocations. +patcher_extra_args() { + if [[ "$FLAG_SKIP_MD5" == true ]]; then + echo --skip-md5 + fi +} + # Stages stock binaries extracted from the mount + their patched output before write-back. PATH_TMP_STAGE="$(mktemp -d -t koensayr.XXXXXX)" MOUNTED=false @@ -381,7 +454,8 @@ patch_in_place_bytes() { sudo cp "${PATH_MOUNT}/${mount_rel}" "${stock}" sudo chown "$(id -u):$(id -g)" "${stock}" - if ! python3 "${PATH_SCRIPT_DIR}/src/patches/${script}" "${stock}" --output "${patched}"; then + # shellcheck disable=SC2046 + if ! python3 "${PATH_SCRIPT_DIR}/src/patches/${script}" "${stock}" --output "${patched}" $(patcher_extra_args); then echo "ERROR: ${script} failed for ${mount_rel}" >&2 exit 1 fi @@ -403,28 +477,38 @@ patch_in_place_y1_apk() { local mount_rel="$1" local stage_dir="${PATH_TMP_STAGE}/$(basename "${mount_rel}")" local stock="${stage_dir}/stock.apk" - local patched="${PATH_SCRIPT_DIR}/src/patches/output/com.innioasis.y1_${VERSION_FIRMWARE}-patched.apk" + local out_dir="${PATH_SCRIPT_DIR}/src/patches/output" + local patched="" - mkdir -p "${stage_dir}" + mkdir -p "${stage_dir}" "${out_dir}" echo " ${mount_rel}: extract → patch_y1_apk.py → write-back" sudo cp "${PATH_MOUNT}/${mount_rel}" "${stock}" sudo chown "$(id -u):$(id -g)" "${stock}" local pyvenv pyvenv="$(resolve_python_venv)" + # shellcheck disable=SC2046 if ! ( cd "${PATH_SCRIPT_DIR}/src/patches" [[ -n "$pyvenv" ]] && source "${pyvenv}/bin/activate" - python3 patch_y1_apk.py "${stock}" + python3 patch_y1_apk.py "${stock}" $(patcher_extra_args) ); then echo "ERROR: patch_y1_apk.py failed for ${mount_rel}" >&2 exit 1 fi - if [[ ! -f "${patched}" ]]; then - echo "ERROR: patch_y1_apk.py did not produce ${patched}" >&2 + shopt -s nullglob + local produced=( "${out_dir}"/com.innioasis.y1_*-patched.apk ) + shopt -u nullglob + if (( ${#produced[@]} == 0 )); then + echo "ERROR: patch_y1_apk.py did not produce com.innioasis.y1_*-patched.apk in ${out_dir}" >&2 exit 1 fi + if (( ${#produced[@]} > 1 )); then + echo "ERROR: multiple patched APKs in ${out_dir}: ${produced[*]}" >&2 + exit 1 + fi + patched="${produced[0]}" if ! sudo cp "${patched}" "${PATH_MOUNT}/${mount_rel}"; then echo "ERROR: failed to write patched ${mount_rel} back to mount" >&2 @@ -452,11 +536,23 @@ if ! command -v python3 >/dev/null 2>&1; then exit 1 fi -echo "Validating rom.zip against stock-firmware manifest.." rom_md5=$(md5_of "$rom") +MANIFEST_MATCH=false if VERSION_FIRMWARE=$(resolve_version rom "$rom_md5"); then + MANIFEST_MATCH=true + echo "Validating rom.zip against stock-firmware manifest.." echo " → matched v${VERSION_FIRMWARE} (rom.zip md5 ${rom_md5})" +elif [[ "$FLAG_ACCEPT_ANY_FIRMWARE" == true ]]; then + echo "Accepting rom.zip without manifest match (md5 ${rom_md5}).." + if [[ -z "$FIRMWARE_SLUG" ]]; then + echo "ERROR: rom.zip is not in KNOWN_FIRMWARES; pass --firmware-slug with --accept-any-firmware." >&2 + print_known_firmwares + exit 1 + fi + VERSION_FIRMWARE="$FIRMWARE_SLUG" + echo " → firmware slug ${VERSION_FIRMWARE}" else + echo "Validating rom.zip against stock-firmware manifest.." echo "ERROR: ${FILENAME_ROM_ZIP} md5 ${rom_md5} does not match any known stock firmware." >&2 print_known_firmwares exit 1 @@ -501,17 +597,27 @@ EOF PATH_SYSTEM_IMG="$raw" fi - sys_md5=$(md5_of "$PATH_SYSTEM_IMG") - expected=$(firmware_field "$VERSION_FIRMWARE" system_md5) - if [[ "$sys_md5" != "$expected" ]]; then - echo "ERROR: extracted system.img md5 ${sys_md5} differs from manifest v${VERSION_FIRMWARE} (expected ${expected})" >&2 - exit 1 + if [[ "$MANIFEST_MATCH" == true && "$FLAG_ACCEPT_ANY_FIRMWARE" == false ]]; then + sys_md5=$(md5_of "$PATH_SYSTEM_IMG") + expected=$(firmware_field "$VERSION_FIRMWARE" system_md5) + if ! md5_matches_field "$expected" "$sys_md5"; then + echo "ERROR: extracted system.img md5 ${sys_md5} differs from manifest v${VERSION_FIRMWARE} (expected one of: ${expected})" >&2 + exit 1 + fi + elif [[ "$MANIFEST_MATCH" == true && "$FLAG_ACCEPT_ANY_FIRMWARE" == true ]]; then + sys_md5=$(md5_of "$PATH_SYSTEM_IMG") + expected=$(firmware_field "$VERSION_FIRMWARE" system_md5) + if ! md5_matches_field "$expected" "$sys_md5"; then + echo " WARNING: extracted system.img md5 ${sys_md5} differs from manifest v${VERSION_FIRMWARE} (expected one of: ${expected}); continuing (--accept-any-firmware)" + fi fi fi # Version-dependent filenames now resolvable. FILENAME_SYSTEM_IMAGE_TARGET="system-${VERSION_FIRMWARE}-devel.img" -FILENAME_MUSIC_APK="$(firmware_field "$VERSION_FIRMWARE" music_apk)" +if [[ "$MANIFEST_MATCH" == true ]]; then + FILENAME_MUSIC_APK="$(firmware_field "$VERSION_FIRMWARE" music_apk)" +fi # Copy validated raw system.img into the artifacts dir (mtkclient flashes from there) and mount. if [[ "$FLAG_ANY_SYSTEM_PATCH" == true ]]; then @@ -535,6 +641,10 @@ if [[ "$FLAG_ANY_SYSTEM_PATCH" == true ]]; then exit 1 fi MOUNTED=true + + if [[ -z "${FILENAME_MUSIC_APK:-}" ]]; then + discover_music_apk + fi fi # Enable ADB debugging @@ -656,34 +766,43 @@ if [[ "$FLAG_ANY_SYSTEM_PATCH" == true ]]; then fi MOUNTED=false - # Flash via MTKClient. Resolve location + venv only now (no point checking - # earlier — the patch steps don't need MTKClient). - PATH_MTKCLIENT="$(resolve_mtkclient_dir)" - PATH_VENV_MTKCLIENT="${PATH_MTKCLIENT}/venv" - if [[ ! -f "${PATH_VENV_MTKCLIENT}/bin/activate" ]]; then - echo "ERROR: MTKClient venv missing at ${PATH_VENV_MTKCLIENT}." >&2 - echo " Run: ${PATH_SCRIPT_DIR}/tools/setup.sh" >&2 - exit 1 - fi + devel_img="${PATH_ARTIFACTS}/${FILENAME_SYSTEM_IMAGE_TARGET}" + if [[ "$FLAG_NO_FLASH" == true ]]; then + echo "Skipping MTKClient flash (--no-flash). Patched image: ${devel_img}" + if [[ "$FLAG_AVRCP" == true ]]; then + echo "After flashing this build on-device, clear MultiDex cache once:" + echo " adb shell rm -rf /data/data/com.innioasis.y1/code_cache/secondary-dexes/" + fi + else + # Flash via MTKClient. Resolve location + venv only now (no point checking + # earlier — the patch steps don't need MTKClient). + PATH_MTKCLIENT="$(resolve_mtkclient_dir)" + PATH_VENV_MTKCLIENT="${PATH_MTKCLIENT}/venv" + if [[ ! -f "${PATH_VENV_MTKCLIENT}/bin/activate" ]]; then + echo "ERROR: MTKClient venv missing at ${PATH_VENV_MTKCLIENT}." >&2 + echo " Run: ${PATH_SCRIPT_DIR}/tools/setup.sh" >&2 + exit 1 + fi - echo "Activating MTKClient venv (${PATH_MTKCLIENT}).." - if ! cd "${PATH_MTKCLIENT}"; then - echo "ERROR: failed to cd into ${PATH_MTKCLIENT} (permissions?)" >&2 - exit 1 - fi - # shellcheck disable=SC1091 - source "${PATH_VENV_MTKCLIENT}/bin/activate" + echo "Activating MTKClient venv (${PATH_MTKCLIENT}).." + if ! cd "${PATH_MTKCLIENT}"; then + echo "ERROR: failed to cd into ${PATH_MTKCLIENT} (permissions?)" >&2 + exit 1 + fi + # shellcheck disable=SC1091 + source "${PATH_VENV_MTKCLIENT}/bin/activate" + + echo "Writing new system.img (plug in and reset Y1 device using button near USB-C port).." + if ! python3 "${PATH_MTKCLIENT}/mtk.py" w android "${devel_img}"; then + echo "ERROR: mtk.py write failed — device left in an unknown state." >&2 + echo " Common causes: device not in BROM mode, USB cable not data-capable," >&2 + echo " mtkclient version mismatch, missing libusb." >&2 + deactivate + exit 1 + fi - echo "Writing new system.img (plug in and reset Y1 device using button near USB-C port).." - if ! python3 "${PATH_MTKCLIENT}/mtk.py" w android "${PATH_ARTIFACTS}/${FILENAME_SYSTEM_IMAGE_TARGET}"; then - echo "ERROR: mtk.py write failed — device left in an unknown state." >&2 - echo " Common causes: device not in BROM mode, USB cable not data-capable," >&2 - echo " mtkclient version mismatch, missing libusb." >&2 + echo "Deactivating MTKClient venv.." deactivate - exit 1 fi - - echo "Deactivating MTKClient venv.." - deactivate fi echo "Done!" diff --git a/docs/SUPPORTED-FIRMWARE-CI.md b/docs/SUPPORTED-FIRMWARE-CI.md new file mode 100644 index 0000000..afc839f --- /dev/null +++ b/docs/SUPPORTED-FIRMWARE-CI.md @@ -0,0 +1,62 @@ +# CI firmware builds + +GitHub Actions workflow [`build-firmware-releases.yml`](../.github/workflows/build-firmware-releases.yml) produces patched **`rom.zip`** releases on [ryan-specter/koensayr-auto](https://github.com/ryan-specter/koensayr-auto/releases). + +## Upstream sources (allowlist) + +CI only builds these upstream tags (see [`tools/ci/discover-inputs.sh`](../tools/ci/discover-inputs.sh)): + +| Repository | Upstream tags | Asset | +|------------|---------------|-------| +| [y1-community/y1-stock-rom](https://github.com/y1-community/y1-stock-rom) | **3.0.2**, **Latest-3.0.7** | `rom.zip` | + +**Koensayr release tags:** `{koensayr-version}-koensayr-{stock-firmware}` (e.g. `2.4.0-koensayr-3.0.7`) so Innioasis Updater can read the stock OS version from the suffix. Stock **3.0.7** is built from upstream tag `Latest-3.0.7`. User-facing copy comes from [`.github/workflows/workflow.md`](../.github/workflows/workflow.md); commits by **SeanathanVT** append a **Detailed notes:** section with the latest commit message. + +**Not built by CI:** other stock tags (`2.8.2`, `ADB-2.1.9`, `type-b-1.7.6`, …), `rom_type_b.zip`, `rom_240p.zip`, `update.zip`, voice packs, and other assets. + +Rockbox-Y1 `rom.zip` is out of scope: the image has no Innioasis `com.innioasis.y1` music APK, so the stock patch pipeline does not apply. Build Rockbox releases manually if needed. + +To add another stock tag, extend `Y1_UPSTREAM_TAGS` in `discover-inputs.sh`. + +## Output naming + +- **GitHub release tag / title:** `{koensayr-version}-koensayr-{firmware-version}` (e.g. `2.4.0-koensayr-3.0.2`, `2.4.0-koensayr-3.0.7`) +- **Internal firmware slug** (`--firmware-slug`): `@` replaced with `-` (e.g. `y1-stock-rom-3.0.2`) for `system-*-devel.img` naming + +## Patch set + +Every green CI build verifies upstream `rom.zip` SHA256 and MD5 against [`KNOWN_FIRMWARES`](../apply.bash), then runs `./apply.bash --all --no-flash` (strict manifest match — no `--accept-any-firmware`): + +- Music-player UX (`--music-apk`) +- Bluetooth pairing (`--bluetooth`) +- ADB + bloat removal (`--adb`, `--remove-apps`) +- Root (`--root`) +- AVRCP 1.3 + Y1Bridge (`--avrcp`) + +Diagnostic tooling under `tools/` is **not** embedded in the ROM. + +## Expectations by upstream + +| Input | CI expectation | +|-------|----------------| +| y1-stock-rom **3.0.2** / **3.0.7** | Supported; matches [`KNOWN_FIRMWARES`](../apply.bash) (3.0.7 accepts both Innioasis and y1-community `system.img` layouts) | + +Failed matrix jobs do not block other releases (`fail-fast: false`). + +## Idempotency + +- **Push to `main`:** always rebuilds and republishes (`--force`), so patched `com.innioasis.y1` APKs and other `--all` changes land in the latest `rom.zip` releases. Each publish **deletes** the prior GitHub release tag and recreates it (no leftover assets such as older `rom-koensayr.zip` names). +- **Weekly schedule:** skips a matrix entry only when `build-manifest.json` on the existing release matches both the upstream `rom.zip` SHA256 and the current koensayr git revision (`koensayr_git_sha`). +- **`workflow_dispatch`:** set **force** to rebuild regardless. + +## Scripts + +| Script | Role | +|--------|------| +| [`tools/ci/discover-inputs.sh`](../tools/ci/discover-inputs.sh) | Emit JSON matrix for allowlisted upstream `rom.zip` assets | +| [`tools/ci/build-one.sh`](../tools/ci/build-one.sh) | Download → MD5/SHA256 gate → patch → repack → `gh release` | +| [`tools/ci/firmware-manifest.sh`](../tools/ci/firmware-manifest.sh) | Read `KNOWN_FIRMWARES` rows from `apply.bash` | +| [`tools/ci/patch-revision.sh`](../tools/ci/patch-revision.sh) | Current git SHA recorded in `build-manifest.json` | +| [`.github/workflows/workflow.md`](../.github/workflows/workflow.md) | User-facing intro prepended to each GitHub release (edit for new features) | +| [`tools/ci/extract-rom.sh`](../tools/ci/extract-rom.sh) | Unzip upstream ROM; record sparse `system.img` | +| [`tools/ci/repack-rom.sh`](../tools/ci/repack-rom.sh) | Replace `system.img` and zip patched `rom.zip` | diff --git a/src/patches/patch_y1_apk.py b/src/patches/patch_y1_apk.py index d219533..4737e07 100755 --- a/src/patches/patch_y1_apk.py +++ b/src/patches/patch_y1_apk.py @@ -87,11 +87,9 @@ # `apt install openjdk-21-jdk` and either `update-alternatives --config java` # or invoke /usr/lib/jvm/java-21-openjdk-*/bin/java directly). -# apktool 2.9.3's smali assembler is memory-frugal and runs fine at the -# default JVM heap on this APK. Newer apktool releases (2.10+) use a parallel -# ThreadPoolExecutor that may need `-Xmx2g` for large APKs; keeping this -# slot here so a future bump can wire it up by changing one constant. -APKTOOL_JVM_FLAGS: list = [] +# apktool 2.9.3's smali assembler is memory-frugal on this APK; CI runners +# still need headroom when assembling two large DEX trees in one `b` pass. +APKTOOL_JVM_FLAGS: list = ["-Xmx4g"] # Stock APK md5s — pulled from /system/app/com.innioasis.y1/ on clean stock # devices. The smali pattern matches in this script assume unpatched bytecode, @@ -149,13 +147,23 @@ def run(cmd, **kw): return result def find_java(): - for candidate in ["java", - "/usr/lib/jvm/java-21-openjdk-amd64/bin/java", - "/usr/lib/jvm/java-17-openjdk-amd64/bin/java", - "/usr/lib/jvm/default-java/bin/java"]: - if shutil.which(candidate): + # Prefer JDK 17/21 — apktool 2.9.3's smali assembler is unreliable on Java 22+. + env_home = os.environ.get("JAVA_HOME", "").strip() + if env_home: + env_java = os.path.join(env_home, "bin", "java") + if os.path.isfile(env_java): + return env_java + for candidate in [ + "/usr/lib/jvm/java-17-openjdk-amd64/bin/java", + "/usr/lib/jvm/java-21-openjdk-amd64/bin/java", + "/usr/lib/jvm/java-17-openjdk/bin/java", + "/usr/lib/jvm/java-21-openjdk/bin/java", + "java", + "/usr/lib/jvm/default-java/bin/java", + ]: + if shutil.which(candidate) or os.path.isfile(candidate): return candidate - sys.exit("ERROR: Java not found. Install Java 11+ and ensure 'java' is on PATH.") + sys.exit("ERROR: Java not found. Install Java 11–21 and ensure 'java' is on PATH.") def md5_file(path: str) -> str: @@ -1057,36 +1065,137 @@ def _repl(m): # ============================================================ -# Patch H: BaseActivity.smali — propagate unhandled discrete media keys +# Patch H / H': dispatchKeyEvent — AVRCP discrete media key propagation # ============================================================ -# Stock dispatchKeyEvent always returns TRUE, swallowing AVRCP-derived -# KEYCODE_MEDIA_PLAY/_PAUSE/_STOP/_NEXT/_PREVIOUS that don't match the -# device's KeyMap. We early-return FALSE on those keycodes for repeatCount==0 -# (so they propagate to AudioService → PlayControllerReceiver Patch E discrete -# arms) and TRUE on repeatCount>0 (silent consume — defangs framework -# InputDispatcher::synthesizeKeyRepeatLocked synthesised repeats that drove -# the "stuck fast-forwarding" symptom). Full rationale + side-effects -# (hardware NEXT/PREV touch buttons lose long-press FF/RW) in -# docs/PATCHES.md Patch H section. -BASE_ACTIVITY_SMALI = "smali/com/innioasis/y1/base/BaseActivity.smali" -base_activity_path = os.path.join(UNPACKED_DIR, BASE_ACTIVITY_SMALI) -if not os.path.exists(base_activity_path): - sys.exit(f"ERROR: Expected smali not found: {base_activity_path}") +def _patch_h_avrcp_block(key_reg: str, label_prefix: str) -> str: + """Smali inserted immediately after getKeyCode move-result (uses v3 scratch).""" + L = label_prefix + return ( + f" const/16 v3, 0x7e\n\n" + f" if-eq {key_reg}, v3, :{L}_avrcp_key\n\n" + f" const/16 v3, 0x7f\n\n" + f" if-eq {key_reg}, v3, :{L}_avrcp_key\n\n" + f" const/16 v3, 0x56\n\n" + f" if-eq {key_reg}, v3, :{L}_avrcp_key\n\n" + f" const/16 v3, 0x57\n\n" + f" if-eq {key_reg}, v3, :{L}_avrcp_key\n\n" + f" const/16 v3, 0x58\n\n" + f" if-eq {key_reg}, v3, :{L}_avrcp_key\n\n" + f" goto :{L}_continue\n\n" + f" :{L}_avrcp_key\n" + f" invoke-virtual {{p1}}, Landroid/view/KeyEvent;->getRepeatCount()I\n\n" + f" move-result v3\n\n" + f" if-eqz v3, :{L}_propagate\n\n" + f" const/4 v0, 0x1\n\n" + f" return v0\n\n" + f" :{L}_propagate\n" + f" const/4 v0, 0x0\n\n" + f" return v0\n\n" + f" :{L}_continue" + ) -with open(base_activity_path, 'r') as f: - base_activity_src = f.read() -OLD_DISPATCH_HEAD = """\ -.method public dispatchKeyEvent(Landroid/view/KeyEvent;)Z - .locals 7 +def _dispatch_keyevent_anchor(smali_src: str): + """Return (insert_pos, key_reg, locals_n) for dispatchKeyEvent getKeyCode, or None.""" + meth = re.search( + r"\.method public dispatchKeyEvent\(Landroid/view/KeyEvent;\)Z.*?(?=\.end method)", + smali_src, + re.DOTALL, + ) + if not meth: + return None + body = meth.group(0) + anch = re.search( + r"invoke-virtual \{p1\}, Landroid/view/KeyEvent;->getKeyCode\(\)I\n+" + r" move-result (v\d+)\n+", + body, + ) + if not anch: + return None + locals_m = re.search(r" \.locals (\d+)", body) + locals_n = int(locals_m.group(1)) if locals_m else 2 + insert_pos = meth.start() + anch.end() + return insert_pos, anch.group(1), locals_n + + +def _apply_dispatch_at_method_entry(smali_src: str, label_prefix: str) -> tuple[str, bool]: + """Insert AVRCP key handling at the start of dispatchKeyEvent (Kotlin / unknown layouts).""" + hdr = re.search( + r"(\.method public dispatchKeyEvent\(Landroid/view/KeyEvent;\)Z\n \.locals (\d+)\n\n)", + smali_src, + ) + if not hdr: + return smali_src, False + + insert_at = hdr.end() + locals_n = int(hdr.group(2)) + if locals_n < 4: + new_hdr = re.sub(r"\.locals \d+", ".locals 4", hdr.group(1), count=1) + smali_src = smali_src[: hdr.start()] + new_hdr + smali_src[hdr.end() :] + insert_at = hdr.start() + len(new_hdr) + + meth = re.search( + r"\.method public dispatchKeyEvent\(Landroid/view/KeyEvent;\)Z.*?\.end method", + smali_src[hdr.start() :], + re.DOTALL, + ) + if not meth: + return smali_src, False - const/4 v0, 0x1 + kc = re.search( + r"invoke-virtual \{p1\}, Landroid/view/KeyEvent;->getKeyCode\(\)I\n\s+move-result (v\d+)", + meth.group(0), + ) + key_reg = kc.group(1) if kc else "v2" + block = _patch_h_avrcp_block(key_reg, label_prefix) + "\n\n" + return smali_src[:insert_at] + block + smali_src[insert_at:], True + + +def _apply_dispatch_keyevent_patch(smali_src: str, label_prefix: str, exact_pairs): + """Try known prologue replacements, then anchor insert after getKeyCode.""" + for old, new in exact_pairs: + if old in smali_src: + return smali_src.replace(old, new, 1), True + + anchor = _dispatch_keyevent_anchor(smali_src) + if anchor: + insert_pos, key_reg, locals_n = anchor + if locals_n < 4: + smali_src = re.sub( + r"(\.method public dispatchKeyEvent\(Landroid/view/KeyEvent;\)Z\n" + r" \.locals )\d+", + r"\g<1>4", + smali_src, + count=1, + ) + anchor = _dispatch_keyevent_anchor(smali_src) + if anchor: + insert_pos, key_reg, locals_n = anchor - if-nez p1, :cond_0 + block = _patch_h_avrcp_block(key_reg, label_prefix) + "\n\n" + return smali_src[:insert_pos] + block + smali_src[insert_pos:], True - return v0 + return _apply_dispatch_at_method_entry(smali_src, label_prefix) + + +def _dispatch_head_with_avrcp_block(key_reg: str, label_prefix: str, suffix: str) -> str: + return _patch_h_avrcp_block(key_reg, label_prefix) + "\n\n " + suffix + + +def _dispatch_after_get_keycode(key_reg: str, label_prefix: str) -> str: + """getKeyCode → move-result → AVRCP filter → const/4 v3, 0x3 (Patch H tail).""" + return ( + " invoke-virtual {p1}, Landroid/view/KeyEvent;->getKeyCode()I\n\n" + f" move-result {key_reg}\n\n" + + _patch_h_avrcp_block(key_reg, label_prefix) + + "\n\n" + " const/4 v3, 0x3" + ) + +# Stock Java (3.0.2 / 3.0.7) — includes .line debug comments. +_OLD_DISPATCH_JAVA_LINES = """\ .line 673 :cond_0 invoke-virtual {p1}, Landroid/view/KeyEvent;->getAction()I @@ -1100,75 +1209,152 @@ def _repl(m): const/4 v3, 0x3""" -NEW_DISPATCH_HEAD = """\ -.method public dispatchKeyEvent(Landroid/view/KeyEvent;)Z - .locals 7 - - const/4 v0, 0x1 - - if-nez p1, :cond_0 - - return v0 - - .line 673 +# Same logic without .line comments (EN_2.8.x and other Kotlin builds). +_OLD_DISPATCH_JAVA = """\ :cond_0 invoke-virtual {p1}, Landroid/view/KeyEvent;->getAction()I move-result v1 - .line 674 invoke-virtual {p1}, Landroid/view/KeyEvent;->getKeyCode()I move-result v2 - const/16 v3, 0x7e + const/4 v3, 0x3""" - if-eq v2, v3, :patch_h_avrcp_key +_OLD_DISPATCH_KOTLIN = """\ + :cond_0 + invoke-static {p1}, Lkotlin/jvm/internal/Intrinsics;->checkNotNull(Ljava/lang/Object;)V - const/16 v3, 0x7f + invoke-virtual {p1}, Landroid/view/KeyEvent;->getAction()I - if-eq v2, v3, :patch_h_avrcp_key + move-result v1 - const/16 v3, 0x56 + invoke-virtual {p1}, Landroid/view/KeyEvent;->getKeyCode()I - if-eq v2, v3, :patch_h_avrcp_key + move-result v2 - const/16 v3, 0x57 + const/4 v3, 0x3""" - if-eq v2, v3, :patch_h_avrcp_key - const/16 v3, 0x58 +def _base_activity_dispatch_pairs(): + prefix = """\ +.method public dispatchKeyEvent(Landroid/view/KeyEvent;)Z + .locals 7 - if-eq v2, v3, :patch_h_avrcp_key + const/4 v0, 0x1 - goto :patch_h_continue + if-nez p1, :cond_0 - :patch_h_avrcp_key - invoke-virtual {p1}, Landroid/view/KeyEvent;->getRepeatCount()I + return v0 - move-result v3 +""" + pairs = [] + for old_mid in (_OLD_DISPATCH_JAVA_LINES, _OLD_DISPATCH_JAVA, _OLD_DISPATCH_KOTLIN): + kc = " invoke-virtual {p1}, Landroid/view/KeyEvent;->getKeyCode()I" + if kc not in old_mid: + continue + head, _, _ = old_mid.partition(kc) + pairs.append(( + prefix + old_mid, + prefix + head + _dispatch_after_get_keycode("v2", "patch_h"), + )) + # Kotlin 2.8.x prologue with a low .locals count — AVRCP block needs v3. + prefix_l4 = prefix.replace(".locals 7", ".locals 4", 1) + kc = " invoke-virtual {p1}, Landroid/view/KeyEvent;->getKeyCode()I" + head, _, _ = _OLD_DISPATCH_KOTLIN.partition(kc) + pairs.append(( + prefix_l4 + _OLD_DISPATCH_KOTLIN, + prefix_l4 + head + _dispatch_after_get_keycode("v2", "patch_h"), + )) + return pairs + + +def _base_player_dispatch_pairs(): + pairs = [] + + def _player_new_head(line304: str) -> str: + return ( + ".method public dispatchKeyEvent(Landroid/view/KeyEvent;)Z\n" + " .locals 4\n\n" + " invoke-virtual {p1}, Landroid/view/KeyEvent;->getKeyCode()I\n\n" + " move-result v0\n\n" + + _patch_h_avrcp_block("v0", "patch_h2") + + "\n\n" + + line304 + + " invoke-static {p1}, Lkotlin/jvm/internal/Intrinsics;->checkNotNull(Ljava/lang/Object;)V\n\n" + " invoke-virtual {p1}, Landroid/view/KeyEvent;->getAction()I\n\n" + " move-result v0" + ) - if-eqz v3, :patch_h_propagate + old_with_line = ( + ".method public dispatchKeyEvent(Landroid/view/KeyEvent;)Z\n" + " .locals 2\n\n" + " .line 304\n" + " invoke-static {p1}, Lkotlin/jvm/internal/Intrinsics;->checkNotNull(Ljava/lang/Object;)V\n\n" + " invoke-virtual {p1}, Landroid/view/KeyEvent;->getAction()I\n\n" + " move-result v0" + ) + pairs.append((old_with_line, _player_new_head(" .line 304\n"))) + pairs.append(( + old_with_line.replace(" .line 304\n", ""), + _player_new_head(""), + )) + + # EN_2.8.x: getKeyCode already appears before getAction (anchor / in-place block). + old_kc_first = ( + ".method public dispatchKeyEvent(Landroid/view/KeyEvent;)Z\n" + " .locals 2\n\n" + " invoke-static {p1}, Lkotlin/jvm/internal/Intrinsics;->checkNotNull(Ljava/lang/Object;)V\n\n" + " invoke-virtual {p1}, Landroid/view/KeyEvent;->getKeyCode()I\n\n" + " move-result v0\n\n" + " invoke-virtual {p1}, Landroid/view/KeyEvent;->getAction()I\n\n" + " move-result v1" + ) + new_kc_first = ( + ".method public dispatchKeyEvent(Landroid/view/KeyEvent;)Z\n" + " .locals 4\n\n" + " invoke-static {p1}, Lkotlin/jvm/internal/Intrinsics;->checkNotNull(Ljava/lang/Object;)V\n\n" + " invoke-virtual {p1}, Landroid/view/KeyEvent;->getKeyCode()I\n\n" + " move-result v0\n\n" + + _patch_h_avrcp_block("v0", "patch_h2") + + "\n\n" + " invoke-virtual {p1}, Landroid/view/KeyEvent;->getAction()I\n\n" + " move-result v1" + ) + pairs.append((old_kc_first, new_kc_first)) + return pairs - return v0 - :patch_h_propagate - const/4 v0, 0x0 +# Patch H: BaseActivity.smali — propagate unhandled discrete media keys +# Stock dispatchKeyEvent always returns TRUE, swallowing AVRCP-derived +# KEYCODE_MEDIA_PLAY/_PAUSE/_STOP/_NEXT/_PREVIOUS that don't match the +# device's KeyMap. We early-return FALSE on those keycodes for repeatCount==0 +# (so they propagate to AudioService → PlayControllerReceiver Patch E discrete +# arms) and TRUE on repeatCount>0 (silent consume — defangs framework +# InputDispatcher::synthesizeKeyRepeatLocked synthesised repeats that drove +# the "stuck fast-forwarding" symptom). Full rationale + side-effects +# (hardware NEXT/PREV touch buttons lose long-press FF/RW) in +# docs/PATCHES.md Patch H section. - return v0 +BASE_ACTIVITY_SMALI = "smali/com/innioasis/y1/base/BaseActivity.smali" +base_activity_path = os.path.join(UNPACKED_DIR, BASE_ACTIVITY_SMALI) +if not os.path.exists(base_activity_path): + sys.exit(f"ERROR: Expected smali not found: {base_activity_path}") - :patch_h_continue - const/4 v3, 0x3""" +with open(base_activity_path, 'r') as f: + base_activity_src = f.read() -if OLD_DISPATCH_HEAD not in base_activity_src: +base_activity_src, patch_h_ok = _apply_dispatch_keyevent_patch( + base_activity_src, "patch_h", _base_activity_dispatch_pairs() +) +if not patch_h_ok: sys.exit( "ERROR: BaseActivity dispatchKeyEvent prologue not found.\n" f" File: {base_activity_path}\n" " The smali shape may differ from supported stock builds." ) -base_activity_src = base_activity_src.replace(OLD_DISPATCH_HEAD, NEW_DISPATCH_HEAD, 1) - if DEBUG_LOGGING: base_activity_src = _inject_log_d( base_activity_src, @@ -1205,83 +1391,16 @@ def _repl(m): with open(base_player_activity_path, 'r') as f: base_player_activity_src = f.read() -OLD_PLAYER_DISPATCH_HEAD = """\ -.method public dispatchKeyEvent(Landroid/view/KeyEvent;)Z - .locals 2 - - .line 304 - invoke-static {p1}, Lkotlin/jvm/internal/Intrinsics;->checkNotNull(Ljava/lang/Object;)V - - invoke-virtual {p1}, Landroid/view/KeyEvent;->getAction()I - - move-result v0""" - -NEW_PLAYER_DISPATCH_HEAD = """\ -.method public dispatchKeyEvent(Landroid/view/KeyEvent;)Z - .locals 2 - - invoke-virtual {p1}, Landroid/view/KeyEvent;->getKeyCode()I - - move-result v0 - - const/16 v1, 0x7e - - if-eq v0, v1, :patch_h2_avrcp_key - - const/16 v1, 0x7f - - if-eq v0, v1, :patch_h2_avrcp_key - - const/16 v1, 0x56 - - if-eq v0, v1, :patch_h2_avrcp_key - - const/16 v1, 0x57 - - if-eq v0, v1, :patch_h2_avrcp_key - - const/16 v1, 0x58 - - if-eq v0, v1, :patch_h2_avrcp_key - - goto :patch_h2_continue - - :patch_h2_avrcp_key - invoke-virtual {p1}, Landroid/view/KeyEvent;->getRepeatCount()I - - move-result v0 - - if-eqz v0, :patch_h2_propagate - - const/4 v0, 0x1 - - return v0 - - :patch_h2_propagate - const/4 v0, 0x0 - - return v0 - - :patch_h2_continue - - .line 304 - invoke-static {p1}, Lkotlin/jvm/internal/Intrinsics;->checkNotNull(Ljava/lang/Object;)V - - invoke-virtual {p1}, Landroid/view/KeyEvent;->getAction()I - - move-result v0""" - -if OLD_PLAYER_DISPATCH_HEAD not in base_player_activity_src: +base_player_activity_src, patch_h2_ok = _apply_dispatch_keyevent_patch( + base_player_activity_src, "patch_h2", _base_player_dispatch_pairs() +) +if not patch_h2_ok: sys.exit( "ERROR: BasePlayerActivity dispatchKeyEvent prologue not found.\n" f" File: {base_player_activity_path}\n" " The smali shape may differ from supported stock builds." ) -base_player_activity_src = base_player_activity_src.replace( - OLD_PLAYER_DISPATCH_HEAD, NEW_PLAYER_DISPATCH_HEAD, 1 -) - if DEBUG_LOGGING: base_player_activity_src = _inject_log_d( base_player_activity_src, @@ -2721,9 +2840,9 @@ def _apply_b5_dbg_instrumentation(src_rel, smali): # -- Step 4: Reassemble DEX with apktool ------------------------------------- print(f"\n[4/4] Reassembling smali -> DEX (this takes ~30 seconds)...") # apktool builds smali->DEX first, then tries aapt for resources. -# Since we decoded with --no-res, the aapt step fails -- but the DEX -# is already built by that point. We ignore the exit code intentionally. -subprocess.run( +# Since we decoded with --no-res, the aapt step often fails after DEX +# assembly; we still require classes.dex + classes2.dex under build/apk/. +build_result = subprocess.run( [java, *APKTOOL_JVM_FLAGS, "-jar", APKTOOL_JAR, "b", UNPACKED_DIR], capture_output=True, text=True ) @@ -2731,7 +2850,16 @@ def _apply_b5_dbg_instrumentation(src_rel, smali): dex1 = os.path.join(UNPACKED_DIR, "build", "apk", "classes.dex") dex2 = os.path.join(UNPACKED_DIR, "build", "apk", "classes2.dex") if not os.path.exists(dex1) or not os.path.exists(dex2): - sys.exit("ERROR: DEX assembly failed -- classes.dex or classes2.dex not produced.") + tail = (build_result.stdout or "") + (build_result.stderr or "") + if tail.strip(): + print(" apktool build output (last 4000 chars):") + print(tail[-4000:]) + sys.exit( + "ERROR: DEX assembly failed -- classes.dex or classes2.dex not produced.\n" + f" apktool exit code: {build_result.returncode}\n" + " Typical causes: smali register overflow (.locals too small), method-id\n" + " cap, or Java 22+ smali-assembler quirks — use JDK 17/21." + ) print(f" classes.dex {os.path.getsize(dex1):,} bytes") print(f" classes2.dex {os.path.getsize(dex2):,} bytes") diff --git a/src/su/Makefile b/src/su/Makefile index 0901213..e15cb29 100644 --- a/src/su/Makefile +++ b/src/su/Makefile @@ -4,15 +4,26 @@ # libc dependency. The only library it links is libgcc.a (for compiler # builtins like __aeabi_*), which the EPEL gcc-arm-linux-gnu package ships. # -# Install (Rocky/Alma/RHEL/Fedora with EPEL): -# sudo dnf install -y epel-release -# sudo dnf install -y gcc-arm-linux-gnu binutils-arm-linux-gnu +# Install: +# Debian / Ubuntu: sudo apt install gcc-arm-linux-gnueabi binutils-arm-linux-gnueabi +# Rocky/Fedora: sudo dnf install epel-release && \ +# sudo dnf install gcc-arm-linux-gnu binutils-arm-linux-gnu # -# The Fedora/RHEL package layout uses the arm-linux-gnueabi triplet -# internally (in /usr/lib/gcc/arm-linux-gnueabi/) but installs binaries with -# the arm-linux-gnu- prefix. Both names refer to the same toolchain. +# Fedora/RHEL ships arm-linux-gnu-gcc; Debian/Ubuntu ships arm-linux-gnueabi-gcc. +# Both target the same ARM EABI triplet for this no-libc build. -CC := arm-linux-gnu-gcc +CROSS_PREFIX ?= +ifeq ($(CROSS_PREFIX),) + ifneq ($(shell command -v arm-linux-gnu-gcc 2>/dev/null),) + CROSS_PREFIX := arm-linux-gnu- + else ifneq ($(shell command -v arm-linux-gnueabi-gcc 2>/dev/null),) + CROSS_PREFIX := arm-linux-gnueabi- + endif +endif + +CC := $(CROSS_PREFIX)gcc +STRIP := $(CROSS_PREFIX)strip +READELF := $(CROSS_PREFIX)readelf CFLAGS := -nostdlib -ffreestanding -fno-builtin -fno-stack-protector \ -Os -Wall -Wextra -std=gnu99 \ @@ -34,9 +45,9 @@ all: $(TARGET) verify-toolchain: @command -v $(CC) >/dev/null 2>&1 || { \ - echo "ERROR: $(CC) not in PATH"; \ - echo "Install: sudo dnf install -y epel-release && \\"; \ - echo " sudo dnf install -y gcc-arm-linux-gnu binutils-arm-linux-gnu"; \ + echo "ERROR: ARM cross-compiler not in PATH (tried arm-linux-gnu-gcc, arm-linux-gnueabi-gcc)"; \ + echo " Debian / Ubuntu: sudo apt install gcc-arm-linux-gnueabi binutils-arm-linux-gnueabi"; \ + echo " Rocky / Fedora: sudo dnf install gcc-arm-linux-gnu binutils-arm-linux-gnu"; \ exit 1; } @$(CC) --version | head -n1 @@ -51,7 +62,7 @@ $(BUILD_DIR)/su.o: su.c | $(BUILD_DIR) verify-toolchain $(TARGET): $(OBJS) $(CC) $(LDFLAGS) -o $@ $^ $(LIBS) - arm-linux-gnu-strip --strip-all $@ + $(STRIP) --strip-all $@ @echo "" @echo "Built: $@" @file $@ | sed 's/^/ /' @@ -62,7 +73,7 @@ $(TARGET): $(OBJS) check: $(TARGET) @file $(TARGET) | grep -q "ELF 32-bit LSB.*ARM" || { echo "FAIL: not ARM ELF"; exit 1; } @file $(TARGET) | grep -q "statically linked" || { echo "FAIL: not statically linked"; exit 1; } - @! arm-linux-gnu-readelf -d $(TARGET) | grep -q NEEDED || { echo "FAIL: has NEEDED entries"; exit 1; } + @! $(READELF) -d $(TARGET) | grep -q NEEDED || { echo "FAIL: has NEEDED entries"; exit 1; } @echo "OK: $(TARGET) is a self-contained statically-linked ARM ELF" clean: diff --git a/staging/README.md b/staging/README.md index 3053376..a5de35f 100644 --- a/staging/README.md +++ b/staging/README.md @@ -11,4 +11,6 @@ Override with `./apply.bash --artifacts-dir ` to point at a different dire The contents of this directory (other than this README) are `.gitignore`d so firmware never lands in commits. **`git clean -dfx` will nuke whatever you stage here** along with other build artifacts — keep a backup of `rom.zip` if you'd rather not re-download. +CI builds use ephemeral directories under `/tmp` via [`tools/ci/build-one.sh`](../tools/ci/build-one.sh), not this folder. + See the top-level [README](../README.md) Quick start for the full flow. diff --git a/tools/ci/build-one.sh b/tools/ci/build-one.sh new file mode 100644 index 0000000..33d1f64 --- /dev/null +++ b/tools/ci/build-one.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +# build-one.sh — download one upstream rom.zip, patch with koensayr --all, repack, +# and publish (or skip) a GitHub release on the current repo. +# +# Usage: +# ./tools/ci/build-one.sh \ +# --source-repo y1-community/y1-stock-rom \ +# --source-tag 3.0.2 \ +# [--release-tag 2.4.0-koensayr-3.0.2] # optional; derived as VERSION-koensayr-FW if omitted +# --download-url \ +# --digest \ +# --slug y1-stock-rom-3.0.2 \ +# [--force] + +set -euo pipefail + +SOURCE_REPO="" +SOURCE_TAG="" +RELEASE_TAG="" +RELEASE_TAG_ARG="" +DOWNLOAD_URL="" +DIGEST="" +SLUG="" +FORCE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --source-repo) SOURCE_REPO="$2"; shift 2 ;; + --source-tag) SOURCE_TAG="$2"; shift 2 ;; + --release-tag) RELEASE_TAG_ARG="$2"; shift 2 ;; + --download-url) DOWNLOAD_URL="$2"; shift 2 ;; + --digest) DIGEST="$2"; shift 2 ;; + --slug) SLUG="$2"; shift 2 ;; + --force) FORCE=true; shift ;; + -h|--help) + cat <<'EOF' +Usage: ./tools/ci/build-one.sh --source-repo REPO --source-tag TAG \ + --release-tag TAG --download-url URL --digest SHA256 --slug SLUG [--force] + +Environment: + KOENSAYR_SKIP_PUBLISH=1 Build only; do not create/upload GitHub release. + GITHUB_REPOSITORY Target repo for releases (defaults from gh). +EOF + exit 0 + ;; + *) + echo "ERROR: unknown arg $1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "$SOURCE_REPO" || -z "$SOURCE_TAG" || -z "$DOWNLOAD_URL" || -z "$SLUG" ]]; then + echo "ERROR: --source-repo, --source-tag, --download-url, and --slug are required" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +CI_DIR="${REPO_ROOT}/tools/ci" +# shellcheck source=firmware-manifest.sh +source "${CI_DIR}/firmware-manifest.sh" +WORKDIR="$(mktemp -d -t koensayr-ci.XXXXXX)" +trap 'rm -rf "$WORKDIR"' EXIT + +STAGING="${WORKDIR}/staging" +EXTRACT="${WORKDIR}/rom-extract" +mkdir -p "$STAGING" "$EXTRACT" + +KOENSAYR_VERSION="$(grep -E '^# Version:' apply.bash | awk '{print $3}')" +PATCH_REVISION="$("${CI_DIR}/patch-revision.sh")" +RELEASE_INTRO_FILE="${REPO_ROOT}/.github/workflows/workflow.md" + +if ! FW_VERSION="$(firmware_version_from_slug "$SLUG")"; then + echo "ERROR: slug ${SLUG} is not a known y1-stock-rom firmware id" >&2 + exit 1 +fi +RELEASE_TAG="${KOENSAYR_VERSION}-koensayr-${FW_VERSION}" +RELEASE_TITLE="${KOENSAYR_VERSION}-koensayr-${FW_VERSION}" +if [[ -n "$RELEASE_TAG_ARG" && "$RELEASE_TAG_ARG" != "$RELEASE_TAG" ]]; then + echo "[build-one] NOTE: matrix release-tag ${RELEASE_TAG_ARG} ignored; using ${RELEASE_TAG}" +fi + +# True when the commit being built was authored by SeanathanVT (GitHub / git display name). +commit_author_is_seanathanvt() { + local author email + author="$(git log -1 --format='%an')" + email="$(git log -1 --format='%ae')" + shopt -s nocasematch + [[ "$author" == *seanathanvt* || "$email" == *seanathanvt* ]] + local rc=$? + shopt -u nocasematch + return $rc +} + +# Idempotency: skip only when an existing release was built from the same upstream +# rom.zip *and* the same koensayr git revision (patches / apply.bash / Y1Bridge / su). +# Push workflows pass --force to always republish after commits to main. +release_already_current() { + local tag="$1" manifest_dir published_rev published_digest + if ! gh release view "$tag" >/dev/null 2>&1; then + return 1 + fi + manifest_dir="$(mktemp -d)" + if ! gh release download "$tag" -p "build-manifest.json" -D "$manifest_dir" >/dev/null 2>&1; then + rm -rf "$manifest_dir" + return 1 + fi + if [[ ! -f "${manifest_dir}/build-manifest.json" ]]; then + rm -rf "$manifest_dir" + return 1 + fi + read -r published_rev published_digest < <( + python3 - "${manifest_dir}/build-manifest.json" <<'PY' +import json, sys +data = json.load(open(sys.argv[1])) +print(data.get("koensayr_git_sha", ""), data.get("upstream_digest_sha256", "")) +PY + ) + rm -rf "$manifest_dir" + if [[ -z "$published_rev" || "$published_rev" != "$PATCH_REVISION" ]]; then + return 1 + fi + if [[ -n "$DIGEST" && "$published_digest" != "$DIGEST" ]]; then + return 1 + fi + return 0 +} + +if [[ "$FORCE" != true && "${KOENSAYR_SKIP_PUBLISH:-}" != "1" ]]; then + if release_already_current "$RELEASE_TAG"; then + echo "[build-one] Release ${RELEASE_TAG} already matches upstream digest and patch revision ${PATCH_REVISION:0:12}; skipping." + exit 0 + fi +fi + +echo "[build-one] Downloading upstream rom.zip.." +curl -fsSL -o "${STAGING}/rom.zip" "$DOWNLOAD_URL" + +if [[ -n "$DIGEST" ]]; then + actual_digest="$(sha256sum "${STAGING}/rom.zip" | awk '{print $1}')" + if [[ "$actual_digest" != "$DIGEST" ]]; then + echo "ERROR: downloaded rom.zip sha256 ${actual_digest} != expected ${DIGEST}" >&2 + exit 1 + fi + echo "[build-one] Upstream rom.zip sha256 verified (${DIGEST:0:16}…)" +fi + +rom_md5="$(md5sum "${STAGING}/rom.zip" | awk '{print $1}')" +expected_rom_md5="$(firmware_manifest_field "$FW_VERSION" rom_md5)" +if ! firmware_md5_matches_field "$expected_rom_md5" "$rom_md5"; then + echo "ERROR: rom.zip md5 ${rom_md5} does not match KNOWN_FIRMWARES v${FW_VERSION} (expected one of: ${expected_rom_md5})" >&2 + echo " Refusing to patch — update apply.bash manifest or fix the upstream download URL." >&2 + exit 1 +fi +echo "[build-one] rom.zip md5 matches KNOWN_FIRMWARES v${FW_VERSION}" + +echo "[build-one] Extracting upstream layout.." +"${CI_DIR}/extract-rom.sh" "${STAGING}/rom.zip" "$EXTRACT" + +echo "[build-one] Patching (koensayr ${KOENSAYR_VERSION}).." +./apply.bash --all --no-flash --artifacts-dir "$STAGING" + +# apply.bash names the loop-mounted image system-${VERSION_FIRMWARE}-devel.img +# (manifest version e.g. 3.0.2 / 3.0.7 when rom.zip matches KNOWN_FIRMWARES). +resolve_devel_img() { + local staging="$1" fw_version="$2" slug="$3" source_tag="$4" + local p + for p in \ + "${staging}/system-${fw_version}-devel.img" \ + "${staging}/system-${slug}-devel.img" \ + "${staging}/system-${source_tag}-devel.img"; do + if [[ -f "$p" ]]; then + echo "$p" + return 0 + fi + done + for p in "${staging}"/system-*-devel.img; do + if [[ -f "$p" ]]; then + echo "$p" + return 0 + fi + done + return 1 +} + +DEVEL_IMG="$(resolve_devel_img "$STAGING" "$FW_VERSION" "$SLUG" "$SOURCE_TAG" || true)" +if [[ -z "$DEVEL_IMG" || ! -f "$DEVEL_IMG" ]]; then + echo "ERROR: expected patched system image under ${STAGING}/" >&2 + echo " (tried system-${FW_VERSION}-devel.img, system-${SLUG}-devel.img, system-${SOURCE_TAG}-devel.img)" >&2 + exit 1 +fi +echo "[build-one] Using patched system image: ${DEVEL_IMG}" + +OUTPUT_ROM="${WORKDIR}/rom-koensayr.zip" +"${CI_DIR}/repack-rom.sh" "$EXTRACT" "$DEVEL_IMG" "$OUTPUT_ROM" + +# Fail closed if we accidentally repacked an unmodified upstream image. +upstream_sha="$(sha256sum "${STAGING}/rom.zip" | awk '{print $1}')" +output_sha="$(sha256sum "$OUTPUT_ROM" | awk '{print $1}')" +if [[ "$upstream_sha" == "$output_sha" ]]; then + echo "ERROR: patched rom.zip is byte-identical to upstream — refusing to publish." >&2 + exit 1 +fi +if [[ "$(wc -c < "${STAGING}/rom.zip")" -ge "$(wc -c < "$OUTPUT_ROM")" ]]; then + echo "ERROR: patched rom.zip is not larger than upstream (expected system.img changes)." >&2 + exit 1 +fi + +# Copy staging rom for convenience +cp -f "$OUTPUT_ROM" "${STAGING}/rom-koensayr.zip" + +MANIFEST="${WORKDIR}/build-manifest.json" +python3 - "$MANIFEST" </dev/null || true + exit 0 +fi + +NOTES="${WORKDIR}/release-notes.md" +{ + echo "# ${RELEASE_TITLE}" + echo "" + if [[ -f "$RELEASE_INTRO_FILE" ]]; then + grep -v '^Devs:' "$RELEASE_INTRO_FILE" || true + else + echo "Koensayr patched firmware for the Innioasis Y1." + fi + echo "" + if commit_author_is_seanathanvt; then + echo "Detailed notes:" + echo "" + git log -1 --format='%B' + echo "" + fi + echo "---" + echo "" + cat < "$NOTES" +cat >> "$NOTES" </dev/null 2>&1; then + echo "[build-one] Removing legacy release tag ${LEGACY_RELEASE_TAG}.." + gh release delete "$LEGACY_RELEASE_TAG" --yes + fi +done + +echo "[build-one] Publishing GitHub release ${RELEASE_TAG} (${RELEASE_TITLE}).." +echo "[build-one] upstream sha256: ${upstream_sha}" +echo "[build-one] output sha256: ${output_sha}" +if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + echo "[build-one] Removing prior release ${RELEASE_TAG} (replace with fresh build).." + gh release delete "$RELEASE_TAG" --yes +fi +gh release create "$RELEASE_TAG" "$RELEASE_ASSET" "$MANIFEST" \ + --title "${RELEASE_TITLE}" \ + --notes-file "$NOTES" + +echo "[build-one] Published ${RELEASE_TAG} (rom.zip + build-manifest.json)" diff --git a/tools/ci/discover-inputs.sh b/tools/ci/discover-inputs.sh new file mode 100644 index 0000000..113b387 --- /dev/null +++ b/tools/ci/discover-inputs.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# discover-inputs.sh — build matrix for allowed y1-stock-rom rom.zip releases. +# +# Only these upstream tags are considered: +# y1-community/y1-stock-rom → 3.0.2, Latest-3.0.7 (published as VERSION-koensayr-3.0.2 / VERSION-koensayr-3.0.7) +# +# Usage: +# ./tools/ci/discover-inputs.sh [--source-repo OWNER/NAME] [--force] + +set -euo pipefail + +SOURCE_FILTER="" +FORCE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --source-repo) + SOURCE_FILTER="$2" + shift 2 + ;; + --force) + FORCE=true + shift + ;; + -h|--help) + cat <<'EOF' +Usage: ./tools/ci/discover-inputs.sh [--source-repo OWNER/NAME] [--force] + +Emits a JSON array of matrix objects: + source_repo, source_tag, release_tag, download_url, digest, slug + +Upstream allowlist (rom.zip only): + y1-community/y1-stock-rom: 3.0.2, Latest-3.0.7 (→ koensayr release VERSION-koensayr-3.0.2 / VERSION-koensayr-3.0.7) +EOF + exit 0 + ;; + *) + echo "ERROR: unknown arg $1" >&2 + exit 1 + ;; + esac +done + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: gh CLI required" >&2 + exit 1 +fi +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 required" >&2 + exit 1 +fi + +REPOS=( + "y1-community/y1-stock-rom" +) + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +KOENSAYR_VERSION="$(grep -E '^# Version:' "${REPO_ROOT}/apply.bash" | awk '{print $3}')" + +python3 - "$SOURCE_FILTER" "$FORCE" "$KOENSAYR_VERSION" "${REPOS[@]}" <<'PY' +import json +import subprocess +import sys + +source_filter = sys.argv[1] +force = sys.argv[2] == "true" +koensayr_version = sys.argv[3] +repos = sys.argv[4:] + +Y1_REPO = "y1-community/y1-stock-rom" +# Upstream GitHub release tag → firmware version for koensayr release naming. +Y1_UPSTREAM_TAGS = { + "3.0.2": "3.0.2", + "Latest-3.0.7": "3.0.7", +} + + +def release_has_rom_zip(repo: str, tag: str) -> dict | None: + out = subprocess.run( + [ + "gh", + "api", + f"repos/{repo}/releases/tags/{tag}", + "--jq", + '[.assets[] | select(.name == "rom.zip")][0]', + ], + capture_output=True, + text=True, + check=False, + ) + if out.returncode != 0 or not out.stdout.strip() or out.stdout.strip() == "null": + return None + try: + asset = json.loads(out.stdout) + except json.JSONDecodeError: + return None + digest = asset.get("digest") or "" + if digest.startswith("sha256:"): + digest = digest[7:] + return { + "browser_download_url": asset["browser_download_url"], + "digest": digest, + } + + +entries: list[dict] = [] + +for repo in repos: + if source_filter and repo != source_filter: + continue + for upstream_tag in sorted( + Y1_UPSTREAM_TAGS.keys(), + key=lambda t: Y1_UPSTREAM_TAGS[t], + ): + asset = release_has_rom_zip(repo, upstream_tag) + if asset is None: + continue + fw_version = Y1_UPSTREAM_TAGS[upstream_tag] + release_tag = f"{koensayr_version}-koensayr-{fw_version}" + slug = f"y1-stock-rom-{fw_version}" + entries.append( + { + "source_repo": repo, + "source_tag": upstream_tag, + "release_tag": release_tag, + "download_url": asset["browser_download_url"], + "digest": asset["digest"], + "slug": slug, + "force": force, + } + ) + +print(json.dumps(entries)) +PY diff --git a/tools/ci/extract-rom.sh b/tools/ci/extract-rom.sh new file mode 100644 index 0000000..812280e --- /dev/null +++ b/tools/ci/extract-rom.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# extract-rom.sh — unzip rom.zip and record whether system.img was sparse. +# +# Usage: ./tools/ci/extract-rom.sh + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +ROM_ZIP="$1" +EXTRACT_DIR="$2" + +mkdir -p "$EXTRACT_DIR" +unzip -q -o "$ROM_ZIP" -d "$EXTRACT_DIR" + +SYS="${EXTRACT_DIR}/system.img" +if [[ ! -f "$SYS" ]]; then + echo "ERROR: ${ROM_ZIP} has no system.img at zip root" >&2 + exit 1 +fi + +is_sparse=0 +if command -v file >/dev/null 2>&1 && file "$SYS" | grep -q "Android sparse image"; then + is_sparse=1 +else + magic=$(head -c 4 "$SYS" 2>/dev/null | od -An -v -t x1 | tr -d ' \n') + [[ "$magic" == "3aff26ed" ]] && is_sparse=1 +fi + +echo "$is_sparse" > "${EXTRACT_DIR}/.koensayr-system-sparse" +echo "[extract] ${ROM_ZIP} → ${EXTRACT_DIR} (system.img sparse=${is_sparse})" diff --git a/tools/ci/firmware-manifest.sh b/tools/ci/firmware-manifest.sh new file mode 100644 index 0000000..3406efc --- /dev/null +++ b/tools/ci/firmware-manifest.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# firmware-manifest.sh — read KNOWN_FIRMWARES rows from apply.bash (single source of truth). +# +# Usage (sourced): +# firmware_manifest_row 3.0.2 +# firmware_manifest_field 3.0.2 rom_md5 + +set -euo pipefail + +FIRMWARE_MANIFEST_APPLY_BASH="${FIRMWARE_MANIFEST_APPLY_BASH:-}" + +firmware_manifest_apply_bash() { + if [[ -n "$FIRMWARE_MANIFEST_APPLY_BASH" && -f "$FIRMWARE_MANIFEST_APPLY_BASH" ]]; then + echo "$FIRMWARE_MANIFEST_APPLY_BASH" + return 0 + fi + local root="${REPO_ROOT:-}" + if [[ -z "$root" ]]; then + root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + fi + echo "${root}/apply.bash" +} + +firmware_manifest_row() { + local version="$1" + local apply + apply="$(firmware_manifest_apply_bash)" + grep -E "^[[:space:]]+\"${version}\|" "$apply" | head -1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/^"//;s/"$//' +} + +firmware_manifest_field() { + local version="$1" field="$2" + local row idx + row="$(firmware_manifest_row "$version")" + if [[ -z "$row" ]]; then + return 1 + fi + case "$field" in + system_md5) idx=2 ;; + boot_md5) idx=3 ;; + rom_md5) idx=4 ;; + music_apk) idx=5 ;; + *) echo "ERROR: unknown firmware_manifest_field ${field}" >&2; return 2 ;; + esac + echo "$row" | cut -d'|' -f"$idx" +} + +# firmware_md5_matches_field +firmware_md5_matches_field() { + local field_value="$1" actual="$2" alt + IFS=',' read -ra alts <<< "$field_value" + for alt in "${alts[@]}"; do + alt="${alt//[[:space:]]/}" + if [[ "$actual" == "$alt" ]]; then + return 0 + fi + done + return 1 +} + +firmware_version_from_slug() { + local slug="$1" + if [[ "$slug" =~ ^y1-stock-rom-([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "${BASH_REMATCH[1]}" + return 0 + fi + return 1 +} diff --git a/tools/ci/patch-revision.sh b/tools/ci/patch-revision.sh new file mode 100644 index 0000000..c68ce9a --- /dev/null +++ b/tools/ci/patch-revision.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# patch-revision.sh — identifier for koensayr patch sources baked into CI rom.zip output. +# +# Used to skip scheduled rebuilds only when upstream firmware and this revision +# already produced the current GitHub release. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git rev-parse HEAD + exit 0 +fi + +# Fallback when .git is unavailable (local tarball builds). +{ + grep -E '^# Version:' apply.bash || true + find apply.bash src/patches src/Y1Bridge src/su -type f 2>/dev/null | LC_ALL=C sort | while read -r f; do + sha256sum "$f" + done +} | sha256sum | awk '{print $1}' diff --git a/tools/ci/repack-rom.sh b/tools/ci/repack-rom.sh new file mode 100644 index 0000000..7976c69 --- /dev/null +++ b/tools/ci/repack-rom.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# repack-rom.sh — replace system.img inside an extracted rom.zip tree with a +# patched raw (or re-sparsified) system image, then produce rom.zip. +# +# Usage: +# ./tools/ci/repack-rom.sh +# +# Writes /.koensayr-system-sparse (0 or 1) on first extract if +# missing; pass the same extract dir used when the flag was recorded. + +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: ./tools/ci/repack-rom.sh + +Expects to contain the contents of upstream rom.zip (including +system.img). Replaces system.img with (raw ext4 from +apply.bash). If .koensayr-system-sparse in the extract dir is 1, runs img2simg +before zipping. + +Create .koensayr-system-sparse when extracting upstream rom.zip: + echo 1 > dir/.koensayr-system-sparse # if system.img inside zip was sparse + echo 0 > dir/.koensayr-system-sparse # raw +EOF + exit 0 + ;; +esac + +if [[ $# -ne 3 ]]; then + echo "usage: $0 " >&2 + echo " see: $0 --help" >&2 + exit 1 +fi + +EXTRACT_DIR="$1" +PATCHED_RAW="$2" +OUTPUT_ZIP="$3" +TARGET="${EXTRACT_DIR}/system.img" +SPARSE_FLAG="${EXTRACT_DIR}/.koensayr-system-sparse" + +if [[ ! -d "$EXTRACT_DIR" ]]; then + echo "ERROR: extract dir not found: ${EXTRACT_DIR}" >&2 + exit 1 +fi +if [[ ! -f "$PATCHED_RAW" ]]; then + echo "ERROR: patched system image not found: ${PATCHED_RAW}" >&2 + exit 1 +fi + +was_sparse=0 +if [[ -f "$SPARSE_FLAG" ]]; then + read -r was_sparse < "$SPARSE_FLAG" || was_sparse=0 +fi + +if [[ "$was_sparse" == "1" ]]; then + if ! command -v img2simg >/dev/null 2>&1; then + echo "ERROR: img2simg required to re-sparsify system.img (install android-tools)" >&2 + exit 1 + fi + echo "[repack] Converting patched raw system.img → sparse.." + img2simg "$PATCHED_RAW" "$TARGET" +else + echo "[repack] Installing patched raw system.img.." + cp -f "$PATCHED_RAW" "$TARGET" +fi + +echo "[repack] Building ${OUTPUT_ZIP}.." +rm -f "$OUTPUT_ZIP" +( + cd "$EXTRACT_DIR" + # Zip root entries only (exclude marker file from archive). + zip -r -9 "$OUTPUT_ZIP" . -x '.koensayr-system-sparse' +) + +echo "[repack] Done: ${OUTPUT_ZIP} ($(wc -c < "$OUTPUT_ZIP") bytes)"