#!/usr/bin/env python3 """Generate the launcher icon VectorDrawable files and a local SVG preview. This keeps the icon tweakable without hand-editing dense Android vector XML. Edit the constants below, run this script, then open build/launcher-icon-preview.svg. """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from xml.sax.saxutils import escape ROOT = Path(__file__).resolve().parents[1] FOREGROUND = ROOT / "app/src/main/res/drawable/ic_launcher_foreground.xml" MONOCHROME = ROOT / "app/src/main/res/drawable/ic_launcher_monochrome.xml" BACKGROUND = ROOT / "app/src/main/res/drawable/ic_launcher_background.xml" PREVIEW = ROOT / "build/launcher-icon-preview.svg" VIEWPORT = 108 # Main geometry knobs. Coordinates are in Android/SVG viewport units. # Open build/launcher-icon-preview.svg after regenerating to compare the # square, rounded, and circular launcher masks. # Statusbar panel. It fills the full top width. STATUS_BOTTOM = 54 CUTOUT_CX = 54 CUTOUT_TOP = 0 CUTOUT_BOTTOM = 39 CUTOUT_STEM_HALF_WIDTH = 2.2 # Android adaptive icons use a 108x108 viewport, but only the central 72x72 # area is guaranteed to survive launcher masks. The preview uses this safe-zone # circle, not the full viewport circle. ADAPTIVE_SAFE_RADIUS = 36 # Row 1. ENVELOPE_X = 39 ENVELOPE_Y = 22.5 ENVELOPE_W = 11 ENVELOPE_H = 8 ENVELOPE_STROKE = 1.3 BATTERY_ICON_X = 58 BATTERY_ICON_Y = 23 BATTERY_ICON_W = 13 BATTERY_ICON_H = 7.7 BATTERY_ICON_RADIUS = 1 BATTERY_ICON_PROTRUSION_W = 2 BATTERY_ICON_PROTRUSION_H = 3 BATTERY_ICON_LEVEL = 0.70 BATTERY_ICON_STROKE = 1.0 # Row 2. DAY_TEXT_X = 31 DAY_TEXT_Y = 31.6 DAY_TEXT_H = 8 DAY_TEXT_STROKE = 1.1 DAY_TEXT_LETTER_SPACING = 2.0 DAY_TEXT_LOWERCASE_SCALE = 0.82 WIFI_X = 61 WIFI_Y = 36.5 WIFI_W = 10 WIFI_H = 5 WIFI_STROKE = 1.3 WIFI_DOT_R = 0.9 SIGNAL_X = 73 SIGNAL_BOTTOM = 42.5 SIGNAL_BAR_W = 1.8 SIGNAL_BAR_SPACING = 1.2 SIGNAL_BAR_HEIGHTS = (2.8, 5.6, 8.4) # The divider between the statusbar illustration and the SBT wordmark. BATTERY_BAR_LEFT = 0 BATTERY_BAR_RIGHT = 108 BATTERY_BAR_Y = STATUS_BOTTOM + 0.0 BATTERY_BAR_H = 6 BATTERY_BAR_LEVEL = 0.70 WORDMARK_X = 35 WORDMARK_Y = 62.8 WORDMARK_W = 50 WORDMARK_H = 20 WORDMARK_STROKE_OUTER = 5.0 WORDMARK_STROKE_INNER = 1.8 COLOR_STATUS = "#77746E" COLOR_CUTOUT = "#050403" COLOR_WHITE = "#FFFFFFFF" COLOR_WARM_WHITE = "#FFF5DC" COLOR_ORANGE = "#FF9F1A" COLOR_GREEN = "#2FDB72" COLOR_BAR_TRACK = "#2A2116" COLOR_BG = "#0D0B08" @dataclass(frozen=True) class PathStyle: fill: str = "#00000000" stroke: str | None = None stroke_width: float | None = None line_cap: str | None = None line_join: str | None = None @dataclass(frozen=True) class IconPath: data: str style: PathStyle comment: str | None = None def round_rect(x: float, y: float, w: float, h: float, r: float) -> str: if r <= 0: return rect(x, y, w, h) r = min(r, w / 2, h / 2) return ( f"M{x + r:g},{y:g} L{x + w - r:g},{y:g} " f"A{r:g},{r:g} 0,0 1 {x + w:g},{y + r:g} " f"L{x + w:g},{y + h - r:g} " f"A{r:g},{r:g} 0,0 1 {x + w - r:g},{y + h:g} " f"L{x + r:g},{y + h:g} " f"A{r:g},{r:g} 0,0 1 {x:g},{y + h - r:g} " f"L{x:g},{y + r:g} " f"A{r:g},{r:g} 0,0 1 {x + r:g},{y:g} Z" ) def rect(x: float, y: float, w: float, h: float) -> str: return f"M{x:g},{y:g} L{x + w:g},{y:g} L{x + w:g},{y + h:g} L{x:g},{y + h:g} Z" def circle(cx: float, cy: float, r: float) -> str: return ( f"M{cx - r:g},{cy:g} " f"A{r:g},{r:g} 0,1 0 {cx + r:g},{cy:g} " f"A{r:g},{r:g} 0,1 0 {cx - r:g},{cy:g}" ) def path(data: str, fill: str = "#00000000", *, stroke: str | None = None, stroke_width: float | None = None, line_cap: str | None = None, line_join: str | None = None, comment: str | None = None) -> IconPath: return IconPath(data, PathStyle(fill, stroke, stroke_width, line_cap, line_join), comment) def stroke_path(data: str, color: str, width: float, *, comment: str | None = None) -> IconPath: return path( data, stroke=color, stroke_width=width, line_cap="round", line_join="round", comment=comment, ) def seven_segment_digit(x: float, y: float, digit: str, scale: float) -> str: segments = { "0": "abcfed", "1": "bc", "2": "abged", "3": "abgcd", "4": "fgbc", "5": "afgcd", "6": "afgecd", "7": "abc", "8": "abcdefg", "9": "abfgcd", }[digit] w = 5 * scale h = 10 * scale mid = y + h / 2 left = x right = x + w top = y bottom = y + h coords = { "a": f"M{left:g},{top:g} L{right:g},{top:g}", "b": f"M{right:g},{top:g} L{right:g},{mid:g}", "c": f"M{right:g},{mid:g} L{right:g},{bottom:g}", "d": f"M{left:g},{bottom:g} L{right:g},{bottom:g}", "e": f"M{left:g},{mid:g} L{left:g},{bottom:g}", "f": f"M{left:g},{top:g} L{left:g},{mid:g}", "g": f"M{left:g},{mid:g} L{right:g},{mid:g}", } return " ".join(coords[s] for s in segments) def colon(x: float, y: float, scale: float) -> str: r = 1.1 * scale return circle(x, y + 3 * scale, r) + " " + circle(x, y + 7 * scale, r) def cutout_path() -> str: cx = CUTOUT_CX top = CUTOUT_TOP bottom = CUTOUT_BOTTOM stem_half = CUTOUT_STEM_HALF_WIDTH return ( f"M{cx - stem_half:g},{top:g} " f"L{cx + stem_half:g},{top:g} " f"L{cx + stem_half:g},{bottom - stem_half:g} " f"C{cx + stem_half:g},{bottom:g} {cx - stem_half:g},{bottom:g} {cx - stem_half:g},{bottom - stem_half:g} " f"Z" ) def envelope_path() -> str: x = ENVELOPE_X y = ENVELOPE_Y w = ENVELOPE_W h = ENVELOPE_H return ( f"M{x:g},{y:g} L{x + w:g},{y:g} L{x + w:g},{y + h:g} L{x:g},{y + h:g} Z " f"M{x:g},{y:g} L{x + w / 2:g},{y + h * 0.64:g} L{x + w:g},{y:g}" ) def battery_icon_paths(fill: str, stroke: str) -> list[IconPath]: x = BATTERY_ICON_X y = BATTERY_ICON_Y w = BATTERY_ICON_W h = BATTERY_ICON_H r = BATTERY_ICON_RADIUS protrusion_w = BATTERY_ICON_PROTRUSION_W protrusion_h = BATTERY_ICON_PROTRUSION_H gauge_pad = max(1.0, BATTERY_ICON_STROKE + 0.4) gauge_w = max(0.0, (w - gauge_pad * 2) * BATTERY_ICON_LEVEL) protrusion_y = y + (h - protrusion_h) / 2 return [ stroke_path( round_rect(x, y, w, h, r) + " " + rect(x + w, protrusion_y, protrusion_w, protrusion_h), stroke, BATTERY_ICON_STROKE, comment="Battery outline", ), path(rect(x + gauge_pad, y + gauge_pad, gauge_w, max(0.0, h - gauge_pad * 2)), fill, comment="Battery fill"), ] def day_text_path() -> str: x = DAY_TEXT_X y = DAY_TEXT_Y h = DAY_TEXT_H lower_h = h * DAY_TEXT_LOWERCASE_SCALE lower_top = y + h - lower_h spacing = DAY_TEXT_LETTER_SPACING m_w = h * 0.62 o_w = lower_h * 0.58 n_w = lower_h * 0.58 o_x = x + m_w + spacing n_x = o_x + o_w + spacing return ( # M f"M{x:g},{y + h:g} L{x:g},{y:g} L{x + m_w / 2:g},{y + h * 0.58:g} " f"L{x + m_w:g},{y:g} L{x + m_w:g},{y + h:g} " # o f"M{o_x:g},{lower_top + lower_h / 2:g} " f"A{o_w / 2:g},{lower_h / 2:g} 0,1 0 {o_x + o_w:g},{lower_top + lower_h / 2:g} " f"A{o_w / 2:g},{lower_h / 2:g} 0,1 0 {o_x:g},{lower_top + lower_h / 2:g} " # n f"M{n_x:g},{y + h:g} L{n_x:g},{lower_top:g} " f"C{n_x + n_w * 0.55:g},{lower_top:g} {n_x + n_w:g},{lower_top + lower_h * 0.2:g} " f"{n_x + n_w:g},{y + h:g}" ) def wifi_paths(color: str) -> list[IconPath]: x = WIFI_X y = WIFI_Y w = WIFI_W h = WIFI_H return [ stroke_path( f"M{x:g},{y:g} C{x + w * 0.28:g},{y - h * 0.75:g} " f"{x + w * 0.72:g},{y - h * 0.75:g} {x + w:g},{y:g} " f"M{x + w * 0.22:g},{y + h * 0.5:g} C{x + w * 0.4:g},{y + h * 0.1:g} " f"{x + w * 0.6:g},{y + h * 0.1:g} {x + w * 0.78:g},{y + h * 0.5:g}", color, WIFI_STROKE, comment="Wi-Fi", ), path(circle(x + w / 2, y + h, WIFI_DOT_R), color, comment="Wi-Fi dot"), ] def signal_path() -> str: pieces = [] for index, height in enumerate(SIGNAL_BAR_HEIGHTS): x = SIGNAL_X + index * (SIGNAL_BAR_W + SIGNAL_BAR_SPACING) pieces.append(rect(x, SIGNAL_BOTTOM - height, SIGNAL_BAR_W, height)) return " ".join(pieces) def clock_paths() -> list[IconPath]: y = 42.5 x = 25 s = 1.0 small = 0.72 pieces = [ seven_segment_digit(x, y, "1", s), seven_segment_digit(x + 8, y, "2", s), seven_segment_digit(x + 20, y, "3", s), seven_segment_digit(x + 28, y, "4", s), ] small_x = x + 40 pieces.extend([ seven_segment_digit(small_x, y + 2.6, "5", small), seven_segment_digit(small_x + 6, y + 2.6, "6", small), ]) return [ stroke_path(" ".join(pieces), COLOR_WHITE, 1.55, comment="Clock 12:34"), path(colon(x + 16.5, y, s) + " " + colon(small_x - 3.5, y + 2.6, small), COLOR_WHITE, comment="Clock colons"), ] def statusbar_paths(monochrome: bool) -> list[IconPath]: white = COLOR_WHITE status_fill = COLOR_WHITE if monochrome else COLOR_STATUS cutout_fill = COLOR_WHITE if monochrome else COLOR_CUTOUT paths: list[IconPath] = [] paths.append(path(rect(0, 0, VIEWPORT, STATUS_BOTTOM), status_fill, comment="Statusbar panel")) paths.append(path(cutout_path(), cutout_fill, comment="Camera cutout")) # Row 1. paths.append(stroke_path(envelope_path(), white, ENVELOPE_STROKE, comment="Envelope")) paths.extend(battery_icon_paths(white, white)) # Row 2. paths.append(stroke_path(day_text_path(), white, DAY_TEXT_STROKE, comment="Mon")) paths.extend(wifi_paths(white)) paths.append(path(signal_path(), white, comment="Cellular bars")) # Row 3. paths.extend(clock_paths()) return paths def battery_bar_paths(monochrome: bool) -> list[IconPath]: green = COLOR_WHITE if monochrome else COLOR_GREEN track = COLOR_WHITE if monochrome else COLOR_BAR_TRACK track_w = max(0.0, BATTERY_BAR_RIGHT - BATTERY_BAR_LEFT) fill_w = track_w * BATTERY_BAR_LEVEL return [ path(round_rect(BATTERY_BAR_LEFT, BATTERY_BAR_Y, track_w, BATTERY_BAR_H, 3), track, comment="Battery bar track"), path(round_rect(BATTERY_BAR_LEFT + 1, BATTERY_BAR_Y + 1, max(0.0, fill_w - 2), BATTERY_BAR_H - 2, 2), green, comment="Battery bar fill"), ] def wordmark_paths(monochrome: bool) -> list[IconPath]: x = WORDMARK_X y = WORDMARK_Y w = WORDMARK_W h = WORDMARK_H gap = w * 0.075 s_w = w * 0.22 b_w = w * 0.24 t_w = w * 0.26 s_x = x b_x = s_x + s_w + gap t_x = b_x + b_w + gap mid = y + h * 0.50 cut = min(b_w * 0.28, h * 0.18) data = ( f"M{s_x + s_w:g},{y:g} L{s_x:g},{y:g} L{s_x:g},{mid:g} L{s_x + s_w:g},{mid:g} " f"L{s_x + s_w:g},{y + h:g} L{s_x:g},{y + h:g} " f"M{b_x:g},{y:g} L{b_x:g},{y + h:g} " f"M{b_x:g},{y:g} L{b_x + b_w - cut:g},{y:g} L{b_x + b_w:g},{y + cut:g} " f"L{b_x + b_w:g},{mid - cut:g} L{b_x + b_w - cut:g},{mid:g} L{b_x:g},{mid:g} " f"M{b_x:g},{mid:g} L{b_x + b_w - cut:g},{mid:g} L{b_x + b_w:g},{mid + cut:g} " f"L{b_x + b_w:g},{y + h - cut:g} L{b_x + b_w - cut:g},{y + h:g} L{b_x:g},{y + h:g} " f"M{t_x:g},{y:g} L{t_x + t_w:g},{y:g} M{t_x + t_w / 2:g},{y:g} L{t_x + t_w / 2:g},{y + h:g}" ) if monochrome: return [stroke_path(data, COLOR_WHITE, WORDMARK_STROKE_OUTER, comment="SBT wordmark")] return [ stroke_path(data, COLOR_ORANGE, WORDMARK_STROKE_OUTER, comment="SBT wordmark outer"), stroke_path(data, COLOR_WARM_WHITE, WORDMARK_STROKE_INNER, comment="SBT wordmark inner"), ] def foreground_paths(monochrome: bool = False) -> list[IconPath]: return statusbar_paths(monochrome) + battery_bar_paths(monochrome) + wordmark_paths(monochrome) def background_paths() -> list[IconPath]: return [ path(rect(0, 0, VIEWPORT, VIEWPORT), COLOR_BG), ] def android_path_xml(icon_path: IconPath) -> str: attrs = [ f'android:fillColor="{icon_path.style.fill}"', f'android:pathData="{escape(icon_path.data)}"', ] if icon_path.style.stroke: attrs.append(f'android:strokeColor="{icon_path.style.stroke}"') if icon_path.style.stroke_width is not None: attrs.append(f'android:strokeWidth="{icon_path.style.stroke_width:g}"') if icon_path.style.line_cap: attrs.append(f'android:strokeLineCap="{icon_path.style.line_cap}"') if icon_path.style.line_join: attrs.append(f'android:strokeLineJoin="{icon_path.style.line_join}"') return " " def write_vector(destination: Path, paths: list[IconPath]) -> None: body = "\n".join( (f"\n \n" if p.comment else "\n") + android_path_xml(p) for p in paths ) destination.write_text( '\n' '' f'{body}\n' '\n', encoding="utf-8", ) def svg_path_xml(icon_path: IconPath) -> str: style = [f'fill="{icon_path.style.fill}"'] if icon_path.style.stroke: style.append(f'stroke="{icon_path.style.stroke}"') if icon_path.style.stroke_width is not None: style.append(f'stroke-width="{icon_path.style.stroke_width:g}"') if icon_path.style.line_cap: style.append(f'stroke-linecap="{icon_path.style.line_cap}"') if icon_path.style.line_join: style.append(f'stroke-linejoin="{icon_path.style.line_join}"') return f'' def write_preview() -> None: PREVIEW.parent.mkdir(parents=True, exist_ok=True) paths = background_paths() + foreground_paths(False) body = "\n ".join(svg_path_xml(p) for p in paths) panel_bg = '' square = f'\n {panel_bg}\n {body}\n' rounded = ( '\n' ' \n' f' {panel_bg}\n {body}\n' ' \n' '\n' '' ) circle_preview = ( '\n' ' \n' f' {panel_bg}\n {body}\n' ' \n' '\n' f'' ) PREVIEW.write_text( '\n' ' \n' ' \n' f' \n' ' \n' ' \n' f' {square}\n' f' {rounded}\n' f' {circle_preview}\n' ' square\n' ' rounded\n' ' circle\n' '\n', encoding="utf-8", ) def main() -> None: write_vector(BACKGROUND, background_paths()) write_vector(FOREGROUND, foreground_paths(False)) write_vector(MONOCHROME, foreground_paths(True)) write_preview() print(f"Wrote {FOREGROUND.relative_to(ROOT)}") print(f"Wrote {MONOCHROME.relative_to(ROOT)}") print(f"Wrote {BACKGROUND.relative_to(ROOT)}") print(f"Wrote {PREVIEW.relative_to(ROOT)}") if __name__ == "__main__": main()