Merge feat/ics-export into release/v2.7.0
v2.7 Branch 1 of 2: .ics export — single-event share + whole-calendar backup of local calendars. Import (feat/ics-import) lands next in the same release. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -233,18 +233,25 @@ pass on the existing controls; new toggles ride in with their own features.
|
||||
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open
|
||||
|
||||
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||
9. **Reminders — defaults + delivery reliability** *(next)* — global default
|
||||
reminder **+ per-calendar override**, bundled with exact-alarm / battery
|
||||
hardening. Elevated above .ics: it's core to the "Calendula is your only
|
||||
calendar app" promise. Full sketch in "Reminders — defaults & delivery
|
||||
reliability" below.
|
||||
10. **Local-calendar backup / export** — device-only calendars have no sync and
|
||||
therefore **no backup**; losing the phone = total data loss. Whole-calendar
|
||||
`.ics` export + restore. A data-integrity gap, not a feature; front-runs and
|
||||
overlaps the single-event .ics work below.
|
||||
11. Share event as .ics + receive/open .ics into a prefilled create form
|
||||
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
||||
default reminder **+ per-calendar override**, bundled with battery-exemption
|
||||
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
|
||||
10. **The `.ics` engine — export + import** *(in progress → v2.7)* — one
|
||||
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
|
||||
four surfaces: single-event share + whole-calendar backup (export),
|
||||
open-`.ics`→form + whole-calendar restore (import). Closes the
|
||||
device-local-calendar data-loss gap (#10/#11 merged here). Built as **two
|
||||
sequential branches in one release**: `feat/ics-export` (write side +
|
||||
UID-on-create precursor) then `feat/ics-import` (parser, restore, dedup).
|
||||
Import is liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` /
|
||||
`RECURRENCE-ID` it can't model. Timezone rule: all-day `VALUE=DATE`,
|
||||
non-recurring timed UTC `Z`, recurring timed `TZID`-labelled from the stored
|
||||
`EVENT_TIMEZONE` (no `VTIMEZONE` blocks; resolved against the OS tz DB on
|
||||
import). Plan: `docs/superpowers/plans/2026-06-18-05-ics-export.md`.
|
||||
11. **Snooze / dismiss notification actions** *(next, after v2.7)* — follows the
|
||||
`.ics` work; inherits v2.6's deferred exact-alarm/WorkManager decision (snooze
|
||||
must re-fire an alarm).
|
||||
12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
|
||||
13. Snooze / dismiss notification actions — follows the reminders slice (#9)
|
||||
|
||||
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Share a single event as an `.ics` file from the event detail screen — hands a
|
||||
standard calendar file to any app via the system share sheet.
|
||||
- Back up your local (device-only) calendars: Settings → Calendars → Export as
|
||||
`.ics` file writes every event of your on-device calendars to a file you
|
||||
choose. Local calendars aren't synced anywhere, so this is their only backup.
|
||||
(Importing `.ics` files back in lands in the next update.)
|
||||
|
||||
_Note: new events now carry a unique identifier so a future `.ics` import can
|
||||
recognise them and avoid duplicates._
|
||||
|
||||
## [2.6.0] — 2026-06-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -112,6 +112,19 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Hands .ics files we stage in the cache to other apps via a content
|
||||
Uri (single-event share). Authority tracks applicationId so the
|
||||
debug suffix doesn't break getUriForFile. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||
<service
|
||||
|
||||
@@ -19,10 +19,12 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -48,6 +50,13 @@ interface CalendarDataSource {
|
||||
*/
|
||||
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||
|
||||
/**
|
||||
* Every master/one-off event of the writable local calendars, mapped for a
|
||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||
* rows are excluded (see [EventExportProjection]).
|
||||
*/
|
||||
fun exportableEvents(): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
||||
@@ -265,6 +274,39 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
override fun exportableEvents(): List<IcsEvent> {
|
||||
// Only the local calendars the app owns and can write — synced calendars
|
||||
// already have a backup (their server). Map id → display name for the
|
||||
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||
val names = calendars()
|
||||
.filter { it.isLocal && it.canModifyContents }
|
||||
.associate { it.id to it.displayName }
|
||||
if (names.isEmpty()) return emptyList()
|
||||
|
||||
val idList = names.keys.joinToString(",")
|
||||
return resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
EventExportProjection.COLUMNS,
|
||||
// Skip soft-deleted rows and exception rows (modified occurrences /
|
||||
// cancellations) — v1 exports masters + one-offs only.
|
||||
"${CalendarContract.Events.CALENDAR_ID} IN ($idList) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL",
|
||||
null,
|
||||
CalendarContract.Events.DTSTART + " ASC",
|
||||
)?.use { c ->
|
||||
c.mapAll {
|
||||
val reader = CursorColumnReader(c)
|
||||
val eventId = reader.getLong(EventExportProjection.IDX_ID)
|
||||
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID)
|
||||
reader.toIcsEvent(
|
||||
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||
calendarName = names[calendarId],
|
||||
)
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||
@@ -316,6 +358,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||
)
|
||||
// A globally-unique UID so a later .ics backup/restore can identify
|
||||
// the event and not duplicate it on re-import (the provider leaves
|
||||
// this null for events it didn't sync). Older rows without one fall
|
||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -28,6 +29,12 @@ interface CalendarRepository {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
suspend fun deleteCalendar(id: Long)
|
||||
|
||||
/**
|
||||
* Every event of the writable local calendars, ready to serialise into a
|
||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||
*/
|
||||
suspend fun exportEvents(): List<IcsEvent>
|
||||
|
||||
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||
suspend fun createEvent(form: EventForm): Long
|
||||
|
||||
|
||||
@@ -99,6 +99,8 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
override suspend fun deleteCalendar(id: Long) =
|
||||
withContext(io) { dataSource.deleteCalendar(id) }
|
||||
|
||||
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
|
||||
|
||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||
|
||||
/**
|
||||
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||
* for backup. [reminderMinutes] are the row's raw provider reminder offsets and
|
||||
* [calendarName] the display name of its calendar (emitted as
|
||||
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
||||
* MapColumnReader.
|
||||
*/
|
||||
internal fun ColumnReader.toIcsEvent(
|
||||
reminderMinutes: List<Int>,
|
||||
calendarName: String?,
|
||||
): IcsEvent {
|
||||
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
||||
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||
|
||||
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
||||
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
||||
val end = when {
|
||||
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
|
||||
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
|
||||
}
|
||||
|
||||
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
|
||||
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
|
||||
EventStatus.Confirmed
|
||||
} else {
|
||||
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
|
||||
}
|
||||
|
||||
return IcsEvent(
|
||||
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
|
||||
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
||||
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||
end = end.toKotlinInstantFromEpochMillis(),
|
||||
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
|
||||
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||
?: "UTC",
|
||||
recurrenceRule = rrule,
|
||||
location = getString(EventExportProjection.IDX_LOCATION),
|
||||
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||
reminderMinutes = reminderMinutes,
|
||||
status = status,
|
||||
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||
calendarName = calendarName,
|
||||
)
|
||||
}
|
||||
|
||||
// Android's calendar provider (and Calendula's own writes) use the non-standard
|
||||
// single-unit forms P<n>S / P<n>D / P<n>W — seconds without the RFC-required
|
||||
// leading T. Matched first; anything else falls through to the general grammar.
|
||||
private val DURATION_SINGLE_UNIT = Regex("""([+-]?)P(\d+)([WDS])""")
|
||||
private val DURATION_GENERAL =
|
||||
Regex("""([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?""")
|
||||
|
||||
/**
|
||||
* Milliseconds of a DURATION (`P1D`, `P3600S`, `PT1H30M`, `P1W`, …). Calendula
|
||||
* only ever writes `P<days>D` / `P<seconds>S`, but the parser also handles the
|
||||
* general RFC 5545 grammar so a foreign row on a local calendar still
|
||||
* round-trips. Unparseable input is treated as zero.
|
||||
*/
|
||||
internal fun parseRfc2445DurationMillis(duration: String?): Long {
|
||||
if (duration.isNullOrBlank()) return 0L
|
||||
val s = duration.trim()
|
||||
|
||||
DURATION_SINGLE_UNIT.matchEntire(s)?.let { m ->
|
||||
val unitSeconds = when (m.groupValues[3]) {
|
||||
"W" -> 7L * 24 * 60 * 60
|
||||
"D" -> 24L * 60 * 60
|
||||
else -> 1L // S
|
||||
}
|
||||
return m.signum() * m.groupValues[2].toLong() * unitSeconds * 1_000L
|
||||
}
|
||||
|
||||
val m = DURATION_GENERAL.matchEntire(s) ?: return 0L
|
||||
val weeks = m.groupValues[2].toLongOrNull() ?: 0L
|
||||
val days = m.groupValues[3].toLongOrNull() ?: 0L
|
||||
val hours = m.groupValues[4].toLongOrNull() ?: 0L
|
||||
val minutes = m.groupValues[5].toLongOrNull() ?: 0L
|
||||
val seconds = m.groupValues[6].toLongOrNull() ?: 0L
|
||||
val totalSeconds = ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds)
|
||||
return m.signum() * totalSeconds * 1_000L
|
||||
}
|
||||
|
||||
/** Sign carried by group 1 (`-` → -1, otherwise +1) of a duration match. */
|
||||
private fun MatchResult.signum(): Long = if (groupValues[1] == "-") -1L else 1L
|
||||
@@ -97,6 +97,48 @@ internal object EventDetailProjection {
|
||||
const val IDX_EVENT_COLOR_KEY = 17
|
||||
}
|
||||
|
||||
/**
|
||||
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
||||
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
||||
* backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
|
||||
* occurrence and cancelled-exception rows are filtered out by the query
|
||||
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
|
||||
* exported yet — a documented v1 limit (import skips them too).
|
||||
*/
|
||||
internal object EventExportProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.UID_2445,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
CalendarContract.Events.STATUS,
|
||||
CalendarContract.Events.AVAILABILITY,
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
const val IDX_UID = 1
|
||||
const val IDX_TITLE = 2
|
||||
const val IDX_DTSTART = 3
|
||||
const val IDX_DTEND = 4
|
||||
const val IDX_DURATION = 5
|
||||
const val IDX_ALL_DAY = 6
|
||||
const val IDX_EVENT_TIMEZONE = 7
|
||||
const val IDX_RRULE = 8
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_DESCRIPTION = 10
|
||||
const val IDX_STATUS = 11
|
||||
const val IDX_AVAILABILITY = 12
|
||||
const val IDX_CALENDAR_ID = 13
|
||||
}
|
||||
|
||||
internal object AttendeeProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.jeanlucmakiola.calendula.data.ics
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The Android IO edge of `.ics` export: writes a serialised calendar to a
|
||||
* SAF document (whole-calendar backup) or stages it in a cache file behind a
|
||||
* `FileProvider` content Uri (single-event share). The serialisation itself is
|
||||
* the pure `domain.ics.IcsWriter`; this class only moves bytes.
|
||||
*/
|
||||
@Singleton
|
||||
class IcsExporter @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
/** Write [content] to the SAF document at [uri] as UTF-8. Throws on failure. */
|
||||
fun writeDocument(uri: Uri, content: String) {
|
||||
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||
out.write(content.toByteArray(Charsets.UTF_8))
|
||||
} ?: throw IOException("Could not open $uri for writing")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage [content] in a private cache file and return a shareable content
|
||||
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
||||
* the receiving app. The authority mirrors the manifest's `FileProvider`.
|
||||
*/
|
||||
fun stageShareFile(fileName: String, content: String): Uri {
|
||||
val dir = File(context.cacheDir, SHARE_DIR).apply { mkdirs() }
|
||||
val file = File(dir, fileName)
|
||||
file.writeText(content, Charsets.UTF_8)
|
||||
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SHARE_DIR = "shared_ics"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
|
||||
/**
|
||||
* Build the [IcsEvent] for sharing a single event. We export the event the user
|
||||
* is looking at as a **one-off** VEVENT (no RRULE): the detail screen shows one
|
||||
* occurrence, so "share this event" should hand off exactly that instance, not
|
||||
* a whole series anchored to a possibly-different DTSTART. Reminders are the
|
||||
* already-decoded semantic lead times the detail screen holds.
|
||||
*/
|
||||
fun EventDetail.toShareIcsEvent(): IcsEvent {
|
||||
val startMillis = instance.start.toEpochMilliseconds()
|
||||
return IcsEvent(
|
||||
uid = deriveIcsUid(existingUid = null, eventId = instance.eventId, dtStartMillis = startMillis),
|
||||
summary = instance.title,
|
||||
start = instance.start,
|
||||
end = instance.end,
|
||||
isAllDay = instance.isAllDay,
|
||||
zoneId = eventTimezone?.takeIf { it.isNotBlank() } ?: "UTC",
|
||||
recurrenceRule = null,
|
||||
location = instance.location,
|
||||
description = description,
|
||||
reminderMinutes = reminders.map { it.minutes },
|
||||
status = status,
|
||||
availability = availability,
|
||||
calendarName = null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A single event ready to be serialised to a `VEVENT`, decoupled from the
|
||||
* provider so [IcsWriter] stays pure-Kotlin and JVM-testable. [start]/[end] are
|
||||
* absolute instants; [isAllDay] and [recurrenceRule] decide how they are
|
||||
* rendered (see [IcsWriter]'s timezone rule).
|
||||
*/
|
||||
data class IcsEvent(
|
||||
/** RFC 5545 UID — globally stable across exports (see [deriveIcsUid]). */
|
||||
val uid: String,
|
||||
val summary: String,
|
||||
val start: Instant,
|
||||
/** Exclusive end (for all-day: the next-day midnight, as the provider stores it). */
|
||||
val end: Instant,
|
||||
val isAllDay: Boolean,
|
||||
/** IANA zone id (`Events.EVENT_TIMEZONE`); only used for recurring timed events. */
|
||||
val zoneId: String,
|
||||
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
||||
val recurrenceRule: String? = null,
|
||||
val location: String? = null,
|
||||
val description: String? = null,
|
||||
/** Reminder lead times in minutes before start (raw provider offsets). */
|
||||
val reminderMinutes: List<Int> = emptyList(),
|
||||
val status: EventStatus = EventStatus.Confirmed,
|
||||
val availability: Availability = Availability.Busy,
|
||||
/** Source calendar name, emitted as `X-CALENDULA-CALENDAR` so a combined backup can fan back out. */
|
||||
val calendarName: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The UID to export for a provider event. A row that already carries a UID
|
||||
* (`Events.UID_2445`) keeps it; otherwise we synthesise a **stable** one from
|
||||
* the event id and its DTSTART so the same legacy event yields the same UID
|
||||
* across repeated backups — which keeps a later restore from duplicating it.
|
||||
*/
|
||||
fun deriveIcsUid(existingUid: String?, eventId: Long, dtStartMillis: Long): String =
|
||||
existingUid?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: "$eventId-$dtStartMillis@calendula"
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
/**
|
||||
* Low-level RFC 5545 text mechanics, kept separate from [IcsWriter] so the
|
||||
* escaping and folding rules can be tested in isolation. Pure Kotlin — no
|
||||
* Android, no time handling.
|
||||
*/
|
||||
|
||||
/** iCalendar mandates CRLF line breaks, not the platform separator. */
|
||||
const val ICS_CRLF: String = "\r\n"
|
||||
|
||||
/** RFC 5545 §3.1: content lines SHOULD be folded at 75 octets (excluding the break). */
|
||||
private const val MAX_OCTETS = 75
|
||||
|
||||
/**
|
||||
* Escape a TEXT value (SUMMARY, DESCRIPTION, LOCATION, …) per RFC 5545 §3.3.11:
|
||||
* backslash, semicolon and comma are escaped, newlines become the literal `\n`.
|
||||
* Backslash is handled first so it doesn't double-escape the others' markers.
|
||||
*/
|
||||
fun escapeText(value: String): String = buildString(value.length) {
|
||||
for (ch in value) {
|
||||
when (ch) {
|
||||
'\\' -> append("\\\\")
|
||||
';' -> append("\\;")
|
||||
',' -> append("\\,")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> Unit // CR is dropped; a lone CRLF in source text folds to one \n
|
||||
else -> append(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a single content line to ≤75 octets per physical line, inserting
|
||||
* `CRLF + space` between segments (the space is part of the 75-octet budget of
|
||||
* the continuation line, so its content caps at 74). Folding counts UTF-8
|
||||
* octets, never splitting a multi-byte character across a boundary.
|
||||
*/
|
||||
fun foldLine(line: String): String {
|
||||
if (line.toByteArray(Charsets.UTF_8).size <= MAX_OCTETS) return line
|
||||
val out = StringBuilder()
|
||||
var octetsThisLine = 0
|
||||
var first = true
|
||||
var i = 0
|
||||
while (i < line.length) {
|
||||
val cp = line.codePointAt(i)
|
||||
val width = Character.charCount(cp)
|
||||
val piece = line.substring(i, i + width)
|
||||
val pieceOctets = piece.toByteArray(Charsets.UTF_8).size
|
||||
// Continuation lines spend one octet on the leading space.
|
||||
val budget = if (first) MAX_OCTETS else MAX_OCTETS - 1
|
||||
if (octetsThisLine + pieceOctets > budget) {
|
||||
out.append(ICS_CRLF).append(' ')
|
||||
octetsThisLine = 0
|
||||
first = false
|
||||
}
|
||||
out.append(piece)
|
||||
octetsThisLine += pieceOctets
|
||||
i += width
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Default `PRODID` advertising the writer that produced the file. */
|
||||
const val ICS_PROD_ID: String = "-//Jean-Luc Makiola//Calendula//EN"
|
||||
|
||||
/**
|
||||
* Hand-rolled RFC 5545 serialiser for the subset Calendula models. No iCal
|
||||
* library: we stay on `kotlinx-datetime` and own the output, exactly as
|
||||
* `domain/Recurrence.kt` owns RRULE. Pure and JVM-testable — the caller
|
||||
* supplies [dtStamp] (the export moment) so the writer never reads a clock.
|
||||
*
|
||||
* Timezone rule (see plan 05, decision 1):
|
||||
* - all-day → `VALUE=DATE`, no zone;
|
||||
* - timed one-off → UTC instant with a `Z` suffix (an instant is an instant);
|
||||
* - timed recurring → `TZID`-labelled local wall time, so the series stays
|
||||
* anchored to wall-clock across DST. No `VTIMEZONE` block is emitted; import
|
||||
* resolves the `TZID` against the OS tz database.
|
||||
*/
|
||||
class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
|
||||
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant): String {
|
||||
val lines = buildList {
|
||||
add("BEGIN:VCALENDAR")
|
||||
add("VERSION:2.0")
|
||||
add("PRODID:$prodId")
|
||||
add("CALSCALE:GREGORIAN")
|
||||
events.forEach { appendEvent(it, dtStamp) }
|
||||
add("END:VCALENDAR")
|
||||
}
|
||||
return lines.joinToString(ICS_CRLF, postfix = ICS_CRLF) { foldLine(it) }
|
||||
}
|
||||
|
||||
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
|
||||
add("BEGIN:VEVENT")
|
||||
add("UID:${event.uid}")
|
||||
add("DTSTAMP:${utcStamp(dtStamp)}")
|
||||
add("SUMMARY:${escapeText(event.summary)}")
|
||||
appendTimes(event)
|
||||
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
||||
event.location?.takeIf { it.isNotBlank() }
|
||||
?.let { add("LOCATION:${escapeText(it)}") }
|
||||
event.description?.takeIf { it.isNotBlank() }
|
||||
?.let { add("DESCRIPTION:${escapeText(it)}") }
|
||||
add("STATUS:${statusValue(event.status)}")
|
||||
add("TRANSP:${transpValue(event.availability)}")
|
||||
event.calendarName?.takeIf { it.isNotBlank() }
|
||||
?.let { add("X-CALENDULA-CALENDAR:${escapeText(it)}") }
|
||||
event.reminderMinutes.filter { it >= 0 }.distinct().forEach { minutes ->
|
||||
appendAlarm(minutes, event.summary)
|
||||
}
|
||||
add("END:VEVENT")
|
||||
}
|
||||
|
||||
private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
|
||||
event.isAllDay -> {
|
||||
add("DTSTART;VALUE=DATE:${utcDate(event.start)}")
|
||||
add("DTEND;VALUE=DATE:${utcDate(event.end)}")
|
||||
}
|
||||
// Recurring: anchor to wall-clock in the event's own zone.
|
||||
event.recurrenceRule?.isNotBlank() == true -> {
|
||||
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull()
|
||||
if (zone != null) {
|
||||
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}")
|
||||
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}")
|
||||
} else {
|
||||
// Unknown zone id → fall back to plain UTC instants.
|
||||
add("DTSTART:${utcStamp(event.start)}")
|
||||
add("DTEND:${utcStamp(event.end)}")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
add("DTSTART:${utcStamp(event.start)}")
|
||||
add("DTEND:${utcStamp(event.end)}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
|
||||
add("BEGIN:VALARM")
|
||||
add("ACTION:DISPLAY")
|
||||
add("DESCRIPTION:${escapeText(summary.ifBlank { "Reminder" })}")
|
||||
add("TRIGGER:${triggerValue(minutes)}")
|
||||
add("END:VALARM")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun statusValue(status: EventStatus): String = when (status) {
|
||||
EventStatus.Confirmed -> "CONFIRMED"
|
||||
EventStatus.Tentative -> "TENTATIVE"
|
||||
EventStatus.Cancelled -> "CANCELLED"
|
||||
}
|
||||
|
||||
// iCal TRANSP is binary; only an explicitly free event is TRANSPARENT.
|
||||
fun transpValue(availability: Availability): String =
|
||||
if (availability == Availability.Free) "TRANSPARENT" else "OPAQUE"
|
||||
|
||||
// A lead time of 0 fires at start (PT0M); anything positive is "before".
|
||||
fun triggerValue(minutes: Int): String =
|
||||
if (minutes <= 0) "PT0M" else "-PT${minutes}M"
|
||||
|
||||
fun utcStamp(instant: Instant): String =
|
||||
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
|
||||
|
||||
fun localStamp(instant: Instant, zone: TimeZone): String =
|
||||
basic(instant.toLocalDateTime(zone))
|
||||
|
||||
fun utcDate(instant: Instant): String {
|
||||
val dt = instant.toLocalDateTime(TimeZone.UTC)
|
||||
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
|
||||
}
|
||||
|
||||
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
|
||||
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -30,6 +32,7 @@ import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -77,6 +80,7 @@ import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import java.time.LocalDate
|
||||
|
||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
@@ -95,6 +99,7 @@ fun CalendarsScreen(
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
|
||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||
// [editorSession] bumps on every open so the editor's field state resets for
|
||||
@@ -131,6 +136,9 @@ fun CalendarsScreen(
|
||||
synced = calendars.filterNot { it.isLocal },
|
||||
error = error,
|
||||
onConsumeError = viewModel::consumeError,
|
||||
backupResult = backupResult,
|
||||
onExportBackup = viewModel::exportBackup,
|
||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||
onBack = onBack,
|
||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||
@@ -144,6 +152,9 @@ private fun CalendarsList(
|
||||
synced: List<CalendarSource>,
|
||||
error: Boolean,
|
||||
onConsumeError: () -> Unit,
|
||||
backupResult: BackupResult?,
|
||||
onExportBackup: (android.net.Uri) -> Unit,
|
||||
onConsumeBackupResult: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onEdit: (CalendarSource) -> Unit,
|
||||
@@ -159,6 +170,31 @@ private fun CalendarsList(
|
||||
}
|
||||
}
|
||||
|
||||
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||
// to the VM to stream the .ics into.
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let(onExportBackup) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
when (val r = backupResult) {
|
||||
is BackupResult.Success -> {
|
||||
snackbarHostState.showSnackbar(
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||
),
|
||||
)
|
||||
onConsumeBackupResult()
|
||||
}
|
||||
BackupResult.Failure -> {
|
||||
snackbarHostState.showSnackbar(backupFailedText)
|
||||
onConsumeBackupResult()
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.calendars_title),
|
||||
onBack = onBack,
|
||||
@@ -195,6 +231,22 @@ private fun CalendarsList(
|
||||
onClick = onAdd,
|
||||
)
|
||||
|
||||
// Backup — local calendars have no sync, so a .ics export is their only
|
||||
// safety net. Offered only when there is something to back up.
|
||||
if (local.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Synced calendars — read-only, grouped by account, each with a
|
||||
@@ -429,6 +481,25 @@ private fun AccountHeader(account: String, accountType: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */
|
||||
@Composable
|
||||
private fun LeadingAvatar(icon: ImageVector) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
||||
@Composable
|
||||
private fun AddAvatar() {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -15,7 +18,9 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -27,6 +32,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class CalendarsViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -45,6 +51,36 @@ class CalendarsViewModel @Inject constructor(
|
||||
|
||||
fun consumeError() { _error.value = false }
|
||||
|
||||
private val _backupResult = MutableStateFlow<BackupResult?>(null)
|
||||
val backupResult: StateFlow<BackupResult?> = _backupResult.asStateFlow()
|
||||
|
||||
fun consumeBackupResult() { _backupResult.value = null }
|
||||
|
||||
/**
|
||||
* Serialise every event of the writable local calendars into the chosen SAF
|
||||
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||
* in [backupResult] for a one-shot message.
|
||||
*/
|
||||
fun exportBackup(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
_backupResult.value = try {
|
||||
val count = withContext(io) {
|
||||
val events = repository.exportEvents()
|
||||
icsExporter.writeDocument(
|
||||
uri = uri,
|
||||
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||
)
|
||||
events.size
|
||||
}
|
||||
BackupResult.Success(count)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
BackupResult.Failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createCalendar(displayName: String, color: Int, description: String?) = write {
|
||||
repository.createLocalCalendar(displayName, color, description)
|
||||
}
|
||||
@@ -69,3 +105,9 @@ class CalendarsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||
sealed interface BackupResult {
|
||||
data class Success(val eventCount: Int) : BackupResult
|
||||
data object Failure : BackupResult
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -59,6 +60,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -96,6 +98,7 @@ import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -132,9 +135,30 @@ fun EventDetailScreen(
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
||||
// an .ics in the cache and hands back a content Uri for the chooser.
|
||||
val shareFailedMessage = stringResource(R.string.event_share_failed)
|
||||
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||
val onShareClick = {
|
||||
scope.launch {
|
||||
val uri = viewModel.shareUri()
|
||||
val sent = uri != null && runCatching {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/calendar"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(send, shareChooserTitle))
|
||||
}.isSuccess
|
||||
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||
// upgrade in place. Granting continues straight into the tapped action.
|
||||
var pendingEdit by remember { mutableStateOf(false) }
|
||||
@@ -203,9 +227,18 @@ fun EventDetailScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Only writable calendars get actions — WebCal subscriptions,
|
||||
// birthday calendars etc. are read-only at the provider level.
|
||||
val s = state
|
||||
// Share works for any loaded event — it only reads the event.
|
||||
if (s is EventDetailUiState.Success) {
|
||||
IconButton(onClick = onShareClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = stringResource(R.string.event_detail_share),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||
// birthday calendars etc. are read-only at the provider level.
|
||||
if (s is EventDetailUiState.Success && s.canModify) {
|
||||
IconButton(
|
||||
onClick = onEditClick,
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -34,6 +40,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class EventDetailViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -113,6 +120,24 @@ class EventDetailViewModel @Inject constructor(
|
||||
_deleteState.value = DeleteUiState.Idle
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise the open event to a `.ics` cache file and return a shareable
|
||||
* content Uri (for an ACTION_SEND), or null when nothing is loaded or the
|
||||
* write fails. The event is exported as a one-off (see [toShareIcsEvent]).
|
||||
*/
|
||||
suspend fun shareUri(): Uri? {
|
||||
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
|
||||
return runCatching {
|
||||
withContext(io) {
|
||||
val ics = IcsWriter().writeCalendar(
|
||||
events = listOf(detail.toShareIcsEvent()),
|
||||
dtStamp = Clock.System.now(),
|
||||
)
|
||||
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||
val detail = repository.eventDetail(target.eventId)
|
||||
// The Events row holds the series start; replace it with this
|
||||
@@ -143,3 +168,13 @@ class EventDetailViewModel @Inject constructor(
|
||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||
}
|
||||
|
||||
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||
private fun shareFileName(title: String): String {
|
||||
val base = title.trim()
|
||||
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
|
||||
.replace(' ', '_')
|
||||
.take(40)
|
||||
.ifBlank { "event" }
|
||||
return "$base.ics"
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
<string name="event_detail_back">Zurück</string>
|
||||
<string name="event_detail_edit">Bearbeiten</string>
|
||||
<string name="event_detail_delete">Löschen</string>
|
||||
<string name="event_detail_share">Teilen</string>
|
||||
<string name="event_share_chooser_title">Termin teilen</string>
|
||||
<string name="event_share_failed">Termin konnte nicht geteilt werden.</string>
|
||||
<string name="event_delete_title">Termin löschen?</string>
|
||||
<string name="event_delete_body">Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt.</string>
|
||||
<string name="event_delete_recurring_title">Wiederkehrenden Termin löschen</string>
|
||||
@@ -297,4 +300,13 @@
|
||||
<string name="calendars_delete_confirm_title">Kalender löschen?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" und alle zugehörigen Termine werden dauerhaft von diesem Gerät entfernt.</string>
|
||||
<string name="calendars_write_error">Änderung konnte nicht gespeichert werden.</string>
|
||||
<!-- Backup (whole-calendar .ics export) -->
|
||||
<string name="calendars_backup_header">Sicherung</string>
|
||||
<string name="calendars_backup_hint">Lokale Kalender werden nirgends synchronisiert – exportiere sie als .ics-Datei, um eine Kopie zu behalten.</string>
|
||||
<string name="calendars_backup_action">Als .ics-Datei exportieren</string>
|
||||
<string name="calendars_backup_failed">Sicherung konnte nicht exportiert werden.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">%d Termin exportiert.</item>
|
||||
<item quantity="other">%d Termine exportiert.</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
<string name="event_detail_back">Back</string>
|
||||
<string name="event_detail_edit">Edit</string>
|
||||
<string name="event_detail_delete">Delete</string>
|
||||
<string name="event_detail_share">Share</string>
|
||||
<string name="event_share_chooser_title">Share event</string>
|
||||
<string name="event_share_failed">Couldn\'t share this event.</string>
|
||||
<string name="event_delete_title">Delete event?</string>
|
||||
<string name="event_delete_body">The event is removed from your calendar and from every device it syncs to.</string>
|
||||
<string name="event_delete_recurring_title">Delete recurring event</string>
|
||||
@@ -294,6 +297,15 @@
|
||||
<string name="calendars_delete_confirm_title">Delete calendar?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
|
||||
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
||||
<!-- Backup (whole-calendar .ics export) -->
|
||||
<string name="calendars_backup_header">Backup</string>
|
||||
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
|
||||
<string name="calendars_backup_action">Export as .ics file</string>
|
||||
<string name="calendars_backup_failed">Couldn\'t export the backup.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">Exported %d event.</item>
|
||||
<item quantity="other">Exported %d events.</item>
|
||||
</plurals>
|
||||
<!-- Launcher long-press shortcuts -->
|
||||
<string name="shortcut_new_event_short">New event</string>
|
||||
<string name="shortcut_new_event_long">Create a new event</string>
|
||||
|
||||
7
app/src/main/res/xml/file_paths.xml
Normal file
7
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Exposes the cache subdirectory where IcsExporter stages files for sharing. -->
|
||||
<paths>
|
||||
<cache-path
|
||||
name="shared_ics"
|
||||
path="shared_ics/" />
|
||||
</paths>
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
|
||||
/**
|
||||
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
||||
@@ -16,6 +17,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||
/** Set to make the next write call throw. */
|
||||
var writeError: Exception? = null
|
||||
/** Id returned by the next [insertEvent]. */
|
||||
@@ -49,6 +51,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
eventColorPaletteResult(calendarId)
|
||||
override fun exportableEvents(): List<IcsEvent> = exportableEventsResult
|
||||
|
||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||
writeError?.let { throw it }
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class IcsExportMapperTest {
|
||||
|
||||
@Test
|
||||
fun `parses the duration forms Calendula writes plus general ones`() {
|
||||
assertThat(parseRfc2445DurationMillis("P1D")).isEqualTo(86_400_000L)
|
||||
assertThat(parseRfc2445DurationMillis("P3600S")).isEqualTo(3_600_000L)
|
||||
assertThat(parseRfc2445DurationMillis("PT1H30M")).isEqualTo(5_400_000L)
|
||||
assertThat(parseRfc2445DurationMillis("P1W")).isEqualTo(604_800_000L)
|
||||
assertThat(parseRfc2445DurationMillis(null)).isEqualTo(0L)
|
||||
assertThat(parseRfc2445DurationMillis("nonsense")).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed one-off row maps with its DTEND and kept UID`() {
|
||||
val reader = MapColumnReader(
|
||||
EventExportProjection.IDX_ID to 42L,
|
||||
EventExportProjection.IDX_UID to "abc@host",
|
||||
EventExportProjection.IDX_TITLE to "Standup",
|
||||
EventExportProjection.IDX_DTSTART to 1_000_000L,
|
||||
EventExportProjection.IDX_DTEND to 1_900_000L,
|
||||
EventExportProjection.IDX_ALL_DAY to 0,
|
||||
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
|
||||
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
|
||||
)
|
||||
|
||||
val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal")
|
||||
|
||||
assertThat(event.uid).isEqualTo("abc@host")
|
||||
assertThat(event.summary).isEqualTo("Standup")
|
||||
assertThat(event.start.toEpochMilliseconds()).isEqualTo(1_000_000L)
|
||||
assertThat(event.end.toEpochMilliseconds()).isEqualTo(1_900_000L)
|
||||
assertThat(event.isAllDay).isFalse()
|
||||
assertThat(event.recurrenceRule).isNull()
|
||||
assertThat(event.reminderMinutes).containsExactly(10)
|
||||
assertThat(event.calendarName).isEqualTo("Personal")
|
||||
assertThat(event.status).isEqualTo(EventStatus.Confirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recurring row without DTEND reconstructs end from DURATION`() {
|
||||
val reader = MapColumnReader(
|
||||
EventExportProjection.IDX_ID to 7L,
|
||||
// No UID column → synthesised stably from id + dtstart.
|
||||
EventExportProjection.IDX_TITLE to "Weekly",
|
||||
EventExportProjection.IDX_DTSTART to 1_000_000L,
|
||||
// DTEND absent (null); DURATION carries the length.
|
||||
EventExportProjection.IDX_DURATION to "P3600S",
|
||||
EventExportProjection.IDX_ALL_DAY to 0,
|
||||
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
|
||||
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||
)
|
||||
|
||||
val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null)
|
||||
|
||||
assertThat(event.uid).isEqualTo("7-1000000@calendula")
|
||||
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(event.end.toEpochMilliseconds()).isEqualTo(1_000_000L + 3_600_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day flag is carried through`() {
|
||||
val reader = MapColumnReader(
|
||||
EventExportProjection.IDX_ID to 1L,
|
||||
EventExportProjection.IDX_TITLE to "Holiday",
|
||||
EventExportProjection.IDX_DTSTART to 0L,
|
||||
EventExportProjection.IDX_DTEND to 86_400_000L,
|
||||
EventExportProjection.IDX_ALL_DAY to 1,
|
||||
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||
)
|
||||
|
||||
assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class IcsTextTest {
|
||||
|
||||
@Test
|
||||
fun `escapes backslash semicolon comma and newline`() {
|
||||
assertThat(escapeText("a\\b;c,d\ne"))
|
||||
.isEqualTo("a\\\\b\\;c\\,d\\ne")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backslash is escaped before its escape markers, not after`() {
|
||||
// A single backslash must become exactly one escaped backslash, not
|
||||
// accidentally combine with a following separator.
|
||||
assertThat(escapeText("\\;")).isEqualTo("\\\\\\;")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `short line is returned unfolded`() {
|
||||
val line = "SUMMARY:short"
|
||||
assertThat(foldLine(line)).isEqualTo(line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long line folds into physical lines of at most 75 octets`() {
|
||||
val line = "DESCRIPTION:" + "x".repeat(300)
|
||||
val folded = foldLine(line)
|
||||
|
||||
val physical = folded.split(ICS_CRLF)
|
||||
assertThat(physical.size).isGreaterThan(1)
|
||||
physical.forEach { assertThat(it.toByteArray(Charsets.UTF_8).size).isAtMost(75) }
|
||||
// Every continuation line begins with the single folding space.
|
||||
physical.drop(1).forEach { assertThat(it).startsWith(" ") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unfolding a folded line restores the original`() {
|
||||
val line = "DESCRIPTION:" + "abcdefg ".repeat(40).trim()
|
||||
val unfolded = foldLine(line).replace(ICS_CRLF + " ", "")
|
||||
assertThat(unfolded).isEqualTo(line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `folding never splits a multi-byte character`() {
|
||||
// 100 emoji (4 UTF-8 octets each) — a naive byte split would corrupt one.
|
||||
val line = "X-NOTE:" + "😀".repeat(100)
|
||||
val folded = foldLine(line)
|
||||
// The reassembled content must still decode to the same string.
|
||||
assertThat(folded.replace(ICS_CRLF + " ", "")).isEqualTo(line)
|
||||
folded.split(ICS_CRLF).forEach {
|
||||
assertThat(it.toByteArray(Charsets.UTF_8).size).isAtMost(75)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class IcsWriterTest {
|
||||
|
||||
private val writer = IcsWriter(prodId = "-//Test//Test//EN")
|
||||
private val stamp = LocalDateTime(2026, 6, 18, 9, 30, 0).toInstant(TimeZone.UTC)
|
||||
|
||||
private fun lines(events: List<IcsEvent>): List<String> =
|
||||
writer.writeCalendar(events, stamp).split(ICS_CRLF)
|
||||
|
||||
private fun instantUtc(y: Int, mo: Int, d: Int, h: Int, mi: Int): Instant =
|
||||
LocalDateTime(y, mo, d, h, mi, 0).toInstant(TimeZone.UTC)
|
||||
|
||||
@Test
|
||||
fun `calendar is wrapped with the required header and CRLF endings`() {
|
||||
val out = writer.writeCalendar(emptyList(), stamp)
|
||||
assertThat(out).startsWith("BEGIN:VCALENDAR\r\n")
|
||||
assertThat(out).endsWith("END:VCALENDAR\r\n")
|
||||
assertThat(out).contains("VERSION:2.0\r\n")
|
||||
assertThat(out).contains("PRODID:-//Test//Test//EN\r\n")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed one-off event writes UTC instants with a Z suffix`() {
|
||||
val event = IcsEvent(
|
||||
uid = "u1@calendula",
|
||||
summary = "Standup",
|
||||
start = instantUtc(2026, 6, 18, 13, 0),
|
||||
end = instantUtc(2026, 6, 18, 13, 30),
|
||||
isAllDay = false,
|
||||
zoneId = "Europe/Berlin",
|
||||
)
|
||||
val l = lines(listOf(event))
|
||||
assertThat(l).contains("DTSTART:20260618T130000Z")
|
||||
assertThat(l).contains("DTEND:20260618T133000Z")
|
||||
assertThat(l).contains("UID:u1@calendula")
|
||||
assertThat(l).contains("STATUS:CONFIRMED")
|
||||
assertThat(l).contains("TRANSP:OPAQUE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recurring timed event anchors to wall-clock with TZID`() {
|
||||
// 13:00 UTC == 15:00 Berlin in summer; the series must read 15:00 local.
|
||||
val event = IcsEvent(
|
||||
uid = "u2@calendula",
|
||||
summary = "Weekly",
|
||||
start = instantUtc(2026, 6, 18, 13, 0),
|
||||
end = instantUtc(2026, 6, 18, 14, 0),
|
||||
isAllDay = false,
|
||||
zoneId = "Europe/Berlin",
|
||||
recurrenceRule = "FREQ=WEEKLY",
|
||||
)
|
||||
val l = lines(listOf(event))
|
||||
assertThat(l).contains("DTSTART;TZID=Europe/Berlin:20260618T150000")
|
||||
assertThat(l).contains("DTEND;TZID=Europe/Berlin:20260618T160000")
|
||||
assertThat(l).contains("RRULE:FREQ=WEEKLY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recurring event with an unknown zone falls back to UTC instants`() {
|
||||
val event = IcsEvent(
|
||||
uid = "u3@calendula",
|
||||
summary = "Weekly",
|
||||
start = instantUtc(2026, 6, 18, 13, 0),
|
||||
end = instantUtc(2026, 6, 18, 14, 0),
|
||||
isAllDay = false,
|
||||
zoneId = "Mars/Olympus",
|
||||
recurrenceRule = "FREQ=WEEKLY",
|
||||
)
|
||||
val l = lines(listOf(event))
|
||||
assertThat(l).contains("DTSTART:20260618T130000Z")
|
||||
assertThat(l).contains("DTEND:20260618T140000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event writes exclusive DATE values without a zone`() {
|
||||
val start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC)
|
||||
val end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC)
|
||||
val event = IcsEvent(
|
||||
uid = "u4@calendula",
|
||||
summary = "Holiday",
|
||||
start = start,
|
||||
end = end,
|
||||
isAllDay = true,
|
||||
zoneId = "UTC",
|
||||
)
|
||||
val l = lines(listOf(event))
|
||||
assertThat(l).contains("DTSTART;VALUE=DATE:20260618")
|
||||
assertThat(l).contains("DTEND;VALUE=DATE:20260619")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reminders become VALARM blocks with before-start triggers`() {
|
||||
val event = IcsEvent(
|
||||
uid = "u5@calendula",
|
||||
summary = "Meeting",
|
||||
start = instantUtc(2026, 6, 18, 13, 0),
|
||||
end = instantUtc(2026, 6, 18, 14, 0),
|
||||
isAllDay = false,
|
||||
zoneId = "UTC",
|
||||
reminderMinutes = listOf(15, 0, 15), // duplicate is dropped
|
||||
)
|
||||
val out = writer.writeCalendar(listOf(event), stamp)
|
||||
val l = out.split(ICS_CRLF)
|
||||
assertThat(l.count { it == "BEGIN:VALARM" }).isEqualTo(2)
|
||||
assertThat(l).contains("TRIGGER:-PT15M")
|
||||
assertThat(l).contains("TRIGGER:PT0M")
|
||||
assertThat(l).contains("ACTION:DISPLAY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text fields and the calendar name are escaped`() {
|
||||
val event = IcsEvent(
|
||||
uid = "u6@calendula",
|
||||
summary = "Lunch; with, notes",
|
||||
start = instantUtc(2026, 6, 18, 13, 0),
|
||||
end = instantUtc(2026, 6, 18, 14, 0),
|
||||
isAllDay = false,
|
||||
zoneId = "UTC",
|
||||
location = "Cafe\\Bar",
|
||||
availability = Availability.Free,
|
||||
status = EventStatus.Tentative,
|
||||
calendarName = "Work, Personal",
|
||||
)
|
||||
val l = lines(listOf(event))
|
||||
assertThat(l).contains("SUMMARY:Lunch\\; with\\, notes")
|
||||
assertThat(l).contains("LOCATION:Cafe\\\\Bar")
|
||||
assertThat(l).contains("STATUS:TENTATIVE")
|
||||
assertThat(l).contains("TRANSP:TRANSPARENT")
|
||||
assertThat(l).contains("X-CALENDULA-CALENDAR:Work\\, Personal")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existing uid is kept and a missing one is synthesised stably`() {
|
||||
assertThat(deriveIcsUid("real-uid@host", 7, 1000)).isEqualTo("real-uid@host")
|
||||
assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo("7-1000@calendula")
|
||||
assertThat(deriveIcsUid(" ", 7, 1000)).isEqualTo("7-1000@calendula")
|
||||
// Stable across calls — a re-export of the same row yields the same UID.
|
||||
assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo(deriveIcsUid(null, 7, 1000))
|
||||
}
|
||||
}
|
||||
150
docs/superpowers/plans/2026-06-18-05-ics-export.md
Normal file
150
docs/superpowers/plans/2026-06-18-05-ics-export.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 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):**
|
||||
|
||||
1. **Keine ICS-Library, kein `java.time`-Desugaring** — Stack ist
|
||||
`kotlinx-datetime` + `kotlin.time.Instant`. RRULE wird bereits in
|
||||
`domain/Recurrence.kt` von Hand geparst/gerendert (inkl. der sorgfältigen
|
||||
`UNTIL`/DST-Korrektur). `IcsWriter` reiht sich in genau diese Kultur ein und
|
||||
nutzt `SimpleRecurrence.toRRule()` direkt.
|
||||
2. **Kein UID-Handling.** `Events.UID_2445` wird 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).
|
||||
3. **Zeitzonen werden gespeichert, aber nie vom Nutzer gewählt.** Lesen/Anzeige:
|
||||
`EVENT_TIMEZONE` landet in `EventDetail.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
|
||||
übergeben `currentSystemDefault()` (kein Zonen-Feld im Formular). Jedes selbst
|
||||
erstellte Event trägt also die Gerätezone zum Erstellzeitpunkt; eingesynkte
|
||||
Events behalten ihre Originalzone.
|
||||
|
||||
**Leitentscheidungen:**
|
||||
|
||||
1. **Zeitzonen-Regel beim Schreiben (fallbasiert):**
|
||||
- **All-day** → `DTSTART;VALUE=DATE:YYYYMMDD`, `DTEND` exklusiv
|
||||
(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 in `EVENT_TIMEZONE` vor; die lokale Wandzeit ist eine
|
||||
`kotlinx-datetime`-Konversion (Instant → LocalDateTime in der Zone).
|
||||
- **`VTIMEZONE`-Blöcke werden bewusst NICHT emittiert.** Beim eigenen
|
||||
Round-Trip löst Branch-2-Import `TZID` gegen die OS-tz-Datenbank auf
|
||||
(`kotlinx-datetime`/`java.time` kennen 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-`VTIMEZONE` ist „später, falls nötig".
|
||||
2. **UID bei jedem Insert.** `insertEvent` schreibt fortan `Events.UID_2445`
|
||||
(z. B. `<random-uuid>@calendula`). Bestehende Events ohne UID exportieren
|
||||
wir mit einer **deterministischen, stabilen** Fallback-UID, abgeleitet aus
|
||||
`event-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).
|
||||
3. **Manueller Export, kein Background.** Backup via
|
||||
`ACTION_CREATE_DOCUMENT` (SAF, MIME `text/calendar`, Default-Name
|
||||
`calendula-backup-<datum>.ics`); Einzel-Event-Share via `ACTION_SEND` mit
|
||||
einem `FileProvider`-Cache-File (`text/calendar`). Kein WorkManager, kein
|
||||
geplantes/automatisches Backup (passt zum „kein Hintergrunddienst"-Ethos;
|
||||
Auto-Backup bleibt explizit Roadmap-`later`).
|
||||
4. **Backup-Layout: eine kombinierte `VCALENDAR`-Datei** über alle
|
||||
gerätelokalen (beschreibbaren) Kalender. Pro Event ein `VEVENT`; die
|
||||
Kalender-Zugehörigkeit reist als `X-WR-CALNAME` / `CATEGORIES` o. ä. 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-CALNAME` pro `VCALENDAR` erlaubt nur einen Namen;
|
||||
für mehrere Kalender in einer Datei brauchen wir ein Pro-`VEVENT`-Property
|
||||
wie `X-CALENDULA-CALENDAR` oder `CATEGORIES`).
|
||||
5. **Feldumfang = was Calendula modelliert.** `IcsWriter` serialisiert genau
|
||||
die gelesenen Felder: `SUMMARY`, `DTSTART`/`DTEND` (Regel #1),
|
||||
`LOCATION`, `DESCRIPTION`, `RRULE` (über `toRRule`), `VALARM` aus 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.
|
||||
6. **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):**
|
||||
- [x] `IcsText`: `escapeText`, `foldLine` (75-Oktett, CRLF+Space) + Test
|
||||
(`IcsTextTest`). Wert-Helfer (Instant→`…T…Z`, Wandzeit→`TZID`,
|
||||
LocalDate→`VALUE=DATE`) leben als private Helfer in `IcsWriter`.
|
||||
- [x] `IcsEvent`-Eingabemodell (reine Kotlin: summary, start/end als Instant
|
||||
+ isAllDay + zoneId, recurrenceRule?, location, description,
|
||||
reminderMinutes, status, availability, uid, calendarName) — entkoppelt
|
||||
vom Provider-Modell
|
||||
- [x] `IcsWriter.writeCalendar(events, dtStamp)` → String: Header, pro Event
|
||||
`VEVENT` nach Entscheidung #5, Zeitzonen-Regel #1, `VALARM`; JVM-Test
|
||||
`IcsWriterTest` (all-day, getimt, wiederkehrend+TZID, unbekannte Zone,
|
||||
Reminder, Escaping)
|
||||
- [x] UID-Ableitung `deriveIcsUid` (`uid ?: "<eventId>-<dtstartMillis>@calendula"`)
|
||||
+ Stabilitätstest
|
||||
|
||||
**Provider → Domain (`data/calendar/IcsExportMapper.kt`):**
|
||||
- [x] Mapper Provider-Row → `IcsEvent` (`ColumnReader.toIcsEvent`) inkl.
|
||||
DURATION→DTEND-Rekonstruktion (`parseRfc2445DurationMillis`),
|
||||
`EventExportProjection`; Datasource-Methode `exportableEvents()` +
|
||||
Repository `exportEvents()`; Test `IcsExportMapperTest`
|
||||
- [x] `insertEvent` schreibt `Events.UID_2445` (`UUID@calendula`) bei jedem
|
||||
Create
|
||||
|
||||
**Android-Export-Schicht:**
|
||||
- [x] `data/ics/IcsExporter`: `writeDocument(uri)` (SAF) + `stageShareFile`
|
||||
(FileProvider-Cache) als UTF-8
|
||||
- [x] Einzel-Event-Share: Share-Action im Event-Detail → `IcsWriter` für ein
|
||||
Event (one-off) → Cache-File über `FileProvider` → `ACTION_SEND`
|
||||
- [x] Ganz-Kalender-Backup: „Export as .ics file" in Settings → Calendars →
|
||||
`ACTION_CREATE_DOCUMENT` → in den URI streamen; Ergebnis-Snackbar
|
||||
(Plural „Exported N events")
|
||||
- [x] `FileProvider` + `file_paths.xml` im Manifest (Cache-Dir für Shares)
|
||||
- [x] Strings DE+EN: Share-Label/Chooser/Fehler, Backup-Sektion/Aktion/
|
||||
Fehler + Plural, dateierter Default-Name
|
||||
|
||||
**Abschluss:**
|
||||
- [ ] `./gradlew lint test assembleDebug` grün ← **nächster Schritt (Test)**
|
||||
- [x] 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`-Property `X-CALENDULA-CALENDAR`
|
||||
(statt `X-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.
|
||||
Reference in New Issue
Block a user