Branch 1 of 2 for v2.7 (the .ics topic). Adds the write side of a hand-rolled RFC 5545 engine (zero deps, stays on kotlinx-datetime): - domain/ics: IcsText (escape + 75-octet folding), IcsEvent model, IcsWriter.writeCalendar. Timezone rule: all-day VALUE=DATE, one-off timed UTC Z, recurring timed TZID-labelled from EVENT_TIMEZONE (no VTIMEZONE — import resolves TZID against the OS tz db). - Single-event share from the detail screen (FileProvider + ACTION_SEND). - Whole-calendar backup of the writable local calendars to a SAF file (Settings -> Calendars -> Export as .ics), one combined VCALENDAR. - insertEvent now writes Events.UID_2445; legacy rows fall back to a stable synthesised UID at export time so a later restore won't dupe. - EXDATE / RECURRENCE-ID overrides are deliberately skipped this pass (documented v1 limit; import will skip them too). Engine + mapper unit-tested. Import (Branch 2, feat/ics-import) ships in the same v2.7 release; no tag until both land + on-device review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.1 KiB
Calendula - Plan 05: ICS Export (v2.7, Branch 1 von 2)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Die Schreib-Hälfte des .ics-Themas. Calendula serialisiert eigene
Events nach RFC 5545 — als Einzel-Event (Share-Sheet) und als
Ganz-Kalender-Backup (SAF-Datei). Damit existiert für gerätelokale Kalender
(ACCOUNT_TYPE_LOCAL) zum ersten Mal ein Backup; ohne Sync ist ein verlorenes
Gerät sonst Totalverlust. Diese Branch baut nur Export; Import
(Parser, Restore, Open-into-form) folgt in Branch 2 (feat/ics-import),
beide landen zusammen in einem Release v2.7.0 — es gibt also keine
Zwischenversion, die UIDs schreibt, ohne sie je zu lesen.
./gradlew lint test assembleDebug bleibt grün; Release erst nach
On-Device-Review (gemeinsam mit Branch 2).
Architecture: Neue reine-Kotlin-Engine domain/ics/ — kein
CalendarContract, keine Android-Deps, voll JVM-testbar. Kern ist
IcsWriter (nimmt eine Liste eigener Event-Modelle + Kalender-Metadaten,
gibt einen VCALENDAR-String zurück). Keine ICS-Library: wir bleiben auf
kotlinx-datetime (kein java.time-Desugaring) und hand-rollen wie schon
bei RRULE in Recurrence.kt. Das Schreiben in eine SAF-Datei / das
Share-Intent liegt in einer dünnen Android-Schicht
(data/ics/IcsExporter o. ä.), die Provider-Reads → Domain-Modelle →
IcsWriter → OutputStream verdrahtet.
Recherche-Befunde (Codebase, 2026-06-18):
- Keine ICS-Library, kein
java.time-Desugaring — Stack istkotlinx-datetime+kotlin.time.Instant. RRULE wird bereits indomain/Recurrence.ktvon Hand geparst/gerendert (inkl. der sorgfältigenUNTIL/DST-Korrektur).IcsWriterreiht sich in genau diese Kultur ein und nutztSimpleRecurrence.toRRule()direkt. - Kein UID-Handling.
Events.UID_2445wird heute nirgends gelesen oder geschrieben. Für idempotenten Restore in Branch 2 muss Import auf UID matchen — ohne UID verdoppelt ein erneuter Import alles. Darum schreibt diese Branch bereits UID bei jedem Insert (Vorarbeit; ohne Import noch unsichtbar, zahlt sich erst in Branch 2 aus — beide im selben Release). - Zeitzonen werden gespeichert, aber nie vom Nutzer gewählt. Lesen/Anzeige:
EVENT_TIMEZONElandet inEventDetail.eventTimezone, das Detail zeigt ein Fremdzonen-Label nur bei Abweichung vom Gerät (foreignTimeZoneLabel). Schreiben (EventWriteMapper.toWriteTimes): all-day → UTC-Mitternachten,EVENT_TIMEZONE="UTC"; getimt →EVENT_TIMEZONE = zone.id, und alle Caller übergebencurrentSystemDefault()(kein Zonen-Feld im Formular). Jedes selbst erstellte Event trägt also die Gerätezone zum Erstellzeitpunkt; eingesynkte Events behalten ihre Originalzone.
Leitentscheidungen:
- Zeitzonen-Regel beim Schreiben (fallbasiert):
- All-day →
DTSTART;VALUE=DATE:YYYYMMDD,DTENDexklusiv (Tag-danach). Keine Zone — trivial korrekt. - Getimt, nicht wiederkehrend → UTC-Instant
…T…Z. Ein Instant ist ein Instant; die Anzeige rechnet beim Import wieder in die Gerätezone. Verlustfrei. - Getimt, wiederkehrend →
DTSTART;TZID=<EVENT_TIMEZONE>:<lokale Wandzeit>. Eine Serie muss an der Wandzeit verankert sein, sonst driftet ein „wöchentlich 9 Uhr" über die nächste DST-Grenze um eine Stunde. Die Zone liegt bereits inEVENT_TIMEZONEvor; die lokale Wandzeit ist einekotlinx-datetime-Konversion (Instant → LocalDateTime in der Zone). VTIMEZONE-Blöcke werden bewusst NICHT emittiert. Beim eigenen Round-Trip löst Branch-2-ImportTZIDgegen die OS-tz-Datenbank auf (kotlinx-datetime/java.timekennen jede IANA-Id). Technisch nicht RFC-konform; einziger Preis sind strenge Fremd-Parser ohne tz-DB — als bekannte Lücke dokumentiert (Skip-and-report-Territorium des Imports), kein Blocker. Voll-VTIMEZONEist „später, falls nötig".
- All-day →
- UID bei jedem Insert.
insertEventschreibt fortanEvents.UID_2445(z. B.<random-uuid>@calendula). Bestehende Events ohne UID exportieren wir mit einer deterministischen, stabilen Fallback-UID, abgeleitet ausevent-id + DTSTART(<id>-<dtstart>@calendula), damit derselbe Bestand über mehrere Backups dieselbe UID behält und Branch-2-Restore nicht verdoppelt. Bestehende Rows werden nicht rückwirkend gestempelt (kein Migrations-Sweep über fremde Kalender). - Manueller Export, kein Background. Backup via
ACTION_CREATE_DOCUMENT(SAF, MIMEtext/calendar, Default-Namecalendula-backup-<datum>.ics); Einzel-Event-Share viaACTION_SENDmit einemFileProvider-Cache-File (text/calendar). Kein WorkManager, kein geplantes/automatisches Backup (passt zum „kein Hintergrunddienst"-Ethos; Auto-Backup bleibt explizit Roadmap-later). - Backup-Layout: eine kombinierte
VCALENDAR-Datei über alle gerätelokalen (beschreibbaren) Kalender. Pro Event einVEVENT; die Kalender-Zugehörigkeit reist alsX-WR-CALNAME/CATEGORIESo. ä. mit, damit Branch-2-Restore wieder auffächern oder per Ziel-Picker einsortieren kann. Eine Datei ist einfacher zu teilen/abzulegen als n Dateien. Offen, vor dem Backup-Task zu fixieren: exaktes Property fürs Kalender-Mapping (X-WR-CALNAMEproVCALENDARerlaubt nur einen Namen; für mehrere Kalender in einer Datei brauchen wir ein Pro-VEVENT-Property wieX-CALENDULA-CALENDARoderCATEGORIES). - Feldumfang = was Calendula modelliert.
IcsWriterserialisiert genau die gelesenen Felder:SUMMARY,DTSTART/DTEND(Regel #1),LOCATION,DESCRIPTION,RRULE(übertoRRule),VALARMaus den Remindern (DISPLAY,TRIGGER=-PT<min>M),STATUS(CONFIRMED/TENTATIVE/CANCELLED),TRANSP(Free→TRANSPARENT/Busy→OPAQUE),UID,DTSTAMP. Felder ohne sauberes Modell (Attendees, RECURRENCE-ID- Ausnahmen) bleiben vorerst weg — Export erzeugt nichts, was Import in Branch 2 nicht auch wieder lesen kann. - Korrekte RFC-5545-Mechanik: Zeilen-Folding bei >75 Oktett (CRLF +
Space-Fortsetzung), Text-Escaping (
\;,\n), CRLF-Zeilenenden,PRODID/VERSION:2.0-Header. Eine reine, einzeln getestete Hilfsschicht (IcsLine/fold/escapeText), nicht ad hoc im Writer verstreut.
Tasks
Domain-Engine (domain/ics/, reine Kotlin, JVM-Tests):
IcsText:escapeText,foldLine(75-Oktett, CRLF+Space) + Test (IcsTextTest). Wert-Helfer (Instant→…T…Z, Wandzeit→TZID, LocalDate→VALUE=DATE) leben als private Helfer inIcsWriter.IcsEvent-Eingabemodell (reine Kotlin: summary, start/end als Instant + isAllDay + zoneId, recurrenceRule?, location, description, reminderMinutes, status, availability, uid, calendarName) — entkoppelt vom Provider-ModellIcsWriter.writeCalendar(events, dtStamp)→ String: Header, pro EventVEVENTnach Entscheidung #5, Zeitzonen-Regel #1,VALARM; JVM-TestIcsWriterTest(all-day, getimt, wiederkehrend+TZID, unbekannte Zone, Reminder, Escaping)- UID-Ableitung
deriveIcsUid(uid ?: "<eventId>-<dtstartMillis>@calendula") + Stabilitätstest
Provider → Domain (data/calendar/IcsExportMapper.kt):
- Mapper Provider-Row →
IcsEvent(ColumnReader.toIcsEvent) inkl. DURATION→DTEND-Rekonstruktion (parseRfc2445DurationMillis),EventExportProjection; Datasource-MethodeexportableEvents()+ RepositoryexportEvents(); TestIcsExportMapperTest insertEventschreibtEvents.UID_2445(UUID@calendula) bei jedem Create
Android-Export-Schicht:
data/ics/IcsExporter:writeDocument(uri)(SAF) +stageShareFile(FileProvider-Cache) als UTF-8- Einzel-Event-Share: Share-Action im Event-Detail →
IcsWriterfür ein Event (one-off) → Cache-File überFileProvider→ACTION_SEND - Ganz-Kalender-Backup: „Export as .ics file" in Settings → Calendars →
ACTION_CREATE_DOCUMENT→ in den URI streamen; Ergebnis-Snackbar (Plural „Exported N events") FileProvider+file_paths.xmlim Manifest (Cache-Dir für Shares)- Strings DE+EN: Share-Label/Chooser/Fehler, Backup-Sektion/Aktion/ Fehler + Plural, dateierter Default-Name
Abschluss:
./gradlew lint test assembleDebuggrün ← nächster Schritt (Test)- CHANGELOG (
[Unreleased]) ergänzt - On-Device-Review; kein Tag/Release vor Review und vor Merge von
Branch 2 (
feat/ics-import)
Offene Detail-Calls (vor Review klären, nicht-blockierend):
- Kalender→Event-Mapping nutzt das per-
VEVENT-PropertyX-CALENDULA-CALENDAR(stattX-WR-CALNAME), damit eine kombinierte Datei mehrere Kalender trägt. - Backup = eine kombinierte
VCALENDAR-Datei über alle lokalen Kalender. - EXDATE /
RECURRENCE-ID-Ausnahmen werden beim Export ausgelassen (ORIGINAL_ID IS NULL) — dokumentierter v1-Grenzfall, Import lässt sie auch aus.