Release 1.0.0 (#20)
First stable release. Merging this bumps versionName to 1.0.0 and triggers the release pipeline (F-Droid, Codeberg, Play). **App** - CalDAV sync built in, with Agendula's own task store; OpenTasks / tasks.org stay available and can be copied over in Settings → Storage - repeating tasks, several reminders per task, lists managed in the app, iCalendar import/export, widget and Quick Settings tile - a list can be kept out of the smart lists (#18) and gets its own notification channel (#17) - duplicate a task with its subtasks (#16) - HTML descriptions shown as plain text (#15) - relative day words in reminder notifications (#14) - asks for exact-alarm access instead of claiming USE_EXACT_ALARM, and re-arms reminders when that access changes **Release plumbing** - floret-kit bumped to v0.4.0; the old pin was never pushed, so a clean clone couldn't check out the submodule. 0.4.0 drops CrashConfig.issueTitle (crash issues are always filed in English) - prebuilt .so files ship unstripped, so the build no longer depends on whether an NDK is installed; now checked by check_reproducible_release.sh - official F-Droid recipe in docs/fdroid-official/, to submit to fdroiddata once v1.0.0 is tagged - Google Play: fastlane uploads the AAB and every locale's What's New after the F-Droid release; a separate listing lane pushes text and graphics from the fastlane tree, which CI now checks against Play's limits - store listing: title "Agendula: Tasks" in every locale, icon, feature graphic, screenshots and 1.0.0 changelogs in en-US, en-GB, de-DE and pt-BR crash_report_issue_title is now unused but stays until Weblate removes the translated copies. Closes #14, closes #15, closes #16, closes #17, closes #18 Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/20
This commit is contained in:
Executable
+201
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate fastlane/metadata/android against what BOTH stores accept.
|
||||
|
||||
The fastlane tree is the single source for the F-Droid listing (official repo
|
||||
harvests it, the self-hosted repo gets it via fastlane_to_fdroid_localized.sh)
|
||||
and for the Play listing (`bundle exec fastlane listing`). F-Droid accepts
|
||||
nearly anything; Play rejects a lot at upload time. Checking against Play's
|
||||
rules here is what keeps the graphics shippable to both from the same files.
|
||||
|
||||
scripts/check_store_listing.py validate what exists
|
||||
scripts/check_store_listing.py --complete also require everything a Play
|
||||
listing needs to go live
|
||||
|
||||
SDK- and dependency-free (PNG/JPEG headers are parsed by hand) so CI can run it
|
||||
without setup.
|
||||
"""
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
META = ROOT / "fastlane/metadata/android"
|
||||
RES = ROOT / "app/src/main/res"
|
||||
|
||||
# Play's limits; F-Droid truncates or recommends the same.
|
||||
TEXT_LIMITS = {"title.txt": 30, "short_description.txt": 80, "full_description.txt": 4000}
|
||||
CHANGELOG_LIMIT = 500
|
||||
|
||||
# The fallback locale of both stores: F-Droid always, Play as the app's default
|
||||
# language. It carries the full text and every graphic.
|
||||
REQUIRED_LOCALES = ("en-US",)
|
||||
IMAGE_LOCALE = "en-US"
|
||||
|
||||
SCREENSHOT_DIRS = ("phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots",
|
||||
"tvScreenshots", "wearScreenshots")
|
||||
# Play policy on the title: no ranking, price or promotional terms.
|
||||
TITLE_BANNED = re.compile(r"\b(best|top|#1|no\.? ?1|free|sale|discount|new|hot|download now)\b", re.I)
|
||||
|
||||
errors, warnings = [], []
|
||||
|
||||
|
||||
def err(path, msg):
|
||||
errors.append(f"{path.relative_to(ROOT)}: {msg}")
|
||||
|
||||
|
||||
def warn(path, msg):
|
||||
warnings.append(f"{path.relative_to(ROOT)}: {msg}")
|
||||
|
||||
|
||||
def image_info(path):
|
||||
"""(format, width, height, has_alpha) or None if unreadable."""
|
||||
data = path.read_bytes()
|
||||
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
w, h, _depth, color = struct.unpack(">IIBB", data[16:26])
|
||||
has_alpha = color in (4, 6) or b"tRNS" in data[:data.find(b"IDAT")]
|
||||
return "png", w, h, has_alpha
|
||||
if data[:2] == b"\xff\xd8":
|
||||
i = 2
|
||||
while i + 9 < len(data):
|
||||
if data[i] != 0xFF:
|
||||
i += 1
|
||||
continue
|
||||
marker = data[i + 1]
|
||||
seg_len = struct.unpack(">H", data[i + 2:i + 4])[0]
|
||||
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
|
||||
h, w = struct.unpack(">HH", data[i + 5:i + 9])
|
||||
return "jpeg", w, h, False
|
||||
i += 2 + seg_len
|
||||
return None
|
||||
|
||||
|
||||
def check_image(path, *, size=None, alpha=None, max_mb, screenshot=False):
|
||||
info = image_info(path)
|
||||
if info is None:
|
||||
err(path, "not a PNG or JPEG")
|
||||
return
|
||||
fmt, w, h, has_alpha = info
|
||||
if size and (w, h) != size:
|
||||
err(path, f"is {w}x{h}, Play requires exactly {size[0]}x{size[1]}")
|
||||
if alpha is True and (fmt != "png" or not has_alpha):
|
||||
err(path, "must be a 32-bit PNG (with alpha channel)")
|
||||
if alpha is False and has_alpha:
|
||||
err(path, "has an alpha channel; Play wants 24-bit PNG or JPEG")
|
||||
if screenshot:
|
||||
short, long_ = sorted((w, h))
|
||||
if short < 320 or long_ > 3840:
|
||||
err(path, f"is {w}x{h}; each side must be 320–3840 px")
|
||||
if long_ > 2 * short:
|
||||
err(path, f"is {w}x{h}; the long side may be at most twice the short side")
|
||||
if short < 1080:
|
||||
warn(path, f"is {w}x{h}; Play only features screenshots at 1080 px or more")
|
||||
mb = path.stat().st_size / 1_000_000
|
||||
if mb > max_mb:
|
||||
err(path, f"is {mb:.1f} MB, over Play's {max_mb} MB limit")
|
||||
|
||||
|
||||
def check_images(locale_dir):
|
||||
images = locale_dir / "images"
|
||||
if not images.is_dir():
|
||||
return
|
||||
if locale_dir.name != IMAGE_LOCALE:
|
||||
warn(images, f"graphics belong in {IMAGE_LOCALE} only; both stores fall back to it")
|
||||
for f in images.iterdir():
|
||||
if f.is_file() and f.stem not in ("icon", "featureGraphic", "promoGraphic", "tvBanner"):
|
||||
err(f, "unknown image; neither store will pick it up")
|
||||
icon = images / "icon.png"
|
||||
if icon.exists():
|
||||
check_image(icon, size=(512, 512), alpha=True, max_mb=1)
|
||||
for name, size in (("featureGraphic", (1024, 500)), ("promoGraphic", (180, 120)),
|
||||
("tvBanner", (1280, 720))):
|
||||
for f in images.glob(f"{name}.*"):
|
||||
check_image(f, size=size, alpha=False, max_mb=15)
|
||||
for d in SCREENSHOT_DIRS:
|
||||
shots = sorted(p for p in (images / d).glob("*") if p.is_file())
|
||||
if len(shots) > 8:
|
||||
err(images / d, f"{len(shots)} screenshots; Play takes at most 8")
|
||||
for s in shots:
|
||||
check_image(s, alpha=False, max_mb=8, screenshot=True)
|
||||
|
||||
|
||||
def version_code():
|
||||
gradle = (ROOT / "app/build.gradle.kts").read_text()
|
||||
name = re.search(r'versionName\s*=\s*"([^"]+)"', gradle).group(1)
|
||||
parts = (name.split(".") + ["0", "0"])[:3]
|
||||
return int(parts[0]) * 10000 + int(parts[1]) * 100 + int(parts[2])
|
||||
|
||||
|
||||
def shipped_languages():
|
||||
langs = set()
|
||||
for d in RES.glob("values-*"):
|
||||
if (d / "strings.xml").exists():
|
||||
m = re.fullmatch(r"values-([a-z]{2,3})(?:-r([A-Z]{2}))?", d.name)
|
||||
if m:
|
||||
langs.add((m.group(1), m.group(2)))
|
||||
return langs
|
||||
|
||||
|
||||
def main():
|
||||
complete = "--complete" in sys.argv[1:]
|
||||
current = version_code()
|
||||
locales = sorted(d for d in META.iterdir() if d.is_dir())
|
||||
names = {d.name for d in locales}
|
||||
|
||||
for loc in REQUIRED_LOCALES:
|
||||
if loc not in names:
|
||||
err(META / loc, "missing; this locale is a store fallback and must exist")
|
||||
|
||||
for d in locales:
|
||||
has_text = any((d / f).exists() for f in TEXT_LIMITS)
|
||||
for fname, limit in TEXT_LIMITS.items():
|
||||
f = d / fname
|
||||
if not f.exists():
|
||||
if has_text or d.name in REQUIRED_LOCALES:
|
||||
err(f, "missing (a locale with any listing text needs all three files)")
|
||||
continue
|
||||
text = f.read_text(encoding="utf-8").strip()
|
||||
if not text:
|
||||
err(f, "empty")
|
||||
elif len(text) > limit:
|
||||
err(f, f"{len(text)} chars, limit {limit}")
|
||||
if fname == "title.txt" and TITLE_BANNED.search(text):
|
||||
warn(f, "promotional term in the title; Play policy forbids these")
|
||||
for f in sorted((d / "changelogs").glob("*.txt")):
|
||||
n = len(f.read_text(encoding="utf-8").strip())
|
||||
if f.stem == str(current) and n > CHANGELOG_LIMIT:
|
||||
err(f, f"{n} chars, limit {CHANGELOG_LIMIT}; Play would reject this release")
|
||||
elif n > CHANGELOG_LIMIT:
|
||||
warn(f, f"{n} chars, over {CHANGELOG_LIMIT} (already published)")
|
||||
check_images(d)
|
||||
|
||||
for lang, region in sorted(shipped_languages(), key=str):
|
||||
match = f"{lang}-{region}" if region else lang
|
||||
if not any(n == match or n.startswith(f"{lang}-") for n in names):
|
||||
warn(META, f"app ships values-{lang}{'-r' + region if region else ''} "
|
||||
"but there is no store locale for it")
|
||||
|
||||
if complete:
|
||||
images = META / IMAGE_LOCALE / "images"
|
||||
if not (images / "icon.png").exists():
|
||||
err(images / "icon.png", "required for Play")
|
||||
if not list(images.glob("featureGraphic.*")):
|
||||
err(images / "featureGraphic.png", "required for Play (1024x500, no alpha)")
|
||||
if len(list((images / "phoneScreenshots").glob("*"))) < 2:
|
||||
err(images / "phoneScreenshots", "Play requires at least 2 phone screenshots")
|
||||
for loc in REQUIRED_LOCALES:
|
||||
if not (META / loc / "changelogs" / f"{current}.txt").exists():
|
||||
err(META / loc / "changelogs" / f"{current}.txt", "no What's New for this version")
|
||||
|
||||
for w in warnings:
|
||||
print(f"warning: {w}")
|
||||
for e in errors:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
if errors:
|
||||
return 1
|
||||
print(f"Store listing OK ({len(locales)} locales, {len(warnings)} warnings).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user