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:
@@ -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
|
||||
|
||||
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())
|
||||
Executable
+35
@@ -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"))"
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user