#!/usr/bin/env bash # Is the falcon actually in the APK? The way this loses is silent: gen/android # is regenerated and gitignored, so a project rebuilt without the overlay copy # still compiles, still installs, and wears tauri's logo again with nothing in # the log. So every APK this repo makes is opened and read before it is # installed. Called by build.sh and dev.sh; run it by hand on any APK. # # bash src-tauri/android/verify-icon.sh [--crunched] # # Since the background became a gradient there is a second way to lose, and it # is quieter still: the adaptive icon's is a reference, and a # reference resolves to whatever the APK happens to hold. A stale APK, or an # overlay copy that did not run, and the name points at something else while # every PNG in the file is still the falcon's. So the check does not ask # whether drawable/ic_launcher_ground.xml is in the APK — it asks what # RESOLVES to, through the resource table, in both build types, # and then reads concept 04's six gradient stops back out of the thing it # found. That half needs aapt2; without it the ground says NOT CHECKED and the # rest of the script still runs. # # Debug and release are read DIFFERENTLY, because AGP treats their resources # differently and both differences were measured on 2026-09-14: # # debug packaged with --no-crunch (AGP's default for the debuggable build # type), so every entry is byte for byte the file on disk — checked # against the real 107 MB APK. Names are names: res/mipmap-hdpi-v4/… # release --crunched. Two things change. aapt2 re-encodes every PNG, so the # bytes are no longer the file; and AGP's resource optimization # (OptimizeResourcesTask → SHORTEN_RESOURCE_PATHS) renames the lot # to res/Ag.png, so the names are gone as well — an APK optimized # here had zero entries matching "res/mipmap". Nothing but the # resource table can say what is what, so that is what is read: # `aapt2 dump resources`, then the pixel dimensions of whatever it # points at. 72x72 at hdpi still tells the kit's legacy icon from # tauri's 49x49. # # If anyone ever sets isCrunchPngs = true on the debug build type, the debug # path fails on byte-equality with the file named; --crunched is the answer. set -uo pipefail # Every membership test below reads its haystack from a HERESTRING, never from # `echo "$VAR" | grep -q`. Measured 2026-09-15: that pipeline plus the # `pipefail` above is a race. `grep -q` exits the instant it matches, `echo` is # still pushing 70 KB of `unzip -l` down a closed pipe, takes SIGPIPE and exits # 141 — and pipefail promotes the dead writer's status to the whole pipeline, # so a check that FOUND what it wanted reports failure. Whether it fires # depends on where the match falls in the stream, so three runs against one # unchanged APK gave fail / pass / fail. A herestring has no writer process and # nothing to kill. APK="${1:-}" test -n "$APK" || { echo "usage: verify-icon.sh [--crunched]"; exit 2; } # resolved before the cd, so a relative path typed by hand still means what it # said; the repo root is where $RES is [ -f "$APK" ] && APK="$(cd "$(dirname "$APK")" && pwd)/$(basename "$APK")" # the repo this script is IN, not a drive letter. build.sh and dev.sh have # already cd'd to /f/Coding/SoulStream and call this as # src-tauri/android/verify-icon.sh, so ../.. lands on the same place they are # — but a checkout on another letter, or an APK checked by hand from # somewhere else, then compares against ITS OWN overlay rather than F:'s. cd "$(dirname "$0")/../.." || { echo "cannot find the repo above $0"; exit 2; } CRUNCH=0; [ "${2:-}" = "--crunched" ] && CRUNCH=1 RES=src-tauri/android/res DPI="mdpi hdpi xhdpi xxhdpi xxxhdpi" NAMES="ic_launcher ic_launcher_round ic_launcher_foreground ic_launcher_monochrome" bad() { echo "ICON CHECK FAILED: $*"; exit 1; } test -f "$APK" || bad "no APK at $APK" test -d "$RES" || bad "$RES is missing — there is nothing to check the APK against" # 1. the overlay itself, before the APK is opened. The monochrome layer is the # one Android 13 tints for themed icons and the one tauri's generator has # never made, so a later edit that drops it should not pass quietly. for x in ic_launcher ic_launcher_round; do test -f "$RES/mipmap-anydpi-v26/$x.xml" || bad "$RES/mipmap-anydpi-v26/$x.xml is missing from the overlay" grep -q " layer — themed icons need one" done # The ground is the other half of the icon and three quarters of what a # masked tile shows, so a later edit that flattens it back to one colour # should not pass quietly either. GROUND="$RES/drawable/ic_launcher_ground.xml" test -f "$GROUND" || bad "$GROUND is missing from the overlay — the adaptive background has nowhere to come from" grep -q 'android:type="linear"' "$GROUND" || bad "$GROUND declares no linear gradient — concept 04's surface" grep -q 'android:type="radial"' "$GROUND" || bad "$GROUND declares no radial gradient — concept 04's glow" # quote-anchored, so the comment above them cannot satisfy the check for c in FF0A2330 FF07141F FF040A10 2E31C0CB 0D1F8FC9 0007141F; do grep -qi "\"#$c\"" "$GROUND" || bad "$GROUND has lost concept 04's stop #$c — it is no longer the desktop's ground" done for x in ic_launcher ic_launcher_round; do grep -q '@drawable/ic_launcher_ground' "$RES/mipmap-anydpi-v26/$x.xml" \ || bad "$RES/mipmap-anydpi-v26/$x.xml does not point its background at @drawable/ic_launcher_ground" done for d in $DPI; do for n in $NAMES; do test -f "$RES/mipmap-$d/$n.png" || bad "$RES/mipmap-$d/$n.png is missing from the overlay" done; done # the PNG header's width and height, eight bytes at offset 16 — the one thing # about an image that survives being re-encoded. Printed as 72x72, so a failure # reads as a size rather than as hex. dims() { local h h="$(xxd -s 16 -l 8 -p)" [ "${#h}" = 16 ] || { echo "unreadable"; return; } printf '%dx%d\n' "$((16#${h:0:8}))" "$((16#${h:8:8}))" } LIST="$(unzip -l "$APK")" || bad "cannot read $APK" # aapt2 is beside the zipalign and apksigner build.sh has just used. Found # here rather than in the release branch below, because both build types want # it now: only the resource table can say what resolves to. SDKU="" [ -n "${ANDROID_HOME:-}" ] && SDKU="$(cygpath -u "$ANDROID_HOME" 2>/dev/null || echo "")" [ -z "$SDKU" ] && [ -n "${LOCALAPPDATA:-}" ] && SDKU="$(cygpath -u "$LOCALAPPDATA" 2>/dev/null || echo "")/Android/Sdk" A2="$(ls -1 "$SDKU"/build-tools/*/aapt2.exe 2>/dev/null | sort -V | tail -1 || true)" # aapt2 is a WINDOWS binary and $APK is an MSYS path. Normally MSYS rewrites # one into the other on the way across, but deploy.sh exports # MSYS_NO_PATHCONV=1 for the adb calls, and that switch is global — so inside # a deploy the path arrives as /f/Coding/... and aapt2 answers "failed opening # zip: I/O error", which reads exactly like a corrupt APK. Hand aapt2 the # Windows form explicitly and keep $APK for unzip and cmp, which want the # MSYS one. (Checked standalone this never fires; it only bites under deploy.) APKW="$(cygpath -w "$APK" 2>/dev/null || echo "$APK")" # 2. the ground, in BOTH build types. Not "is the file in the APK" — the # adaptive icon's is a REFERENCE, so what matters is what it # resolves to, and that is the one question a stale APK answers wrongly # while every other check still passes. (build.sh pipes Gradle through # `| tail -20 || true` and then runs `ls -la "$APK"`, so a Gradle run that # failed leaves the PREVIOUS build's APK exactly where the next step looks # for it.) Everything below comes out of the resource table and never out # of a path: a release APK has had res/mipmap-anydpi-v26/ic_launcher.xml # renamed to res/BW.xml, and the table is the only thing that still knows. TBL="" [ -n "$A2" ] && { TBL="$("$A2" dump resources "$APKW" 2>/dev/null)" || bad "aapt2 could not read the resource table of $APK"; } # name [config] -> the file that resource points at. The config is not # optional detail: mipmap/ic_launcher has SIX entries — the anydpi adaptive # XML and five legacy PNGs — so a name-only lookup hands back # res/mipmap-mdpi-v4/ic_launcher.png and the whole check collapses. An empty # config means the entry with no qualifier at all, which is what a plain # drawable has. "anydpi" matches the "anydpi-v26" too, as look() does. path_of() { echo "$TBL" | awk -v n="$1" -v c="${2:-}" ' $1 == "resource" && $3 == n { f = 1; next } /^[ \t]*resource 0x/ { f = 0 } f { cfg = $1; gsub(/[()]/, "", cfg) if (c == "" || cfg == c || cfg ~ "^" c "-v[0-9]+$") for (i = 1; i <= NF; i++) if ($i ~ /^res\//) { print $i; exit } }'; } name_of() { echo "$TBL" | awk -v id="$1" '$1 == "resource" && $2 == id { print $3; exit }'; } if [ -z "$TBL" ]; then echo "== ground: NOT CHECKED — no aapt2 under ${SDKU:-(no SDK path)}/build-tools." echo " What points at is a resource id; only the table reads it." elif grep -q "0_resource_name_obfuscated" <<< "$TBL"; then echo "== ground: NOT CHECKED — this APK's resource names are collapsed." else ADAPT="$(path_of mipmap/ic_launcher anydpi)" test -n "$ADAPT" || bad "mipmap/ic_launcher has no anydpi entry in this APK — the adaptive icon is not in it, so $RES never reached src-tauri/gen/android/app/src/main/res" BGID="$("$A2" dump xmltree "$APKW" --file "$ADAPT" 2>/dev/null \ | awk '/E: background/ { f = 1 } f && /android:drawable\(/ { sub(/.*=@?/, ""); print; exit }')" test -n "$BGID" || bad "the adaptive icon in this APK ($ADAPT) declares no " BGNAME="$(name_of "$BGID")" [ "$BGNAME" = "drawable/ic_launcher_ground" ] \ || bad "the APK's adaptive background is ${BGNAME:-$BGID}, not drawable/ic_launcher_ground — a stale APK, or the overlay did not reach src-tauri/gen/android/app/src/main/res" # and that what it resolves to is still concept 04 rather than a flat # fill. aapt:attr lifts each inline out into a resource of its # own, so the two fills are references as well; follow the ids rather than # the $name__N filenames aapt2 happens to give them this version. GPATH="$(path_of drawable/ic_launcher_ground)" test -n "$GPATH" || bad "drawable/ic_launcher_ground resolves to no file in this APK" GTREE="$("$A2" dump xmltree "$APKW" --file "$GPATH" 2>/dev/null)" || bad "aapt2 cannot read $GPATH out of this APK" IDS="$(echo "$GTREE" | grep -oE 'fillColor\(0x[0-9a-f]+\)=@0x[0-9a-f]+' | grep -oE '0x[0-9a-f]+$')" NFILL="$(echo "$IDS" | grep -c . || true)" [ "$NFILL" = 2 ] || bad "the ground in this APK has $NFILL gradient fills, not 2 — concept 04 is a surface and the glow over it" STOPS="" for id in $IDS; do g="$(path_of "$(name_of "$id")")" test -n "$g" || bad "the ground's gradient $id has no file in the resource table" STOPS="$STOPS $("$A2" dump xmltree "$APKW" --file "$g" | grep -oE 'color\(0x[0-9a-f]+\)=#[0-9a-f]{8}' | grep -oE '[0-9a-f]{8}$' | tr '\n' ' ')" done for c in ff0a2330 ff07141f ff040a10 2e31c0cb 0d1f8fc9 0007141f; do grep -qw "$c" <<< "$STOPS" || bad "the ground in this APK has lost concept 04's stop #$c" done echo "== ground: = $BGNAME, concept 04's six stops, read back out of the APK" fi # What none of this can see is a pixel. Whether the gradient BANDS on the # phone's OLED, how the glow reads under the Pixel's mask and how the falcon # sits on a lit ground rather than a flat one are a launcher screenshot, not a # resource table. if [ "$CRUNCH" = 0 ]; then # 3. debug: the adaptive XMLs by name, then all twenty PNGs byte for byte. # Nothing but the overlay makes a mipmap-anydpi-v26 file, so its absence # is exactly the failure this script exists for. for x in ic_launcher ic_launcher_round; do grep -q " res/mipmap-anydpi-v26/$x\.xml$" <<< "$LIST" \ || bad "res/mipmap-anydpi-v26/$x.xml is not in the APK — $RES never reached src-tauri/gen/android/app/src/main/res" done # and the ground by name as well. In release the paths are shortened # away and only the table above can find it; here it is still a file. grep -q " res/drawable/ic_launcher_ground\.xml$" <<< "$LIST" \ || bad "res/drawable/ic_launcher_ground.xml is not in the APK — the adaptive background never reached src-tauri/gen/android/app/src/main/res" for d in $DPI; do for n in $NAMES; do got="res/mipmap-$d-v4/$n.png" grep -q " $got$" <<< "$LIST" || bad "$got is not in the APK" unzip -p "$APK" "$got" | cmp -s - "$RES/mipmap-$d/$n.png" \ || bad "$got in the APK is not $RES/mipmap-$d/$n.png — a stale res in gen/, the overlay lost a file, or this build crunched its PNGs (then: --crunched)" done; done echo "== icon: adaptive + monochrome, five densities, byte for byte from $RES" exit 0 fi # 4. release. Ask the resource table what the names point at, since the paths # have very likely been shortened away. # $A2 and $SDKU come from section 2, which both build types now need. if [ -z "$A2" ]; then echo "== icon: NOT CHECKED — no aapt2 under ${SDKU:-(no SDK path)}/build-tools." echo " A release APK's resource paths are shortened (res/Ag.png), so names" echo " alone cannot find the icon. Unverified here, not missing." exit 0 fi # one line per file entry: " " MAP="$(echo "$TBL" | awk ' /^[ \t]*resource 0x[0-9a-f]+ mipmap\// { split($3, a, "/"); cur = a[2]; next } /^[ \t]*resource 0x/ { cur = ""; next } cur != "" { for (i = 1; i <= NF; i++) if ($i ~ /^res\//) { cfg = $1; gsub(/[()]/, "", cfg); print cur, cfg, $i } } ')" || bad "aapt2 could not read the resource table of $APK" # the other half of the same optimization pass: with resource shrinking on, # aapt2 also collapses the NAMES, and then even the table cannot say which # entry is the launcher icon if grep -q "0_resource_name_obfuscated" <<< "$MAP"; then echo "== icon: NOT CHECKED — this APK's resource names are collapsed" echo " (aapt2 optimize --collapse-resource-names, which comes with resource" echo " shrinking). Nothing left to match on. Unverified here, not missing." exit 0 fi # the config is printed as "mdpi" or as "mdpi-v4" depending on the link's # min-sdk — both seen on this machine from the same resource tree, so both are # taken, and "anydpi" matches the "anydpi-v26" the adaptive icon lives in look() { echo "$MAP" | awk -v n="$1" -v c="$2" '$1 == n && ($2 == c || $2 ~ "^" c "-v[0-9]+$") { print $3; exit }'; } for x in ic_launcher ic_launcher_round; do got="$(look "$x" anydpi)" test -n "$got" || bad "mipmap/$x has no anydpi entry in this APK — the adaptive icon is not in it, so $RES never reached src-tauri/gen/android/app/src/main/res" done for d in $DPI; do for n in $NAMES; do got="$(look "$n" "$d")" test -n "$got" || bad "mipmap/$n has no $d entry in this APK" a="$(unzip -p "$APK" "$got" | dims)" b="$(dims < "$RES/mipmap-$d/$n.png")" [ "$a" = "$b" ] || bad "mipmap/$n at $d ($got) is $a, not the overlay's $b" done; done echo "== icon: adaptive + monochrome, five densities, by resource table ($(basename "$(dirname "$A2")")); release re-encodes the bytes, so dimensions only"