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
202 lines
8.0 KiB
Python
Executable File
202 lines
8.0 KiB
Python
Executable File
#!/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())
|