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
+13
View File
@@ -17,6 +17,8 @@
# 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency
# metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's
# binary scanner rejects as an extra signing block.
# 4. jniLibs { keepDebugSymbols += "**/*.so" } — else AGP strips prebuilt .so
# files only when an NDK is installed, so the bytes depend on the host.
set -euo pipefail
APP="app/build.gradle.kts"
@@ -62,6 +64,17 @@ else
fail=1
fi
# 4. Prebuilt native libs must not be stripped. AGP strips them only if an NDK is
# installed, so the output would depend on the build host.
if grep -Pzoq 'jniLibs\s*\{[^}]*keepDebugSymbols\s*\+=\s*"\*\*/\*\.so"' "$APP"; then
echo "OK: jniLibs keepDebugSymbols — native libs ship unstripped on every host."
else
echo "ERROR: '$APP' is missing 'jniLibs { keepDebugSymbols += \"**/*.so\" }'." >&2
echo " AGP strips .so files only when an NDK is present, so a from-source" >&2
echo " rebuild would not match the published APK." >&2
fail=1
fi
if [ "$fail" -ne 0 ]; then
echo >&2
echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2
+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())
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Capture the emulator screen and frame it in the AVD's device skin, like
# Android Studio's "Take screenshot" with the device frame on.
#
# usage: scripts/emulator_screenshot.sh <name> [serial]
# writes design/store/raw/<name>.png and design/store/framed/<name>.png
set -euo pipefail
cd "$(dirname "$0")/.."
NAME="${1:?need a name, e.g. 01-today}"
SERIAL="${2:-emulator-5554}"
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
AVD=$(adb -s "$SERIAL" emu avd name | head -1 | tr -d '\r')
SKIN=$(grep -oP '^skin.path=\K.*' "$HOME/.android/avd/$AVD.avd/config.ini" || true)
[ -n "$SKIN" ] || SKIN="$SDK/skins/$(grep -oP '^skin.name=\K.*' "$HOME/.android/avd/$AVD.avd/config.ini")"
[ -f "$SKIN/layout" ] || { echo "No skin layout for $AVD at $SKIN" >&2; exit 1; }
# Screen offset inside the frame: the `device` part of the portrait layout.
read -r X Y < <(awk '/part2/{p=1} p&&/ x /{x=$2} p&&/ y /{print x, $2; exit}' "$SKIN/layout")
mkdir -p design/store/raw design/store/framed
RAW="design/store/raw/$NAME.png"
OUT="design/store/framed/$NAME.png"
adb -s "$SERIAL" exec-out screencap -p > "$RAW"
BACK=$(ls "$SKIN"/back.* | head -1)
MASK=$(ls "$SKIN"/mask.* 2>/dev/null | head -1 || true)
magick "$BACK" -alpha set -background none \
\( -size "$(magick identify -format '%wx%h' "$BACK")" xc:none \
"$RAW" -geometry "+$X+$Y" -composite \
${MASK:+"$MASK" -geometry "+$X+$Y" -composite} \) \
+swap -composite "$OUT"
echo "$OUT ($(magick identify -format '%wx%h' "$OUT"), frame $(basename "$SKIN"))"
+10 -7
View File
@@ -14,8 +14,8 @@
# short_description.txt -> summary.txt
# full_description.txt -> description.txt
# title.txt -> name.txt
# images/icon.png -> icon.png
# images/phoneScreenshots/* -> phoneScreenshots/*
# images/{icon,featureGraphic,promoGraphic,tvBanner}.* -> same name
# images/<type>Screenshots/* -> <type>Screenshots/*
# changelogs/<versionCode>.txt -> changelogs/<versionCode>.txt
# (changelogs are seeded into the fastlane tree by
# scripts/sync_changelog_to_fastlane.sh.)
@@ -32,11 +32,14 @@ for locdir in "$SRC"/*/; do
[ -f "$locdir/short_description.txt" ] && cp "$locdir/short_description.txt" "$dst/summary.txt"
[ -f "$locdir/full_description.txt" ] && cp "$locdir/full_description.txt" "$dst/description.txt"
[ -f "$locdir/title.txt" ] && cp "$locdir/title.txt" "$dst/name.txt"
[ -f "$locdir/images/icon.png" ] && cp "$locdir/images/icon.png" "$dst/icon.png"
if [ -d "$locdir/images/phoneScreenshots" ]; then
mkdir -p "$dst/phoneScreenshots"
cp "$locdir"images/phoneScreenshots/* "$dst/phoneScreenshots/"
fi
for img in "$locdir"images/{icon,featureGraphic,promoGraphic,tvBanner}.*; do
cp "$img" "$dst/"
done
for shots in "$locdir"images/*Screenshots/; do
t="$(basename "$shots")"
mkdir -p "$dst/$t"
cp "$shots"* "$dst/$t/"
done
# Per-version changelogs live in the same fastlane tree (see
# scripts/sync_changelog_to_fastlane.sh) and map straight across.
if [ -d "$locdir/changelogs" ]; then
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Build the dmfs `tasks.db` fixture the one-shot import is tested against.
The provider is deleted, so the import cannot
keep a real v0.3.x database around by asking the provider to create one. This
writes the schema the provider's TaskDatabaseHelper produces at DATABASE_VERSION
23 — tables `Lists`, `Tasks`, `Properties` only, which are the three the import
reads — and seeds a spread that covers what the import has to get right.
Regenerate with: python3 scripts/make_import_fixture.py
"""
from __future__ import annotations
import os
import sqlite3
import sys
OUT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"app/src/androidTest/assets/tasks-v23.db",
)
DDL = [
"""CREATE TABLE Lists (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
account_name TEXT, account_type TEXT, list_name TEXT, list_color INTEGER,
list_access_level INTEGER, visible INTEGER, sync_enabled INTEGER,
list_owner TEXT, _dirty INTEGER DEFAULT 0, _sync_id TEXT,
sync_version TEXT, sync1 TEXT, sync2 TEXT, sync3 TEXT, sync4 TEXT,
sync5 TEXT, sync6 TEXT, sync7 TEXT, sync8 TEXT)""",
"""CREATE TABLE Tasks (
_id INTEGER PRIMARY KEY AUTOINCREMENT, version INTEGER DEFAULT 0,
list_id INTEGER NOT NULL, title TEXT, location TEXT, geo TEXT,
description TEXT, url TEXT, organizer TEXT, priority INTEGER,
task_color INTEGER, class INTEGER, completed INTEGER,
completed_is_allday INTEGER, percent_complete INTEGER,
status INTEGER DEFAULT 0, is_new INTEGER, is_closed INTEGER,
dtstart INTEGER, created INTEGER, last_modified INTEGER,
is_allday INTEGER, tz TEXT, due INTEGER, duration TEXT, rdate TEXT,
exdate TEXT, rrule TEXT, parent_id INTEGER, sorting TEXT,
has_alarms INTEGER, has_properties INTEGER, pinned INTEGER,
original_instance_sync_id TEXT, original_instance_id INTEGER,
original_instance_time INTEGER, original_instance_allday INTEGER,
_dirty INTEGER DEFAULT 1, _deleted INTEGER DEFAULT 0, _sync_id TEXT,
_uid TEXT, sync_version TEXT, sync1 TEXT, sync2 TEXT, sync3 TEXT,
sync4 TEXT, sync5 TEXT, sync6 TEXT, sync7 TEXT, sync8 TEXT)""",
"""CREATE TABLE Properties (
property_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER,
mimetype INTEGER, prop_version INTEGER,
data0 TEXT, data1 TEXT, data2 TEXT, data3 TEXT, data4 TEXT, data5 TEXT,
data6 TEXT, data7 TEXT, data8 TEXT, data9 TEXT, data10 TEXT,
data11 TEXT, data12 TEXT, data13 TEXT, data14 TEXT, data15 TEXT,
prop_sync1 TEXT, prop_sync2 TEXT, prop_sync3 TEXT, prop_sync4 TEXT,
prop_sync5 TEXT, prop_sync6 TEXT, prop_sync7 TEXT, prop_sync8 TEXT)""",
]
LOCAL = ("Local", "org.dmfs.account.LOCAL")
CALDAV = ("me@example.org", "bitfire.at.davdroid")
ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
# 2026-01-15T09:00:00Z, and a day in millis.
T0 = 1_768_467_600_000
DAY = 86_400_000
def seed(db: sqlite3.Connection) -> None:
lists = db.executemany(
"INSERT INTO Lists (_id, account_name, account_type, list_name, list_color,"
" visible, sync_enabled, list_owner) VALUES (?,?,?,?,?,?,?,?)",
[
(1, *LOCAL, "Personal", 0xFF7A5C6B, 1, 1, None),
(2, *LOCAL, "Hidden list", 0xFF445566, 0, 1, None),
# An external account inside our own authority: only reachable if the
# user pointed DAVx5 at us. Imported as a local list, UID preserved.
(3, *CALDAV, "Work", 0xFF2244AA, 1, 1, "Me"),
],
)
del lists
def task(**kw):
cols = ", ".join(kw)
marks = ", ".join("?" * len(kw))
db.execute(f"INSERT INTO Tasks ({cols}) VALUES ({marks})", tuple(kw.values()))
task(_id=1, list_id=1, title="Buy milk", due=T0 + DAY, status=0,
_uid="a1b2c3d4-0000-4000-8000-000000000001", created=T0, last_modified=T0)
# No UID: the import mints one.
task(_id=2, list_id=1, title="Call the dentist", due=T0 + 2 * DAY, status=1,
percent_complete=40, created=T0, last_modified=T0)
task(_id=3, list_id=1, title="Gather receipts", parent_id=1, status=0,
_uid="a1b2c3d4-0000-4000-8000-000000000003", created=T0, last_modified=T0)
task(_id=4, list_id=1, title="Renew domain", status=2, percent_complete=100,
completed=T0 - DAY, is_closed=1,
_uid="a1b2c3d4-0000-4000-8000-000000000004", created=T0, last_modified=T0)
task(_id=5, list_id=1, title="Water the plants", dtstart=T0, due=T0 + 3600_000,
rrule="FREQ=WEEKLY;BYDAY=MO,TH", tz="Europe/Berlin",
_uid="a1b2c3d4-0000-4000-8000-000000000005", created=T0, last_modified=T0)
task(_id=6, list_id=1, title="Team offsite", dtstart=T0 - T0 % DAY,
due=T0 - T0 % DAY + DAY, is_allday=1,
_uid="a1b2c3d4-0000-4000-8000-000000000006", created=T0, last_modified=T0)
# Deleted-but-unsynced: gone as far as the user is concerned, so not imported.
task(_id=7, list_id=1, title="Cancelled thing", _deleted=1,
_uid="a1b2c3d4-0000-4000-8000-000000000007", created=T0, last_modified=T0)
task(_id=8, list_id=2, title="Task in a hidden list", status=0,
_uid="a1b2c3d4-0000-4000-8000-000000000008", created=T0, last_modified=T0)
task(_id=9, list_id=3, title="Ship the release", due=T0 + 5 * DAY, status=0,
_uid="a1b2c3d4-0000-4000-8000-000000000009", created=T0, last_modified=T0,
_sync_id="https://dav.example.org/tasks/9.ics")
db.executemany(
"INSERT INTO Properties (property_id, task_id, mimetype, data0, data1, data2, data3)"
" VALUES (?,?,?,?,?,?,?)",
[
# data0 minutes before, data1 reference (1 = DUE), data3 alarm type.
(1, 1, ALARM_MIMETYPE, "30", "1", None, "1"),
(2, 9, ALARM_MIMETYPE, "1440", "1", "Ship it", "1"),
# A non-alarm property the import must skip.
(3, 1, "vnd.android.cursor.item/category", "Errands", None, None, None),
],
)
def main() -> int:
os.makedirs(os.path.dirname(OUT), exist_ok=True)
if os.path.exists(OUT):
os.remove(OUT)
db = sqlite3.connect(OUT)
try:
for statement in DDL:
db.execute(statement)
db.execute("PRAGMA user_version = 23")
seed(db)
db.commit()
finally:
db.close()
print(f"wrote {OUT}")
return 0
if __name__ == "__main__":
sys.exit(main())
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Write sample task lists for store screenshots, dated relative to a given day.
scripts/make_store_sample_tasks.py [YYYY-MM-DD]
Writes design/store/sample/<list>.ics, one list per file, for the in-app
iCalendar import. Defaults to today.
"""
import sys
from datetime import date, timedelta
from pathlib import Path
OUT = Path(__file__).resolve().parent.parent / "design/store/sample"
# (summary, due offset in days or None, extras)
LISTS = {
"Personal": [
("Renew passport", 0, {"PRIORITY": "1", "DESCRIPTION": "Photos are in the desk drawer. Book a slot at the town hall first."}),
("Call the dentist", 0, {}),
("Water the plants", 0, {"RRULE": "FREQ=DAILY;INTERVAL=3"}),
("Go for a run", 0, {"RRULE": "FREQ=WEEKLY;BYDAY=MO,WE,FR"}),
("Cancel the old gym membership", -2, {"PRIORITY": "1"}),
("Birthday gift for Sam", 3, {"PERCENT-COMPLETE": "33", "children": [
("Order the book", True), ("Wrap it", False), ("Write a card", False)]}),
("Plan the weekend hike", 4, {"LOCATION": "Saxon Switzerland"}),
("Read \"The Midnight Library\"", None, {"PERCENT-COMPLETE": "40"}),
("Back up the photos", None, {}),
("Pick up dry cleaning", -1, {"done": True}),
],
"Work": [
("Send the Q3 report to Lena", -1, {"PRIORITY": "1"}),
("Review Alex's pull request", 0, {"PRIORITY": "5"}),
("Weekly team sync notes", 1, {"RRULE": "FREQ=WEEKLY;BYDAY=TH"}),
("Prepare slides for the quarterly review", 2, {"PRIORITY": "5", "PERCENT-COMPLETE": "50", "children": [
("Collect the numbers", True), ("Draft the outline", True),
("Design the charts", False), ("Rehearse once", False)]}),
("Book flights to the Lisbon conference", 6, {"LOCATION": "Lisbon", "URL": "https://example.org/conference"}),
("Update the onboarding docs", None, {"PRIORITY": "9"}),
("Plan the team offsite", -1, {"done": True}),
],
"Home": [
("Take out the recycling", 1, {"RRULE": "FREQ=WEEKLY;BYDAY=TH"}),
("Fix the leaky tap", 4, {"DESCRIPTION": "Washer size is 1/2\". Turn off the water under the sink first."}),
("Pay the rent", "month", {"RRULE": "FREQ=MONTHLY;BYMONTHDAY=1", "PRIORITY": "1"}),
("Book the car service", 8, {}),
("Clean the gutters", None, {}),
("Change the bed sheets", 0, {"done": True}),
],
"Groceries": [
("Oat milk", None, {}),
("Eggs", None, {}),
("Cherry tomatoes", None, {}),
("Fresh basil", None, {}),
("Coffee beans", None, {"PRIORITY": "1"}),
("Sourdough bread", None, {"done": True}),
("Parmesan", None, {"done": True}),
],
}
def ics_date(d):
return d.strftime("%Y%m%d")
def todo(uid, summary, due, extras, stamp, parent=None, done=False):
lines = ["BEGIN:VTODO", f"UID:{uid}", f"DTSTAMP:{stamp}", f"SUMMARY:{summary}"]
if due is not None:
lines.append(f"DUE;VALUE=DATE:{ics_date(due)}")
if "RRULE" in extras:
lines.append(f"DTSTART;VALUE=DATE:{ics_date(due)}")
for key in ("PRIORITY", "PERCENT-COMPLETE", "LOCATION", "URL", "RRULE"):
if key in extras:
lines.append(f"{key}:{extras[key]}")
if "DESCRIPTION" in extras:
lines.append("DESCRIPTION:" + extras["DESCRIPTION"].replace(",", "\\,"))
if parent:
lines.append(f"RELATED-TO:{parent}")
if done:
lines += ["STATUS:COMPLETED", "PERCENT-COMPLETE:100", f"COMPLETED:{stamp}"]
else:
lines.append("STATUS:NEEDS-ACTION")
lines.append("END:VTODO")
return lines
def main():
today = date.fromisoformat(sys.argv[1]) if len(sys.argv) > 1 else date.today()
stamp = today.strftime("%Y%m%dT080000Z")
next_first = (today.replace(day=1) + timedelta(days=32)).replace(day=1)
OUT.mkdir(parents=True, exist_ok=True)
for name, tasks in LISTS.items():
lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Agendula//store sample//EN",
f"X-WR-CALNAME:{name}"]
for i, (summary, offset, extras) in enumerate(tasks):
due = next_first if offset == "month" else (
None if offset is None else today + timedelta(days=offset))
uid = f"sample-{name.lower()}-{i}"
lines += todo(uid, summary, due, extras, stamp, done=extras.get("done", False))
for j, (child, child_done) in enumerate(extras.get("children", [])):
lines += todo(f"{uid}-{j}", child, None, {}, stamp, parent=uid, done=child_done)
lines.append("END:VCALENDAR")
(OUT / f"{name}.ics").write_text("\r\n".join(lines) + "\r\n", encoding="utf-8")
print(OUT / f"{name}.ics")
if __name__ == "__main__":
main()
+36 -23
View File
@@ -1,16 +1,13 @@
#!/usr/bin/env bash
# Write the current version's CHANGELOG.md section into the fastlane changelog
# file that F-Droid harvests: fastlane/metadata/android/en-US/changelogs/<code>.txt
# (en-US is F-Droid's fallback locale, so it covers every language).
# Ensure the current version's "What's New" exists at
# fastlane/metadata/android/en-US/changelogs/<code>.txt, which both F-Droid and
# Google Play publish.
#
# Run this when cutting a release (after editing CHANGELOG.md and bumping
# versionName in app/build.gradle.kts) and COMMIT the result, so the OFFICIAL
# F-Droid repo — which reads the changelog from the tagged source tree — shows
# this version's "What's New". The self-hosted release pipeline also runs it so
# its changelog never depends on the file having been committed. Idempotent.
#
# Extraction matches the awk used for the Gitea release notes so all three
# (release notes, self-hosted changelog, official changelog) stay in sync.
# A COMMITTED file wins and is never rewritten: it is a hand-written summary
# under 500 characters (Play's cap), not a copy of the CHANGELOG.md section.
# Only when the file is missing is it generated from that section, so the
# self-hosted release pipeline always has something to publish. The other
# locales' changelogs are written by hand and never touched here.
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
@@ -20,22 +17,38 @@ MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
# Play rejects a longer "What's New" and F-Droid truncates it in-client.
MAX_CHARS=${MAX_CHARS:-500}
CL_DIR="fastlane/metadata/android/en-US/changelogs"
mkdir -p "$CL_DIR"
OUT="$CL_DIR/${VERSION_CODE}.txt"
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > "$OUT"
# Trim leading blank lines (same as the pipeline did).
sed -i -e '/./,$!d' "$OUT"
if [ ! -s "$OUT" ]; then
echo "See CHANGELOG.md for $VERSION." > "$OUT"
if [ -s "$OUT" ]; then
ACTION="Kept"
else
ACTION="Generated"
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > "$OUT"
# Trim leading blank lines (same as the pipeline did).
sed -i -e '/./,$!d' "$OUT"
[ -s "$OUT" ] || echo "See CHANGELOG.md for $VERSION." > "$OUT"
fi
CHARS=$(wc -m < "$OUT" | tr -d ' ')
echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
if [ "$CHARS" -gt 500 ]; then
echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2
# Counted like check_store_listing.py: surrounding whitespace doesn't count.
CHARS=$(python3 -c 'import sys; print(len(open(sys.argv[1], encoding="utf-8").read().strip()))' "$OUT")
echo "$ACTION $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
# Hard limit, enforced in CI (.forgejo/workflows/ci.yaml) and before a release.
if [ "$CHARS" -gt "$MAX_CHARS" ]; then
cat >&2 <<MSG
ERROR: $OUT is $CHARS characters, over the $MAX_CHARS-character limit.
F-Droid truncates the in-client changelog, so this one would be cut off
mid-sentence and Play would reject the upload. Replace it with a hand-written
summary under $MAX_CHARS characters and commit it.
MSG
exit 1
fi
+20 -5
View File
@@ -38,11 +38,26 @@ adb shell pm revoke "$PKG" org.tasks.permission.WRITE_TASKS 2>/dev/null || t
adb shell pm revoke "$PKG" android.permission.POST_NOTIFICATIONS 2>/dev/null || true
echo
echo "Installed and reset. Now verify ON THE DEVICE before releasing:"
echo " 1. Launch from a clean state — the permission screen must appear (no crash)."
echo " 2. Grant tasks access — the task list must load."
echo " 3. Create a task with a due reminder and confirm the notification fires."
echo " 4. Exercise the release's headline changes end to end."
echo "Installed and reset. Now verify ON THE DEVICE before releasing."
echo
echo "Which path you are on depends on what else is installed — since 1.0.0 the"
echo "app owns its store, so the default path grants nothing at all:"
echo
echo " No OpenTasks / tasks.org on the device (the default, and what a fresh"
echo " install looks like):"
echo " 1. Launch from a clean state — the task list must load straight away."
echo " There is no permission to grant in this mode, so no gate appears."
echo " 2. Create a list, then a task in it — a fresh install has neither."
echo " 3. Give the task a due reminder and confirm the notification fires."
echo
echo " With OpenTasks or tasks.org installed:"
echo " 1. Launch from a clean state — the permission gate must appear (no crash)."
echo " 2. Grant tasks access — that app's task list must load."
echo " 3. Settings -> Storage -> Copy tasks from ... — the confirm must name a"
echo " real count, and switching to \"On this device\" must then show them."
echo " 4. Create a task with a due reminder and confirm the notification fires."
echo
echo " Either way: exercise the release's headline changes end to end."
echo
echo "Watch for crashes with: adb logcat -b crash"
echo "Only merge the release branch to main once all of the above pass on a device"