diff --git a/README.md b/README.md index 22d1571..c47924e 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,28 @@ The project is a standard Gradle Android project: ./gradlew assembleDebug ``` -For local release builds, `build.sh` uses the Android SDK and JDK available on -the machine, writes `local.properties`, builds a release APK, and copies it to a -local sync folder if present. +Release builds require local signing configuration: + +```bash +./gradlew assembleRelease +``` + +## Releases + +Public releases use two-part versions such as `0.1`, `0.2`, and eventually +`1.0`. + +Gitea automatically provides source archives for release tags, so the release +assets only need the APK and its checksum. A release can be created with: + +```bash +mkdir -p local +printf '%s' 'your-gitea-token' > local/gitea-token + +GITEA_BASE_URL=git.ajpanton.se \ +GITEA_TOKEN_FILE=local/gitea-token \ +scripts/release-gitea.sh 0.1 +``` ## Development Note diff --git a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/ClockLayout.java b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/ClockLayout.java index e4569e8..05c7116 100644 --- a/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/ClockLayout.java +++ b/app/src/main/java/se/ajpanton/statusbartweak/runtime/render/ClockLayout.java @@ -12,6 +12,7 @@ import se.ajpanton.statusbartweak.runtime.features.clock.ClockLayoutTextFactory; import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.AnchorSide; import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds; import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.LayoutPosition; +import se.ajpanton.statusbartweak.shell.settings.SbtDefaults; final class ClockLayout { private static final int MIDDLE_COLLISION_MARGIN_PX = 2; @@ -103,6 +104,7 @@ final class ClockLayout { measuredRows = positionRowsFromLogicalSteps( measuredRows, Math.max(1, autoGapSteps), + Math.max(1, baseHeightSteps), Math.max(1, statusbarHeight), verticalOffsetSteps, bottomRowBaselineAnchorY); @@ -216,6 +218,7 @@ final class ClockLayout { private static ArrayList positionRowsFromLogicalSteps( ArrayList measuredRows, int autoGapSteps, + int baseHeightSteps, int statusbarHeight, int verticalOffsetSteps, int bottomRowBaselineAnchorY @@ -238,7 +241,10 @@ final class ClockLayout { } } else { MeasuredClockRow nextRow = measuredRows.get(rowIndex + 1); - int gapSteps = resolveGapSteps(nextRow.source.gapBeforePx, autoGapSteps); + int gapSteps = resolveGapSteps( + nextRow.source.gapBeforePx, + autoGapSteps, + baseHeightSteps); topSteps = nextBottomSteps - gapSteps - Math.max(1, row.logicalHeightSteps); bottomPx = nextBottomPx - stepsToPx(gapSteps, statusbarHeight); } @@ -657,11 +663,12 @@ final class ClockLayout { return max > 0f ? max : 1f; } - private static int resolveGapSteps(int gapBeforeSteps, int autoGapSteps) { + private static int resolveGapSteps(int gapBeforeSteps, int autoGapSteps, int baseHeightSteps) { if (gapBeforeSteps == ClockLayoutTextFactory.ROW_GAP_AUTO) { return Math.max(1, autoGapSteps); } - return gapBeforeSteps; + int defaultSteps = Math.max(1, SbtDefaults.CLOCK_TEXT_SIZE_STEPS_DEFAULT); + return Math.round(gapBeforeSteps * Math.max(1, baseHeightSteps) / (float) defaultSteps); } private static int stepsToPx(int steps, int statusbarHeight) { diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9..5dd4ed1 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -5,166 +5,6 @@ android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:fillColor="#0D0B08" + android:pathData="M0,0 L108,0 L108,108 L0,108 Z" /> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d1..0357924 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,109 @@ + - - - - - - - - + + + + + + + + + + - \ No newline at end of file + android:strokeLineCap="round" + android:strokeLineJoin="round" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..8befb02 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml index 6f3b755..8fde456 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -2,5 +2,5 @@ - - \ No newline at end of file + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d6..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a307..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d642..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/scripts/generate_launcher_icon.py b/scripts/generate_launcher_icon.py new file mode 100755 index 0000000..16b6ac0 --- /dev/null +++ b/scripts/generate_launcher_icon.py @@ -0,0 +1,505 @@ +#!/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' + ' \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() diff --git a/scripts/release-gitea.sh b/scripts/release-gitea.sh new file mode 100755 index 0000000..3d57f30 --- /dev/null +++ b/scripts/release-gitea.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + GITEA_BASE_URL=git.example.com GITEA_TOKEN_FILE=local/gitea-token scripts/release-gitea.sh 0.1 + +Creates a Gitea release for the current HEAD and uploads: + - the signed release APK + - a SHA-256 checksum file for the APK + +Environment: + GITEA_BASE_URL Base URL of the Gitea instance, for example git.example.com + GITEA_TOKEN Gitea access token with repository write/release permissions + GITEA_TOKEN_FILE Optional file containing the token, used when GITEA_TOKEN is unset + GITEA_OWNER Optional repository owner override. Defaults to origin remote owner. + GITEA_REPO Optional repository name override. Defaults to origin remote repo. + VERSION_CODE Optional Android versionCode override. + +The script requires a clean working tree and pushes the current branch and tag. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +version="${1:-}" +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+$ ]]; then + usage >&2 + exit 2 +fi + +: "${GITEA_BASE_URL:?Set GITEA_BASE_URL, for example git.example.com}" + +if [[ -z "${GITEA_TOKEN:-}" && -n "${GITEA_TOKEN_FILE:-}" ]]; then + if [[ ! -f "$GITEA_TOKEN_FILE" ]]; then + echo "GITEA_TOKEN_FILE does not exist: ${GITEA_TOKEN_FILE}" >&2 + exit 1 + fi + GITEA_TOKEN="$(tr -d '\r\n' < "$GITEA_TOKEN_FILE")" +fi +: "${GITEA_TOKEN:?Set GITEA_TOKEN or GITEA_TOKEN_FILE to a Gitea access token}" + +require_clean_tree() { + if [[ -n "$(git status --porcelain)" ]]; then + echo "Working tree is not clean. Commit or stash changes before releasing." >&2 + exit 1 + fi +} + +remote_repo_path() { + local remote_url + remote_url="$(git remote get-url origin)" + remote_url="${remote_url%.git}" + case "$remote_url" in + ssh://*) + remote_url="${remote_url#ssh://}" + remote_url="${remote_url#*/}" + ;; + http://*|https://*) + remote_url="${remote_url#http://}" + remote_url="${remote_url#https://}" + remote_url="${remote_url#*/}" + ;; + *@*:*) + remote_url="${remote_url#*:}" + ;; + esac + printf '%s\n' "$remote_url" +} + +json_string() { + python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1" +} + +extract_json_id() { + python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' +} + +version_code_for() { + local major minor + IFS=. read -r major minor <<<"$1" + printf '%d\n' "$((major * 10000 + minor * 100))" +} + +urlencode() { + python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1" +} + +normalize_base_url() { + local base_url + base_url="${1%/}" + case "$base_url" in + http://*|https://*) ;; + *) base_url="https://${base_url}" ;; + esac + printf '%s\n' "$base_url" +} + +require_clean_tree + +tag="v${version}" +release_name="StatusBarTweak ${version}" +version_code="${VERSION_CODE:-$(version_code_for "$version")}" +repo_path="$(remote_repo_path)" +owner="${GITEA_OWNER:-${repo_path%%/*}}" +repo="${GITEA_REPO:-${repo_path##*/}}" +gitea_base_url="$(normalize_base_url "$GITEA_BASE_URL")" +api_base="${gitea_base_url}/api/v1" + +if [[ "$owner" == "$repo_path" || -z "$owner" || -z "$repo" ]]; then + echo "Could not infer owner/repo from origin. Set GITEA_OWNER and GITEA_REPO." >&2 + exit 1 +fi + +git fetch origin --tags +if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "Tag ${tag} already exists." >&2 + exit 1 +fi + +./gradlew clean assembleRelease \ + -PsbtVersionName="$version" \ + -PsbtVersionCode="$version_code" + +apk_source="app/build/outputs/apk/release/app-release.apk" +if [[ ! -f "$apk_source" ]]; then + echo "Release APK not found at ${apk_source}." >&2 + exit 1 +fi + +release_dir="build/release/${tag}" +apk_name="StatusBarTweak-${version}.apk" +checksum_name="${apk_name}.sha256" +mkdir -p "$release_dir" +cp "$apk_source" "${release_dir}/${apk_name}" +(cd "$release_dir" && sha256sum "$apk_name" > "$checksum_name") + +git tag -a "$tag" -m "$release_name" +git push origin HEAD +git push origin "$tag" + +body="Tested primarily on Samsung Galaxy Fold 5 running One UI 8.0 / Android 16." +payload="$( + cat </dev/null +} + +upload_asset "${release_dir}/${apk_name}" +upload_asset "${release_dir}/${checksum_name}" + +echo "Created release ${tag}: ${gitea_base_url}/${owner}/${repo}/releases/tag/${tag}"