/** * Draws the launcher icon: a 3x3 pad grid with the centre pad lit. * * Kept in the repo because the icon is otherwise only 15 binary PNGs with no * record of how they were made — changing a colour or a gap would mean redrawing * it from scratch. Re-run with: npm run icon * * There is no SVG rasteriser on this machine (no Inkscape, no rsvg-convert, and * ImageMagick 6 here has no SVG delegate), so the artwork is ImageMagick * primitives. The command is built as a real array: three attempts at this in * bash collapsed the nine draw commands into one argument, which ImageMagick * then read as a filename. */ import { execFileSync } from "node:child_process"; import { mkdirSync, rmSync } from "node:fs"; const BODY = "#e9e9e6"; const PAD = "#1b1b1b"; const ORANGE = "#ff4a00"; const INK = "#111111"; const RES = "android/app/src/main/res"; /** Adaptive foregrounds: 108dp canvas, art confined to the inner 66%. */ const FOREGROUND = [ ["mdpi", 108], ["hdpi", 162], ["xhdpi", 216], ["xxhdpi", 324], ["xxxhdpi", 432], ]; /** Legacy icons, for launchers older than adaptive support. */ const LEGACY = [ ["mdpi", 48], ["hdpi", 72], ["xhdpi", 96], ["xxhdpi", 144], ["xxxhdpi", 192], ]; /** * @param pct how much of the canvas the grid may use. 66 for adaptive * foregrounds, because the launcher masks everything outside that; 82 for * legacy icons, which are shown whole. */ function grid(size, out, pct, bg) { const inner = Math.floor((size * pct) / 100); const off = Math.floor((size - inner) / 2); const cell = Math.floor((inner * 26) / 100); // 3 cells + 2 gaps of 11% const gap = Math.floor((inner * 11) / 100); const rad = Math.max(1, Math.floor((cell * 18) / 100)); // as on the pads const stroke = Math.max(1, Math.round(size / 110)); const args = ["-size", `${size}x${size}`, `xc:${bg}`]; for (let row = 0; row < 3; row++) { for (let col = 0; col < 3; col++) { const x = off + col * (cell + gap); const y = off + row * (cell + gap); const lit = row === 1 && col === 1; args.push( "-fill", lit ? ORANGE : PAD, "-stroke", INK, "-strokewidth", String(stroke), "-draw", `roundrectangle ${x},${y} ${x + cell},${y + cell} ${rad},${rad}`, ); } } // -strip removes the tIME chunk. Without it the pixels are identical between // runs but the bytes are not, so the committed PNGs churn on every re-run and // this script cannot be shown to be their source. args.push("-strip", out); execFileSync("convert", args); } function circleMask(src, size, out) { const half = Math.floor(size / 2); execFileSync("convert", [ src, "(", "-size", `${size}x${size}`, "xc:none", "-fill", "white", "-draw", `circle ${half},${half} ${half},0`, ")", "-alpha", "set", "-compose", "DstIn", "-composite", "-strip", out, ]); } for (const [density, px] of FOREGROUND) { grid(px, `${RES}/mipmap-${density}/ic_launcher_foreground.png`, 66, "none"); } for (const [density, px] of LEGACY) { const square = `${RES}/mipmap-${density}/ic_launcher.png`; grid(px, square, 82, BODY); circleMask(square, px, `${RES}/mipmap-${density}/ic_launcher_round.png`); } // Previews, since a PNG on disk is the only way to actually look at this. mkdirSync("design/icon", { recursive: true }); grid(512, "design/icon/preview-square.png", 82, BODY); circleMask("design/icon/preview-square.png", 512, "design/icon/preview-round.png"); grid(432, "design/icon/.fg.png", 66, "none"); execFileSync("convert", [ "-size", "432x432", `xc:${BODY}`, "design/icon/.fg.png", "-composite", "(", "-size", "432x432", "xc:none", "-fill", "white", "-draw", "roundrectangle 0,0 431,431 108,108", ")", "-alpha", "set", "-compose", "DstIn", "-composite", "design/icon/preview-as-launcher-shows-it.png", ]); rmSync("design/icon/.fg.png", { force: true }); console.log( `icon drawn: ${FOREGROUND.length} adaptive foregrounds, ` + `${LEGACY.length} legacy pairs, 3 previews in design/icon/`, );