#!/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 local/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 / "local/launcher-icon-preview.svg"
VIEWPORT = 108
# Main geometry knobs. Coordinates are in Android/SVG viewport units.
# Open local/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',
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()