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:
Jean-Luc Makiola
2026-09-24 16:36:49 +02:00
co-authored by makiolaj
parent 7bbdbe60e3
commit ac01993d41
476 changed files with 53728 additions and 2919 deletions
+201
View File
@@ -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())