Add release tooling and generated launcher icon

This commit is contained in:
ajp_anton
2026-06-13 10:37:25 +00:00
parent fbab6dac6a
commit dbf9560486
13 changed files with 928 additions and 193 deletions
+505
View File
@@ -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 " <path\n " + "\n ".join(attrs) + " />"
def write_vector(destination: Path, paths: list[IconPath]) -> None:
body = "\n".join(
(f"\n <!-- {escape(p.comment)} -->\n" if p.comment else "\n") + android_path_xml(p)
for p in paths
)
destination.write_text(
'<?xml version="1.0" encoding="utf-8"?>\n'
'<vector xmlns:android="http://schemas.android.com/apk/res/android"\n'
' android:width="108dp"\n'
' android:height="108dp"\n'
' android:viewportWidth="108"\n'
' android:viewportHeight="108">'
f'{body}\n'
'</vector>\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'<path d="{escape(icon_path.data)}" {" ".join(style)} />'
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 = '<rect width="108" height="108" fill="#050403" />'
square = f'<g transform="translate(0,0)">\n {panel_bg}\n {body}\n</g>'
rounded = (
'<g clip-path="url(#rounded-mask)">\n'
' <g transform="translate(126,0)">\n'
f' {panel_bg}\n {body}\n'
' </g>\n'
'</g>\n'
'<rect x="126" y="0" width="108" height="108" rx="27" fill="none" '
'stroke="#66FFFFFF" stroke-width="0.8" />'
)
circle_preview = (
'<g clip-path="url(#circle-mask)">\n'
' <g transform="translate(252,0)">\n'
f' {panel_bg}\n {body}\n'
' </g>\n'
'</g>\n'
f'<circle cx="306" cy="54" r="{ADAPTIVE_SAFE_RADIUS}" fill="none" '
'stroke="#66FFFFFF" stroke-width="0.8" />'
)
PREVIEW.write_text(
'<svg xmlns="http://www.w3.org/2000/svg" width="1368" height="432" viewBox="0 0 360 114">\n'
' <defs>\n'
' <clipPath id="rounded-mask"><rect x="126" y="0" width="108" height="108" rx="27" /></clipPath>\n'
f' <clipPath id="circle-mask"><circle cx="306" cy="54" r="{ADAPTIVE_SAFE_RADIUS}" /></clipPath>\n'
' </defs>\n'
' <rect width="360" height="114" fill="#18130C" />\n'
f' {square}\n'
f' {rounded}\n'
f' {circle_preview}\n'
' <text x="54" y="113" fill="#DDD4C2" font-size="4" text-anchor="middle">square</text>\n'
' <text x="180" y="113" fill="#DDD4C2" font-size="4" text-anchor="middle">rounded</text>\n'
' <text x="306" y="113" fill="#DDD4C2" font-size="4" text-anchor="middle">circle</text>\n'
'</svg>\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()
+185
View File
@@ -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 <<EOF
{
"tag_name": $(json_string "$tag"),
"target_commitish": $(json_string "$(git rev-parse HEAD)"),
"name": $(json_string "$release_name"),
"body": $(json_string "$body"),
"draft": false,
"prerelease": true
}
EOF
)"
release_id="$(
curl --fail --silent --show-error \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
--data "$payload" \
"${api_base}/repos/$(urlencode "$owner")/$(urlencode "$repo")/releases" \
| extract_json_id
)"
upload_asset() {
local path name
path="$1"
name="$(basename "$path")"
curl --fail --silent --show-error \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${path}" \
"${api_base}/repos/$(urlencode "$owner")/$(urlencode "$repo")/releases/${release_id}/assets?name=$(urlencode "$name")" \
>/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}"