Files
SoulStream/handoff/rounds/patch-round45.cjs
T
KeelrLeek 9f0601027c 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.
2026-09-14 07:57:41 -05:00

231 lines
14 KiB
JavaScript

// Round forty-five: the icon where it moves. The launcher tile is the round
// before this one; these are the four places the mark has to appear that a
// mipmap does not reach. The status-bar glyph on BOTH notices — the playback
// card's and the transfers notice's — becomes the kit's own monochrome falcon,
// because a note beside an arrow does not read as one app, and because the
// shade composites a small icon from its ALPHA CHANNEL alone: the fill stays
// white on transparent, and a coloured export would render as a white blob.
// The lock screen gets an app-icon fallback for its large icon, decoded ONCE,
// because `MediaHub.art` is null on every track change until a cover finishes
// decoding and is null forever for a track that has none. The app manifest
// lives in gitignored gen/ and declares no `android:appCategory`, so the
// media-session plugin's library manifest contributes `audio` through the
// merger. And Android 12+ draws a splash whatever we do — a white flash today
// — so a `Theme.App.Starting` on the brand ground replaces it, in values-v31
// AND values-night-v31, because the qualifier order puts night mode ahead of
// platform version and a splash written only into values-v31 is silently
// ignored on a dark-mode phone.
//
// No Rust. The walk gains one assertion, `media-card`, and it asserts the only
// thing here that can actually break: a drawable the shade cannot inflate, or
// a large icon that throws, and the card is simply never posted. The glyph
// itself and the splash are SCREENSHOTS — `dumpsys notification` prints the
// icon's resource id and never a pixel, and the splash is gone before the
// walk's first frame.
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;
const load = (file) => { p = file; s = fs.readFileSync(root + p, 'utf8'); };
const save = () => fs.writeFileSync(root + 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);
/* Four of the writes below are whole files, which succeed as happily the
second time as the first, while the Kotlin, manifest, build-script and
smoke.mjs `rep`s would throw somewhere in the middle and leave half a tree.
Refuse a second run here, before a byte is touched: `appIcon` is the name
this round puts in MediaService.kt and nothing else in the repo makes it. */
const MEDIA_KT = 'crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt';
if (fs.readFileSync(root + MEDIA_KT, 'utf8').includes('fun appIcon(')) {
throw new Error('round 45 is already applied in ' + root + ' — reset the tree rather than re-running');
}
/* The glyph source: the `d` of the kit's monochrome layer, byte for byte, no
edit. The kit's README is explicit that the foreground's optical offset sits
deliberately above the geometric safe-zone line and must not be "fixed" — it
is inside this contour's own placement chain, and the group numbers below
only re-frame the whole thing for a 24dp box, which preserves it. */
const FALCON = ins('ins-r45-falcon-path.txt');
if (FALCON.length !== 5161 || !/^M [\d.]/.test(FALCON) || !FALCON.endsWith('Z') || /[^MCLZ0-9.\- ]/.test(FALCON)) {
throw new Error('ins-r45-falcon-path.txt is not the kit path (len ' + FALCON.length + ')');
}
// ── A. the two glyphs, the same falcon in both modules ────────────────
/* Whole-file writes: there is nothing in either drawable worth anchoring on,
and the transfers one keeps its tray bar byte-identical so the pair still
reads as one family — the falcon dives into the tray, which is the download.
Two copies of the path on purpose: the comment already in the down glyph
says why (R is per-module and the media-session module's is not visible
from the transfers one). */
const glyph = (head, group, extra) =>
'<?xml version="1.0" encoding="utf-8"?>' + NL
+ head + NL
+ '<vector xmlns:android="http://schemas.android.com/apk/res/android"' + NL
+ ' android:width="24dp"' + NL
+ ' android:height="24dp"' + NL
+ ' android:viewportWidth="24"' + NL
+ ' android:viewportHeight="24">' + NL
+ ' <group' + NL
+ ' android:translateX="' + group.tx + '"' + NL
+ ' android:translateY="' + group.ty + '"' + NL
+ ' android:scaleX="' + group.scale + '"' + NL
+ ' android:scaleY="' + group.scale + '">' + NL
+ ' <path' + NL
+ ' android:fillColor="#FFFFFFFF"' + NL
+ ' android:fillType="evenOdd"' + NL
+ ' android:pathData="' + FALCON + '" />' + NL
+ ' </group>' + NL
+ extra
+ '</vector>' + NL;
fs.writeFileSync(root + 'crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_soulstream_note.xml',
glyph(
`<!-- the status-bar glyph: the falcon, white on transparent, as the shade
wants — Android composites a notification's small icon from its ALPHA
CHANNEL and tints it itself, so a coloured export renders as a white blob
and only a screenshot can tell the difference.
The path is the logo kit's monochrome layer verbatim
(branding/soulstream-logo-kit/android/adaptive/ic_launcher_monochrome.svg),
drawn in its own large source space. An Android path element has no
transform attribute, so the SVG's
translate(9.814 9.814) scale(0.0863) translate(-40.740741 20.370370)
is folded into the group below and re-framed for a 24dp box: the falcon
lands x 4.91 to 19.09, y 2 to 22. It is a narrow mark (0.709:1), so the
side margins are generous and the top and bottom are not, which is what a
status bar wants. The transfers notice carries the same falcon. -->`,
{ tx: '-1.6606', ty: '-0.7287', scale: '0.026185' }, ''));
fs.writeFileSync(root + 'crates/tauri-plugin-transfer-notice/android/src/main/res/drawable/ic_soulstream_down.xml',
glyph(
`<!-- the status-bar glyph: the falcon diving into a tray, white on transparent,
as the shade wants. Its own copy of the path on purpose —
R.drawable.ic_soulstream_note belongs to the media-session module's R
class and is not visible here — and deliberately the SAME falcon, so the
two notices read as one app.
Same kit source and same fold as the playback glyph
(branding/soulstream-logo-kit/android/adaptive/ic_launcher_monochrome.svg),
sized to clear the tray: the falcon lands x 7.04 to 16.96, y 2 to 16, and
the tray bar below it is unchanged at y 17.5 to 20. -->`,
{ tx: '2.4375', ty: '0.0899', scale: '0.018330' },
' <path' + NL
+ ' android:fillColor="#FFFFFFFF"' + NL
+ ' android:pathData="M5,17.5h14v2.5H5z" />' + NL));
// ── B. the app-icon fallback, so the lock screen is never a grey hole ─
load(MEDIA_KT);
/* alphabetically where they belong, and BitmapFactory is the last of the
graphics imports today. Canvas is the only one this round needs:
AdaptiveIconDrawable is deliberately NOT special-cased — see appIcon(). */
rep(`import android.graphics.BitmapFactory`,
`import android.graphics.BitmapFactory
import android.graphics.Canvas`);
/* the last field of MediaHub, so a sibling adding one of its own to the top
of the object does not meet this */
rep(` @Volatile var service: MediaService? = null`,
` @Volatile var service: MediaService? = null
/** the app's own icon, decoded once — see appIcon() */
@Volatile private var icon: Bitmap? = null`);
rep(` fun act(action: String, position: Double = -1.0) {`,
ins('ins-r45-appicon.txt') + NL + NL + ` fun act(action: String, position: Double = -1.0) {`);
/* the session's metadata (the lock-screen background) and the card's own
large icon. `this` is the service, which is the Context appIcon wants. */
rep(` MediaHub.art?.let {`, ` (MediaHub.art ?: MediaHub.appIcon(this))?.let {`);
rep(` .setLargeIcon(MediaHub.art)`, ` .setLargeIcon(MediaHub.art ?: MediaHub.appIcon(this))`);
save();
// ── C. the splash, as a tracked res overlay ───────────────────────────
/* Round forty-four's overlay root, assumed to be src-tauri/android/res/
mirroring app/src/main/res/ — the only shape consistent with what is here:
gen/ is gitignored, and build.sh already copies proguard-soulstream.pro in
exactly this way. If that round chose a different root, MOVE these two
files: they would otherwise land where nothing copies them and the splash
would do nothing at all, with no build error to say so. */
fs.mkdirSync(root + 'src-tauri/android/res/values-v31', { recursive: true });
fs.mkdirSync(root + 'src-tauri/android/res/values-night-v31', { recursive: true });
fs.writeFileSync(root + 'src-tauri/android/res/values-v31/themes.xml', ins('ins-r45-themes-v31.txt') + NL);
fs.writeFileSync(root + 'src-tauri/android/res/values-night-v31/themes.xml', ins('ins-r45-themes-night-v31.txt') + NL);
// ── D. appCategory, the only way in from a repo that cannot edit gen/ ─
load('crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml');
rep(` <application>
<service`,
` <!-- The app's own manifest lives in gitignored gen/ and declares no
android:appCategory, so the merger takes this one's: an application
attribute from a library merges up when the app does not set it.
API 26+, and minSdk here is 26. -->
<application android:appCategory="audio">
<service`);
save();
// ── E. the overlay's copy step, only if round forty-four has not ──────
/* Deliberately conditional. If that round already copies the whole res tree,
this adds nothing; if it copies some narrower part of it, two overlapping
`cp -r`s are harmless. The anchor is the gradle banner, which is one line
in each script and is not something a sibling round moves. */
for (const script of ['src-tauri/android/build.sh', 'src-tauri/android/dev.sh']) {
load(script);
if (s.includes('src-tauri/android/res/.')) continue;
rep(`echo "== gradle $(date +%T)"`, ins('ins-r45-overlay-copy.txt') + NL + `echo "== gradle $(date +%T)"`);
save();
}
// ── F. the walk: the card is still posted, and still answers ──────────
load('tests/mobile/smoke.mjs');
rep(` let title = "", text = "";`, ` let title = "", text = "", card = "";`);
/* the `continue` moves BELOW the `extra` declaration it currently sits above.
It has to: `extra` is a const arrow, so today it is in the temporal dead
zone for every channel that is not downloads — nothing read it there, so
nothing broke, and nothing about the downloads branch changes here. */
rep(` if (ch) chans.push(ch);
if (ch !== "downloads") continue;`,
` if (ch) chans.push(ch);`);
rep(` title = extra("android.title");
text = extra("android.text");`,
` /* the media card's own title, for \`media-card\` in the phone
tail. This is why the \`continue\` below now sits under the
\`extra\` declaration rather than over it. */
if (ch === "playback") card = extra("android.title");
if (ch !== "downloads") continue;
title = extra("android.title");
text = extra("android.text");`);
rep(` return { shadeSeen: blocks.length, shadeChans: chans.join("|"), shadeTitle: title, shadeText: text };`,
` return { shadeSeen: blocks.length, shadeChans: chans.join("|"), shadeTitle: title, shadeText: text, shadeCard: card };`);
rep(` return { shadeSeen: 0, shadeChans: "ERR " + e.message, shadeTitle: "", shadeText: "" };`,
` return { shadeSeen: 0, shadeChans: "ERR " + e.message, shadeTitle: "", shadeText: "", shadeCard: "" };`);
/* between `media-session` and `media-keys` on purpose: the session block has
just proved the card is up, and the keys block below still proves the
transport, exactly where it was. A sibling round appending at the END of the
phone tail, or after `background`, will not meet this. */
rep(`console.log((sessOk ? " ok " : " FAIL ") + "media-session".padEnd(16) + " " + JSON.stringify(sess));`,
`console.log((sessOk ? " ok " : " FAIL ") + "media-session".padEnd(16) + " " + JSON.stringify(sess));`
+ NL + NL + ins('ins-r45-smoke-card.txt'));
save();
// ── G. the doc ────────────────────────────────────────────────────────
load('ANDROID.md');
/* the last sentence of the media-session paragraph, NOT the `## What a screen
reader says` heading below it and NOT the `## Background playback` heading
above it — round forty-four will want a heading of its own for the icon,
and this round creates none. No round count anywhere in the file is
touched. */
rep(`the desktop has \`smtc.rs\`).`,
`the desktop has \`smtc.rs\`).` + NL + NL + ins('ins-r45-doc-icon.txt'));
/* the end of the onTimeout paragraph in "The downloads in the shade" */
rep(`the fetches are the Rust core's own and Transfers still
lists them.`,
`the fetches are the Rust core's own and Transfers still
lists them.` + NL + NL + ins('ins-r45-doc-down.txt'));
save();
console.log('round 45 wired');