android: the handoff — the pending rounds, the pipeline, and what is unverified

Everything written this session that has not been through the device: rounds 43
to 48 (each patch adversarially verified, 43's transient-notice expectation
fixed), a partial round 49, the pipeline scripts, the Android Auto design and
the icon comparison mock. Also the two harness changes that were never A/B'd —
adaptive waits, which may only shorten a declared wait and never lengthen it,
and a ship that runs the mock walk and the deploy at the same time. The A/B and
its 255-step baseline are in handoff/tools; main stays at what the phone has
actually walked.
This commit is contained in:
KeelrLeek committed 2026-09-14 07:57:41 -05:00
1 parent 867fd42972
commit 9f0601027c
400 files changed
+26401 -3

No files matched your search

+328
View File
@@ -0,0 +1,328 @@
// Round forty-seven: the launcher's ground. Round 44 gave the phone the real
// falcon and made the adaptive background ONE COLOUR — `#FF05080B`, the kit's
// flat night — with a note that a gradient was being mocked and would replace
// one file. The mock has settled it: the falcon covers about a quarter of a
// circle-masked tile, so the ground is three quarters of what anyone sees; the
// desktop ships concept 04 "Deep navy glow" and its glow survives every mask,
// lifting the visible top-left corner from #05080B to #0E3543; and a phone
// wearing a flatter icon than the desktop is a divergence for no gain. So the
// `@color/` becomes a `@drawable/`: one vector carrying concept 04's linear
// surface and the radial glow over it, layer-fit — the 1024 artwork mapped
// onto the whole 108 layer rather than onto the 72 window, which is
// indistinguishable under a circle or a squircle and never shows a flat edge
// when a launcher parallaxes the layers apart.
//
// A VECTOR and not five PNGs, on evidence rather than preview: the kit's glow
// is translate(270 220) rotate(43) scale(660), a UNIFORM scale, so the SVG
// ellipse is a circle and the rotate is a no-op — which is exactly what
// android:type="radial" can carry (centre + gradientRadius, no transform of
// its own). Two numbers there and no vector could have said it. The linear
// axis is 51.1 degrees off horizontal, which also rules out the other cheap
// route, a <layer-list> of <shape>s, whose android:angle "must be a multiple
// of 45". Folding stop-opacity into 8-bit alpha costs at most one level of
// 255. Banding is not a vector-vs-PNG question — both are 8-bit and quantise
// identically — but a PNG could be dithered and a vector cannot, so if Kyle's
// launcher screenshot contours, the fix is a dithered density set behind this
// same @drawable/ic_launcher_ground name.
//
// Named ic_launcher_ground and NOT ic_launcher_background: the generated
// project still carries Android Studio's template drawable of that name (the
// green robot), so a build whose overlay copy failed would silently link it.
// Under this name the same failure is a hard aapt2 link error.
//
// verify-icon.sh follows. It does not ask whether the file is in the APK — it
// asks what <background> RESOLVES to, through the resource table, in both
// build types, and then reads concept 04's six stops back out of what it
// found. That is the check that catches a stale APK as well as a lost
// overlay. What it cannot do is see a pixel: banding, the glow's weight under
// the Pixel's mask and how the falcon reads on a lit ground are a launcher
// screenshot on Kyle's phone, and nothing here claims otherwise.
//
// Round 45's notification glyphs and splash sit on the same overlay and are
// not touched; nor are the legacy mipmaps (with minSdk 26, mipmap-anydpi-v26
// always wins, and verify-icon compares them byte for byte). No build.sh, no
// dev.sh — round 44's `cp -r src-tauri/android/res/.` takes the whole tree, so
// a new drawable/ directory needs no build change. No Rust, no Kotlin, no
// frontend, no walk step.
//
// Not "unreachable from inside the app", though, and the round does not claim
// it: MediaHub.appIcon() in the media-session plugin draws whatever
// getApplicationIcon() returns into a 512 px bitmap and hands it to the
// lock-screen card as the large icon for any track whose cover has not been
// decoded yet, so the card carries this ground too. It is the ONE reader of
// the launcher icon inside the app (grepped: nothing else touches
// getApplicationIcon, R.mipmap or @mipmap at runtime), it is a fallback and
// not a fixture, it caches after one decode, and the walk asserts the card's
// android.title and the session's PLAYBACK state — never its icon — so no
// step changes meaning. ANDROID.md puts it on the list of things only the
// phone can answer, since it is the quickest of them for Kyle to look at.
const fs = require('fs');
const root = process.env.SS_ROOT || 'F:/Coding/SoulStream/';
const scratch = 'C:/Users/Kyle/AppData/Local/Temp/claude/F--Coding-SoulStream/5488514a-11e8-482a-b0a0-20cb511ee110/scratchpad/';
const ins = (name) => fs.readFileSync(scratch + name, 'utf8').replace(/\s+$/, '');
let p, s;
/* `save` does not save. Round 44's pre-flight checked every anchor against
the files as they were ON DISK and then wrote as it went, which is one step
short: an insert can make a LATER anchor ambiguous, and then the check
passes while the run still dies halfway through with two files rewritten.
(The ground block below carries its own copy of the aapt2 discovery, and
that is exactly what it did.) So every edit is computed in memory first,
`save` only queues the result, and nothing reaches the disk until all of
them are queued — the reps themselves, in the order they really run, are
the pre-flight. */
const queued = [];
const load = (file) => { p = file; s = fs.readFileSync(root + p, 'utf8'); };
const save = () => { queued.push([p, s]); };
const once = (a) => {
const i = s.indexOf(a);
if (i < 0) throw new Error('missing in ' + p + ': ' + a.slice(0, 90));
if (s.indexOf(a, i + 1) >= 0) throw new Error('ambiguous in ' + p + ': ' + a.slice(0, 90));
return i;
};
const rep = (a, b) => { once(a); s = s.replace(a, () => b); };
const NL = String.fromCharCode(10);
const RES = root + 'src-tauri/android/res/';
const GROUND = RES + 'drawable/ic_launcher_ground.xml';
/* ── the re-run guard, first and before anything is read ──────────────────
Two files take `rep`s and three are rewritten wholesale; a second pass
would throw partway and leave the tree half done, which is exactly what
round 44's own first draft did. The ground drawable is what this round
creates and nothing else in the repo makes it. */
if (fs.existsSync(GROUND)) {
throw new Error('src-tauri/android/res/drawable/ic_launcher_ground.xml already exists — round 47 has already run against ' + root);
}
/* ── pre-flight: everything checked before a byte moves ───────────────── */
const holds = (file, needles) => {
let t;
try { t = fs.readFileSync(root + file, 'utf8'); }
catch (e) { throw new Error('cannot read ' + file + ' under ' + root + ' — ' + e.code); }
for (const n of needles) {
if (!t.includes(n)) throw new Error(file + ' does not hold ' + JSON.stringify(n.slice(0, 70)) + ' — this is not the tree round 47 was written against');
}
return t;
};
/* round 44 has to have run, and to still say what it said: refuse to clobber
an overlay somebody else has already moved on */
holds('src-tauri/android/res/values/ic_launcher_background.xml', [
'the brand\'s night; the whole adaptive background is this',
'<color name="ic_launcher_background">#FF05080B</color>',
]);
for (const x of ['ic_launcher', 'ic_launcher_round']) {
holds('src-tauri/android/res/mipmap-anydpi-v26/' + x + '.xml', [
'<background android:drawable="@color/ic_launcher_background" />',
'<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />',
]);
}
/* ── and the SOURCE of the numbers, which is the whole premise ──────────
The phone's ground is the desktop's ground. If src-tauri/icons/icon.svg is
ever re-cut, the literals below stop describing it and this round would
ship a divergence while claiming to close one — so the round refuses
instead. Everything is derived here and compared against the insert, so a
typo in either one is caught rather than shipped. */
/* the whole stop, offset included, and not just the colour: an offset is a
number the SVG owns exactly as much as a coordinate is, and six right
colours at three wrong places is a different gradient. */
const SVG = holds('src-tauri/icons/icon.svg', [
'<linearGradient id="rounded-4-surface-gradient" x1="130" y1="50" x2="880" y2="980" gradientUnits="userSpaceOnUse">',
'gradientTransform="translate(270 220) rotate(43) scale(660)"',
'<stop offset="0" stop-color="#0A2330"/>',
'<stop offset="0.52" stop-color="#07141F"/>',
'<stop offset="1" stop-color="#040A10"/>',
'<stop offset="0" stop-color="#31C0CB" stop-opacity="0.18"/>',
'<stop offset="0.58" stop-color="#1F8FC9" stop-opacity="0.05"/>',
'<stop offset="1" stop-color="#07141F" stop-opacity="0"/>',
]);
/* layer-fit: the 1024 artwork over the whole 108 layer. 108/1024 is 27/256,
so every one of these terminates inside eight decimals and lands on an
exact float — no rounding goes into the drawable at all. */
const at = (v) => (v * 27 / 256).toFixed(8).replace(/0+$/, '').replace(/\.$/, '');
const a8 = (a) => Math.round(a * 255).toString(16).toUpperCase().padStart(2, '0');
const GEOM = ['startX="' + at(130) + '"', 'startY="' + at(50) + '"', 'endX="' + at(880) + '"', 'endY="' + at(980) + '"',
'centerX="' + at(270) + '"', 'centerY="' + at(220) + '"', 'gradientRadius="' + at(660) + '"'];
const STOPS = ['#FF0A2330', '#FF07141F', '#FF040A10',
'#' + a8(0.18) + '31C0CB', '#' + a8(0.05) + '1F8FC9', '#' + a8(0) + '07141F'];
/* and the pair, which is what the drawable actually has to spell. The bare
colours above stay because verify-icon.sh's overlay check greps for those
strings on their own; these are what stop an offset typo. */
const PAIRS = [['0', STOPS[0]], ['0.52', STOPS[1]], ['1', STOPS[2]],
['0', STOPS[3]], ['0.58', STOPS[4]], ['1', STOPS[5]]]
.map(([o, c]) => 'android:offset="' + o + '" android:color="' + c + '"');
/* every insert, before a typo in a filename lands a half-made overlay */
const INSERTS = ['ins-r47-ground-vector.txt', 'ins-r47-adaptive-icon.txt', 'ins-r47-bg-color.txt',
'ins-r47-verify-header.txt', 'ins-r47-verify-overlay.txt', 'ins-r47-verify-ground.txt',
'ins-r47-verify-debug.txt', 'ins-r47-doc-lead.txt', 'ins-r47-doc-ground.txt',
'ins-r47-doc-colour.txt', 'ins-r47-doc-checking.txt', 'ins-r47-doc-cannot.txt'];
for (const f of INSERTS) {
if (!fs.existsSync(scratch + f)) throw new Error('missing insert: ' + scratch + f);
}
/* and the ground insert has to be the artwork above, not a near miss */
{
const g = ins('ins-r47-ground-vector.txt');
for (const n of GEOM.concat(PAIRS)) {
if (!g.includes(n)) throw new Error('ins-r47-ground-vector.txt is missing ' + n + ', which src-tauri/icons/icon.svg says the ground is');
}
for (const n of ['android:type="linear"', 'android:type="radial"', 'xmlns:aapt="http://schemas.android.com/aapt"']) {
if (!g.includes(n)) throw new Error('ins-r47-ground-vector.txt is missing ' + n);
}
}
/* ── the text anchors, all of them, before a byte moves ────────────────── */
const A_VI_HEADER = '# Debug and release are read DIFFERENTLY, because AGP treats their resources';
const A_VI_OVERLAY = [
'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',
].join(NL);
const A_VI_LIST = 'LIST="$(unzip -l "$APK")" || bad "cannot read $APK"';
const A_VI_SDKU = [
'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)"',
].join(NL);
const A_VI_DEBUG_NUM = ' # 2. debug: the adaptive XMLs by name, then all twenty PNGs byte for byte.';
/* the backslash is real shell, so it is doubled for JS */
const A_VI_DEBUG_XMLS = [
' for x in ic_launcher ic_launcher_round; do',
' echo "$LIST" | grep -q " res/mipmap-anydpi-v26/$x\\.xml$" \\',
' || bad "res/mipmap-anydpi-v26/$x.xml is not in the APK — $RES never reached src-tauri/gen/android/app/src/main/res"',
' done',
].join(NL);
const A_VI_REL_NUM = [
'# 3. release. Ask the resource table what the names point at, since the paths',
'# have very likely been shortened away. aapt2 is beside the zipalign and',
'# apksigner build.sh has just used.',
].join(NL);
const A_VI_REL_MAP = 'MAP="$("$A2" dump resources "$APK" | awk \'';
const A_DOC_TABLE = 'the legacy mipmaps and the one colour the background is.';
const A_DOC_BULLET = '- `values/ic_launcher_background.xml` — `#FF05080B`, the brand\'s night.';
const A_DOC_FLAT = [
'and `@color/` for the background instead.',
'',
'**The background is one colour resource, not five identical flat PNGs.**',
'A richer gradient is being mocked separately; when it is approved it replaces',
'one file, and the three XML references do not move.',
].join(NL);
const A_DOC_COLOUR = [
'**The colour file is named `ic_launcher_background.xml` on purpose.**',
'`src-tauri/icons/android/values/ic_launcher_background.xml` declares the same',
].join(NL);
const A_DOC_LEAD = [
'resources back out. It checks the overlay\'s own two XMLs first, before the APK',
'is opened — a `<monochrome>` layer that a later edit drops is a failure too —',
'and then reads the APK, debug and release differently, because AGP hands them',
'to aapt2 differently:',
].join(NL);
const A_DOC_CHECKING = [
'Both `build.sh` and `dev.sh` call it, so no APK this repo makes can quietly go',
'back to the old artwork. It costs about a second and a half on the 107 MB',
'debug APK.',
].join(NL);
const A_DOC_SPLASH = 'five seconds, that splash is the falcon on `#05080B` rather than tauri\'s mark.';
const A_DOC_CANNOT = [
'**Adaptive masking and the monochrome layer can only be judged from the',
'phone\'s home screen**, which is a minute with the Pixel: the icon under the',
'launcher\'s mask, in the app drawer, in recents and in another app\'s share',
'sheet — and then Wallpaper & style → Themed icons on, to see the monochrome',
'layer take the wallpaper\'s colour.',
].join(NL);
// ── the check follows the layer (computed, not yet written) ───────────
load('src-tauri/android/verify-icon.sh');
rep(A_VI_HEADER, ins('ins-r47-verify-header.txt') + NL + '#' + NL + A_VI_HEADER);
/* section 1, straight after the adaptive XMLs and before the PNGs */
rep(A_VI_OVERLAY, ins('ins-r47-verify-overlay.txt') + NL + A_VI_OVERLAY);
/* The aapt2 discovery moves up out of the release branch, because both build
types read the table now — SDKU as well as A2, since the release branch's
"NOT CHECKED" message interpolates it. This rep goes FIRST: the ground
block below carries its own copy of those four lines, and inserting it
before this would leave two of them in the file and make this anchor
ambiguous. */
rep(A_VI_SDKU, '# $A2 and $SDKU come from section 2, which both build types now need.');
/* the shared block, after the zip listing and before the build types split,
so it runs for both */
rep(A_VI_LIST, A_VI_LIST + NL + NL + ins('ins-r47-verify-ground.txt'));
/* renumbered, since a section 2 now sits in front of them */
rep(A_VI_DEBUG_NUM, ' # 3. debug: the adaptive XMLs by name, then all twenty PNGs byte for byte.');
rep(A_VI_REL_NUM, [
'# 4. release. Ask the resource table what the names point at, since the paths',
'# have very likely been shortened away.',
].join(NL));
/* and in debug the ground is still a file with a name */
rep(A_VI_DEBUG_XMLS, A_VI_DEBUG_XMLS + NL + ins('ins-r47-verify-debug.txt'));
/* section 2 has already dumped the table; asking aapt2 for it a second time
on a 107 MB release APK is this round's own doing to undo */
rep(A_VI_REL_MAP, 'MAP="$(echo "$TBL" | awk \'');
save();
// ── the doc ───────────────────────────────────────────────────────────
load('ANDROID.md');
rep(A_DOC_TABLE, 'the legacy mipmaps and the gradient drawable the background is.');
rep(A_DOC_BULLET, [
'- `drawable/ic_launcher_ground.xml` — the adaptive **background**: concept 04\'s',
' gradient, the desktop icon\'s own ground, over the whole 108-unit layer;',
'- `values/ic_launcher_background.xml` — `#FF05080B`, the brand\'s night. Not',
' the background any more, and kept for its filename (below).',
].join(NL));
rep(A_DOC_FLAT, ins('ins-r47-doc-ground.txt'));
rep(A_DOC_COLOUR, ins('ins-r47-doc-colour.txt'));
rep(A_DOC_LEAD, ins('ins-r47-doc-lead.txt'));
rep(A_DOC_CHECKING, ins('ins-r47-doc-checking.txt'));
/* NOT "the falcon on concept 04's ground", and not the flat night
either. Whether the splash draws an adaptive icon's BACKGROUND is the
platform's decision, and developer.android.com states it as "if there is
enough contrast with the window background" — which is a simplification of
SplashscreenContentDrawer.processAdaptiveIcon, whose first and dominant
clause is !mIsBgComplex. Contrast is consulted only for a background that
is ONE colour; a multi-colour one is kept whatever the contrast is. So
round 44's flat #FF05080B — the same colour round 45 writes as
windowSplashScreenBackground, so isRgbSimilarInHsv's a == b short circuit
fires — was dropped, and this gradient one will not be. That is the
OPPOSITE of what the one-line version of the rule predicts, which is why
the doc names the real one. */
rep(A_DOC_SPLASH, [
'five seconds, that splash is the falcon rather than tauri\'s mark. Whether',
'the ground is under it there is the platform\'s call, and this round very',
'likely changes the answer. `SplashscreenContentDrawer.processAdaptiveIcon`',
'drops an adaptive icon\'s background and draws the foreground alone —',
'enlarged, since it is then filling the slot by itself — only when that',
'background is **not complex** *and* reads as the same colour as',
'`windowSplashScreenBackground`. A background with more than one colour in it',
'is kept and the whole icon is drawn, contrast or no contrast. Round 44\'s flat',
'`#FF05080B` is exactly the colour round 45\'s `values-v31/themes.xml` writes',
'there, so it was dropped and the falcon drew large on the flat night; a',
'gradient is not one colour, so the likely new answer is the whole icon —',
'this ground, under a falcon no longer enlarged to fill the slot. Likely,',
'not proven: it is a screenshot, and the docs\' own one-line version of that',
'rule would have predicted the other way.',
].join(NL));
rep(A_DOC_CANNOT, ins('ins-r47-doc-cannot.txt'));
save();
// ── and only now, the disk ────────────────────────────────────────────
/* Every anchor above matched, in the order the reps really run, and both
files are finished strings. What is left cannot fail for a reason this
round can cause. */
// the overlay: one layer swapped, nothing else moved. A new directory under
// src-tauri/android/res/ — build.sh and dev.sh copy the whole tree
// (`cp -r src-tauri/android/res/. …`), so neither script changes.
fs.mkdirSync(RES + 'drawable', { recursive: true });
fs.writeFileSync(GROUND, ins('ins-r47-ground-vector.txt') + NL);
/* both adaptive XMLs are byte-identical, as round 44 wrote them */
const ICON_XML = ins('ins-r47-adaptive-icon.txt') + NL;
fs.writeFileSync(RES + 'mipmap-anydpi-v26/ic_launcher.xml', ICON_XML);
fs.writeFileSync(RES + 'mipmap-anydpi-v26/ic_launcher_round.xml', ICON_XML);
/* the colour keeps its value and its filename; only the comment changes, and
the comment is the whole defence — a reader who saw only this file would
otherwise still think the background is flat */
fs.writeFileSync(RES + 'values/ic_launcher_background.xml', ins('ins-r47-bg-color.txt') + NL);
for (const [file, text] of queued) fs.writeFileSync(root + file, text);
console.log('round 47 wired');