Compare commits
15 Commits
v2.6.0
...
renovate/c
| Author | SHA1 | Date | |
|---|---|---|---|
| e283a7a0f3 | |||
| 81baadfaf3 | |||
| 35022267dc | |||
| 588e024036 | |||
| eeef089e4a | |||
| 9023899ddb | |||
| 2f153fef56 | |||
| 290a905f8b | |||
| d20d446cbe | |||
| 6e14d5964b | |||
| 3dfc96718c | |||
| e1c2e9f2e5 | |||
| 90b219bdad | |||
| 233a9b03a3 | |||
| 0b683d374f |
42
.gitea/workflows/renovate.yml
Normal file
42
.gitea/workflows/renovate.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Renovate
|
||||
|
||||
on:
|
||||
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
|
||||
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
|
||||
schedule:
|
||||
- cron: '0 5 * * 1'
|
||||
# Manual run for an on-demand sweep from the Actions tab.
|
||||
workflow_dispatch:
|
||||
|
||||
# Never let two Renovate runs touch the repo at once.
|
||||
concurrency:
|
||||
group: renovate
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: docker
|
||||
# Run the Renovate image *as* the job container and invoke the `renovate`
|
||||
# binary directly. The renovatebot/github-action wrapper is a thin Node
|
||||
# action that shells out to `docker run …` — it needs a Docker CLI + socket
|
||||
# inside the job, which the Gitea runner's plain node container has not, so
|
||||
# it died on "Unable to locate executable file: docker". Running the image
|
||||
# directly drops the docker-in-docker requirement entirely.
|
||||
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
|
||||
container:
|
||||
image: ghcr.io/renovatebot/renovate:43.232.0
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
run: renovate
|
||||
env:
|
||||
# Self-hosted Gitea, not github.com.
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
|
||||
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
# Scope to this repo only — no org-wide autodiscovery.
|
||||
RENOVATE_AUTODISCOVER: 'false'
|
||||
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
|
||||
# Commits/PRs authored as the bot, not a real maintainer.
|
||||
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
|
||||
LOG_LEVEL: info
|
||||
@@ -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)
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.7.0] — 2026-06-18
|
||||
|
||||
### 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.
|
||||
- Open or share an `.ics` file into Calendula: a single event opens the create
|
||||
form prefilled for review, while a file with many events (e.g. a backup) opens
|
||||
a bulk import — pick a calendar and import them all. Re-importing a backup
|
||||
won't create duplicates (events are matched by their unique identifier), and
|
||||
anything Calendula can't represent (changed recurring occurrences, guest
|
||||
lists) is reported rather than silently dropped.
|
||||
|
||||
### Fixed
|
||||
- All-day events that cover a single day (e.g. a birthday) no longer show up on
|
||||
the following day as well — in the day, week and month views or on the event
|
||||
detail screen. The extra day came from interpreting the all-day date range in
|
||||
the device's time zone instead of UTC.
|
||||
- Fixed the app crashing immediately on every launch in the optimized release
|
||||
build: release code-shrinking (R8) was stripping a database class the
|
||||
home-screen widget framework needs, so the app died at startup before showing
|
||||
anything. Added the missing keep rule.
|
||||
|
||||
## [2.6.0] — 2026-06-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
||||
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
||||
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
||||
versionCode = 20600
|
||||
versionName = "2.6.0"
|
||||
versionCode = 20700
|
||||
versionName = "2.7.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
11
app/proguard-rules.pro
vendored
11
app/proguard-rules.pro
vendored
@@ -4,3 +4,14 @@
|
||||
|
||||
# Compose Compiler may keep its own; defaults are fine
|
||||
-dontwarn org.jetbrains.annotations.**
|
||||
|
||||
# Room database implementations (pulled in transitively via
|
||||
# androidx.glance:glance-appwidget → androidx.work → androidx.room).
|
||||
# The widgets rely on Glance, whose WorkManager backend stores state in a Room
|
||||
# database. Under R8 full mode (AGP 9 default) the generated *_Impl subclasses
|
||||
# of RoomDatabase lose their usable no-arg constructor / are marked abstract,
|
||||
# so Room's reflective instantiation throws InstantiationException and the app
|
||||
# crashes at startup with "Failed to create an instance of ...WorkDatabase".
|
||||
# Keep the generated Room database implementations fully intact.
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-dontwarn androidx.room.paging.**
|
||||
|
||||
@@ -47,6 +47,21 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||
</intent-filter>
|
||||
<!-- Receive a .ics shared from another app. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
@@ -112,6 +127,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
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -12,6 +13,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -35,11 +37,16 @@ class MainActivity : AppCompatActivity() {
|
||||
// create). Consumed once by CalendarHost, same pattern as the detail key.
|
||||
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
|
||||
|
||||
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
|
||||
// by CalendarHost's import flow.
|
||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
// and the Settings screen, so a theme change applies app-wide at once.
|
||||
@@ -60,6 +67,8 @@ class MainActivity : AppCompatActivity() {
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +78,21 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onNewIntent(intent)
|
||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
||||
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
||||
* app's own `calendula://` deep-links never match.
|
||||
*/
|
||||
private fun Intent.importUriOrNull(): Uri? {
|
||||
val uri = when (action) {
|
||||
Intent.ACTION_VIEW -> data
|
||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
else -> null
|
||||
} ?: return null
|
||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||
}
|
||||
|
||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
||||
|
||||
@@ -18,11 +18,15 @@ 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.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
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 +52,26 @@ 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>
|
||||
|
||||
/**
|
||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||
* dedup an `.ics` import so re-importing a backup doesn't double events.
|
||||
*/
|
||||
fun existingUids(calendarId: Long): Set<String>
|
||||
|
||||
/**
|
||||
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||
* minting one when absent); returns the new `Events._ID`. Reminders are
|
||||
* written as the file's raw lead minutes (METHOD_ALERT).
|
||||
*/
|
||||
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
|
||||
|
||||
/**
|
||||
* 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 +289,112 @@ 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()
|
||||
}
|
||||
|
||||
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Events.UID_2445),
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||
arrayOf(calendarId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
||||
} ?: emptySet()
|
||||
|
||||
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||
val startMillis = event.start.toEpochMillis()
|
||||
val endMillis = event.end.toEpochMillis()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||
// only when the source event carried none.
|
||||
put(
|
||||
CalendarContract.Events.UID_2445,
|
||||
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||
)
|
||||
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, startMillis)
|
||||
if (event.recurrenceRule == null) {
|
||||
put(CalendarContract.Events.DTEND, endMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
importDuration(startMillis, endMillis, event.isAllDay),
|
||||
)
|
||||
}
|
||||
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||
// timed rows keep the event's own zone.
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
}
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||
val eventId = ContentUris.parseId(uri)
|
||||
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
|
||||
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
||||
}
|
||||
}
|
||||
return eventId
|
||||
}
|
||||
|
||||
/** Provider DURATION for an imported recurring row: whole days / seconds. */
|
||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
|
||||
}
|
||||
|
||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||
}
|
||||
|
||||
/** 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 +446,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,9 @@ 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 de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -28,6 +31,19 @@ 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>
|
||||
|
||||
/**
|
||||
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||
* UID already exists in the target are skipped (idempotent restore); the
|
||||
* rest are inserted. See [CalendarDataSource.insertImportedEvent].
|
||||
*/
|
||||
suspend fun importEvents(targetCalendarId: Long, events: List<ParsedIcsEvent>): IcsImportSummary
|
||||
|
||||
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||
suspend fun createEvent(form: EventForm): Long
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ 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.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -99,6 +101,28 @@ 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 importEvents(
|
||||
targetCalendarId: Long,
|
||||
events: List<ParsedIcsEvent>,
|
||||
): IcsImportSummary = withContext(io) {
|
||||
val existing = dataSource.existingUids(targetCalendarId)
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
for (event in events) {
|
||||
// A known UID means the event is already in this calendar — skip,
|
||||
// keeping a restore idempotent (no overwrite this pass).
|
||||
if (event.uid != null && event.uid in existing) {
|
||||
skipped++
|
||||
} else {
|
||||
dataSource.insertImportedEvent(event, targetCalendarId)
|
||||
imported++
|
||||
}
|
||||
}
|
||||
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
||||
}
|
||||
|
||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,21 @@
|
||||
package de.jeanlucmakiola.calendula.data.ics
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Android IO edge of `.ics` import: reads the text of a received/opened
|
||||
* document [Uri]. Parsing is the pure `domain.ics.IcsParser`; this class only
|
||||
* pulls bytes off the ContentResolver. Returns null on any read failure.
|
||||
*/
|
||||
@Singleton
|
||||
class IcsImporter @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
fun readText(uri: Uri): String? = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -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,40 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
// 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`, `-PT15M`, `P1W`, …),
|
||||
* sign-aware. Calendula writes `P<days>D` / `P<seconds>S`, but the parser also
|
||||
* accepts the general RFC 5545 grammar so backup `DURATION` rows and foreign
|
||||
* `VALARM` triggers round-trip. Unparseable input is treated as zero.
|
||||
*/
|
||||
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
|
||||
@@ -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,259 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||
* how the writer encoded them.
|
||||
*/
|
||||
data class ParsedIcsEvent(
|
||||
val uid: String?,
|
||||
val summary: String,
|
||||
val start: Instant,
|
||||
val end: Instant,
|
||||
val isAllDay: Boolean,
|
||||
val zoneId: String,
|
||||
val recurrenceRule: String? = null,
|
||||
val location: String? = null,
|
||||
val description: String? = null,
|
||||
val reminderMinutes: List<Int> = emptyList(),
|
||||
val status: EventStatus = EventStatus.Confirmed,
|
||||
val availability: Availability = Availability.Busy,
|
||||
val calendarName: String? = null,
|
||||
)
|
||||
|
||||
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||
enum class IcsParseWarning {
|
||||
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||
ModifiedOccurrenceSkipped,
|
||||
|
||||
/** A `VEVENT` with no parseable `DTSTART`. */
|
||||
EventWithoutStartSkipped,
|
||||
|
||||
/** `ATTENDEE` rows were present; Calendula doesn't import attendees. */
|
||||
AttendeesIgnored,
|
||||
|
||||
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||
UnknownTimezone,
|
||||
}
|
||||
|
||||
data class IcsParseResult(
|
||||
val events: List<ParsedIcsEvent>,
|
||||
val warnings: Set<IcsParseWarning>,
|
||||
)
|
||||
|
||||
/** Outcome of a bulk `.ics` import into one calendar. */
|
||||
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||
|
||||
/**
|
||||
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||
* JVM-testable. Liberal-in/strict-out: unknown properties are ignored, a single
|
||||
* malformed `VEVENT` is skipped (not fatal), and unsupported constructs
|
||||
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||
*/
|
||||
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||
|
||||
fun parse(text: String): IcsParseResult {
|
||||
val lines = unfoldLines(text)
|
||||
val events = mutableListOf<ParsedIcsEvent>()
|
||||
val warnings = mutableSetOf<IcsParseWarning>()
|
||||
var calendarName: String? = null
|
||||
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
val line = parseContentLine(lines[i])
|
||||
if (line == null) { i++; continue }
|
||||
when {
|
||||
line.isBegin("VEVENT") -> {
|
||||
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
||||
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
||||
?.let(events::add)
|
||||
i = end + 1
|
||||
}
|
||||
line.isBegin("VTIMEZONE") -> {
|
||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||
}
|
||||
line.name == "X-WR-CALNAME" -> {
|
||||
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
||||
i++
|
||||
}
|
||||
else -> i++
|
||||
}
|
||||
}
|
||||
return IcsParseResult(events, warnings)
|
||||
}
|
||||
|
||||
private fun parseVevent(
|
||||
body: List<String>,
|
||||
fileCalendarName: String?,
|
||||
warnings: MutableSet<IcsParseWarning>,
|
||||
): ParsedIcsEvent? {
|
||||
var uid: String? = null
|
||||
var summary = ""
|
||||
var dtStart: IcsDateTime? = null
|
||||
var dtEnd: IcsDateTime? = null
|
||||
var duration: String? = null
|
||||
var rrule: String? = null
|
||||
var location: String? = null
|
||||
var description: String? = null
|
||||
var status = EventStatus.Confirmed
|
||||
var availability = Availability.Busy
|
||||
var calendarName = fileCalendarName
|
||||
val reminders = mutableListOf<Int>()
|
||||
var skipAsOverride = false
|
||||
|
||||
var i = 0
|
||||
while (i < body.size) {
|
||||
val line = parseContentLine(body[i])
|
||||
if (line == null) { i++; continue }
|
||||
when (line.name) {
|
||||
"BEGIN" -> if (line.value.trim().equals("VALARM", true)) {
|
||||
val end = indexOfEnd(body, i + 1, "VALARM")
|
||||
parseAlarmMinutes(body.subList(i + 1, end))?.let(reminders::add)
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||
"SUMMARY" -> summary = unescapeText(line.value)
|
||||
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||
"DURATION" -> duration = line.value.trim()
|
||||
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||
"STATUS" -> status = mapIcsStatus(line.value)
|
||||
"TRANSP" -> availability =
|
||||
if (line.value.trim().equals("TRANSPARENT", true)) Availability.Free
|
||||
else Availability.Busy
|
||||
"RECURRENCE-ID" -> skipAsOverride = true
|
||||
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||
"X-CALENDULA-CALENDAR" ->
|
||||
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if (skipAsOverride) {
|
||||
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||
return null
|
||||
}
|
||||
val start = dtStart ?: run {
|
||||
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||
return null
|
||||
}
|
||||
val end = dtEnd
|
||||
?: duration?.let {
|
||||
start.copy(
|
||||
instant = Instant.fromEpochMilliseconds(
|
||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||
),
|
||||
)
|
||||
}
|
||||
?: start
|
||||
return ParsedIcsEvent(
|
||||
uid = uid,
|
||||
summary = summary,
|
||||
start = start.instant,
|
||||
end = end.instant,
|
||||
isAllDay = start.isAllDay,
|
||||
zoneId = start.zoneId,
|
||||
recurrenceRule = rrule,
|
||||
location = location,
|
||||
description = description,
|
||||
reminderMinutes = reminders.distinct(),
|
||||
status = status,
|
||||
availability = availability,
|
||||
calendarName = calendarName,
|
||||
)
|
||||
}
|
||||
|
||||
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
||||
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||
val trigger = body.asSequence()
|
||||
.mapNotNull { parseContentLine(it) }
|
||||
.firstOrNull { it.name == "TRIGGER" }
|
||||
?: return null
|
||||
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||
// Negative = before start (the normal case) → positive lead minutes.
|
||||
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
||||
}
|
||||
|
||||
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||
val raw = line.value.trim()
|
||||
val isDate = line.params["VALUE"].equals("DATE", true) ||
|
||||
(raw.length == 8 && !raw.contains('T'))
|
||||
if (isDate) {
|
||||
val date = parseBasicDate(raw) ?: return null
|
||||
return IcsDateTime(date.atStartOfDayIn(TimeZone.UTC), isAllDay = true, zoneId = "UTC")
|
||||
}
|
||||
val isUtc = raw.endsWith("Z")
|
||||
val ldt = parseBasicDateTime(raw.removeSuffix("Z")) ?: return null
|
||||
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
|
||||
|
||||
val tzid = line.params["TZID"]
|
||||
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
|
||||
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
|
||||
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
|
||||
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
|
||||
}
|
||||
|
||||
private data class IcsDateTime(val instant: Instant, val isAllDay: Boolean, val zoneId: String)
|
||||
|
||||
private companion object {
|
||||
fun IcsContentLine.isBegin(component: String) =
|
||||
name == "BEGIN" && value.trim().equals(component, true)
|
||||
|
||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||
var i = from
|
||||
while (i < lines.size) {
|
||||
val line = parseContentLine(lines[i])
|
||||
if (line != null && line.name == "END" &&
|
||||
line.value.trim().equals(component, true)
|
||||
) {
|
||||
return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
return lines.size
|
||||
}
|
||||
|
||||
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
|
||||
"TENTATIVE" -> EventStatus.Tentative
|
||||
"CANCELLED" -> EventStatus.Cancelled
|
||||
else -> EventStatus.Confirmed
|
||||
}
|
||||
|
||||
fun parseBasicDate(s: String): LocalDate? = runCatching {
|
||||
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
|
||||
}.getOrNull()
|
||||
|
||||
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
|
||||
val date = LocalDate(
|
||||
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
|
||||
)
|
||||
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
|
||||
val time = LocalTime(
|
||||
s.substring(9, 11).toInt(),
|
||||
s.substring(11, 13).toInt(),
|
||||
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
|
||||
)
|
||||
LocalDateTime(date, time)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse of [escapeText]: turn `\n`/`\N` into newlines and unescape `\\`, `\;`,
|
||||
* `\,`. A backslash before any other character is dropped, keeping the
|
||||
* character (lenient — foreign files escape liberally).
|
||||
*/
|
||||
fun unescapeText(value: String): String = buildString(value.length) {
|
||||
var i = 0
|
||||
while (i < value.length) {
|
||||
val c = value[i]
|
||||
if (c == '\\' && i + 1 < value.length) {
|
||||
when (val next = value[i + 1]) {
|
||||
'n', 'N' -> append('\n')
|
||||
else -> append(next) // \\, \;, \, and any other escaped char
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse of [foldLine] across a whole document: split into physical lines on
|
||||
* CRLF/LF/CR, then re-join any line that begins with a single space or tab onto
|
||||
* the previous one (RFC 5545 unfolding). Returns the logical content lines.
|
||||
*/
|
||||
fun unfoldLines(text: String): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
for (physical in text.split("\r\n", "\n", "\r")) {
|
||||
if (physical.isEmpty()) continue
|
||||
val isContinuation = physical[0] == ' ' || physical[0] == '\t'
|
||||
if (isContinuation && out.isNotEmpty()) {
|
||||
out[out.lastIndex] = out.last() + physical.substring(1)
|
||||
} else {
|
||||
out.add(physical)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* One unfolded content line split into its property name, parameters and value:
|
||||
* `SUMMARY;LANGUAGE=en:Lunch` → name `SUMMARY`, params `{LANGUAGE=en}`, value
|
||||
* `Lunch`. The value is everything after the first colon that isn't inside a
|
||||
* quoted parameter; param keys are upper-cased, quoted param values unquoted.
|
||||
* Returns null for a line with no colon.
|
||||
*/
|
||||
data class IcsContentLine(val name: String, val params: Map<String, String>, val value: String)
|
||||
|
||||
fun parseContentLine(line: String): IcsContentLine? {
|
||||
var inQuote = false
|
||||
var colon = -1
|
||||
for (i in line.indices) {
|
||||
when (line[i]) {
|
||||
'"' -> inQuote = !inQuote
|
||||
':' -> if (!inQuote) { colon = i; break }
|
||||
}
|
||||
}
|
||||
if (colon < 0) return null
|
||||
val head = splitUnquoted(line.substring(0, colon), ';')
|
||||
val name = head.firstOrNull()?.trim()?.uppercase().orEmpty()
|
||||
if (name.isEmpty()) return null
|
||||
val params = buildMap {
|
||||
for (part in head.drop(1)) {
|
||||
val eq = part.indexOf('=')
|
||||
if (eq > 0) {
|
||||
put(
|
||||
part.substring(0, eq).trim().uppercase(),
|
||||
part.substring(eq + 1).trim().removeSurrounding("\""),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return IcsContentLine(name, params, line.substring(colon + 1))
|
||||
}
|
||||
|
||||
/** Split on [delimiter] except where it falls inside a double-quoted run. */
|
||||
private fun splitUnquoted(text: String, delimiter: Char): List<String> {
|
||||
val parts = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var inQuote = false
|
||||
for (c in text) {
|
||||
when {
|
||||
c == '"' -> { inQuote = !inQuote; current.append(c) }
|
||||
c == delimiter && !inQuote -> { parts.add(current.toString()); current.clear() }
|
||||
else -> current.append(c)
|
||||
}
|
||||
}
|
||||
parts.add(current.toString())
|
||||
return parts
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
/**
|
||||
* Prefill the create form from a single parsed `.ics` event (the "open one
|
||||
* event" path). [calendarId] is left null so the form preselects the last-used
|
||||
* calendar, exactly like a fresh create — the user confirms the target and
|
||||
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||
*/
|
||||
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
val (start, end) = if (isAllDay) {
|
||||
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
|
||||
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
} else {
|
||||
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = null,
|
||||
title = summary,
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
location = location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = reminderMinutes.distinct().sorted(),
|
||||
availability = availability,
|
||||
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||
@@ -23,6 +24,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||
@@ -48,6 +50,8 @@ fun CalendarHost(
|
||||
onDetailKeyConsumed: () -> Unit = {},
|
||||
widgetNavRequest: WidgetNavRequest? = null,
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
) {
|
||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||
@@ -121,6 +125,18 @@ fun CalendarHost(
|
||||
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
||||
|
||||
// An opened/received .ics file. [ImportScreen] parses it and either opens
|
||||
// the prefilled create form (one event → [importForm]) or its own bulk
|
||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||
LaunchedEffect(requestedImportUri) {
|
||||
if (requestedImportUri != null) {
|
||||
importUri = requestedImportUri
|
||||
onImportConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
||||
LaunchedEffect(widgetNavRequest) {
|
||||
@@ -254,5 +270,26 @@ fun CalendarHost(
|
||||
) {
|
||||
CalendarsScreen(onBack = { showCalendars = false })
|
||||
}
|
||||
|
||||
// Import flow for an opened/received .ics file. A single event routes
|
||||
// into the create form (prefilled, for review); many open the picker.
|
||||
importUri?.let { uri ->
|
||||
ImportScreen(
|
||||
uri = uri,
|
||||
onClose = { importUri = null },
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
importForm = form
|
||||
},
|
||||
)
|
||||
}
|
||||
importForm?.let { form ->
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
initialForm = form,
|
||||
onClose = { importForm = null },
|
||||
onSaved = { importForm = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ fun RootScreen(
|
||||
onDetailKeyConsumed: () -> Unit = {},
|
||||
widgetNavRequest: WidgetNavRequest? = null,
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -62,6 +64,8 @@ fun RootScreen(
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
|
||||
@@ -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,
|
||||
@@ -743,14 +776,19 @@ private fun formatWhen(
|
||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||
|
||||
if (instance.isAllDay) {
|
||||
// All-day end is the exclusive next midnight; step back to the last
|
||||
// covered day so a one-day event reads as a single date.
|
||||
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
|
||||
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
|
||||
allDayLabel to dateFull.format(startLdt.toLocalDate())
|
||||
// All-day events live at UTC midnights with an exclusive end. Resolve
|
||||
// the covered dates in UTC — not the device zone, which would shift the
|
||||
// midnight boundaries off the intended date (east of UTC pushes the
|
||||
// end past the last day; west of UTC pulls the start back) — and step
|
||||
// the end back to the last covered day so a one-day event reads as a
|
||||
// single date.
|
||||
val utc = ZoneId.of("UTC")
|
||||
val startDate = instance.start.toJavaLocalDateTime(utc).toLocalDate()
|
||||
val lastDate = (instance.end - 1.seconds).toJavaLocalDateTime(utc).toLocalDate()
|
||||
return if (startDate == lastDate) {
|
||||
allDayLabel to dateFull.format(startDate)
|
||||
} else {
|
||||
allDayLabel to
|
||||
"${dateMedium.format(startLdt.toLocalDate())} – ${dateMedium.format(lastLdt.toLocalDate())}"
|
||||
allDayLabel to "${dateMedium.format(startDate)} – ${dateMedium.format(lastDate)}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||
@@ -156,19 +157,23 @@ fun EventEditScreen(
|
||||
onSaved: () -> Unit,
|
||||
editKey: LongArray? = null,
|
||||
initialStartMinutes: Int? = null,
|
||||
initialForm: EventForm? = null,
|
||||
viewModel: EventEditViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(initialDateIso, editKey) {
|
||||
if (editKey != null) {
|
||||
viewModel.openForEdit(
|
||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||
when {
|
||||
// Single-event .ics open: the form arrives prefilled for review.
|
||||
initialForm != null -> viewModel.openImported(initialForm)
|
||||
editKey != null -> viewModel.openForEdit(
|
||||
eventId = editKey[0],
|
||||
beginMillis = editKey[1],
|
||||
endMillis = editKey[2],
|
||||
)
|
||||
} else {
|
||||
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
viewModel.openNew(date, initialStartMinutes)
|
||||
else -> {
|
||||
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
viewModel.openNew(date, initialStartMinutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -210,6 +210,21 @@ class EventEditViewModel @Inject constructor(
|
||||
applyDefaultReminder()
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
||||
* the create form" path). [form] already carries the file's fields; its
|
||||
* [EventForm.calendarId] is null so the calendar still resolves to the
|
||||
* last-used/first-writable one, and reminders are frozen as touched so the
|
||||
* settings default never overwrites what the file specified. No-op when a
|
||||
* form is already open, so the prefill survives configuration changes.
|
||||
*/
|
||||
fun openImported(form: EventForm) {
|
||||
if (_form.value != null || _editTarget.value != null) return
|
||||
_remindersTouched.value = true
|
||||
_revealed.value = form.populatedFields()
|
||||
_form.value = form
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefill a new event's reminders from the settings default — the all-day
|
||||
* default for all-day events, otherwise the resolved calendar's per-calendar
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package de.jeanlucmakiola.calendula.ui.imports
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
|
||||
/**
|
||||
* Handles an opened/received `.ics` file. A single event is handed straight to
|
||||
* the prefilled create form via [onOpenSingle]; several events show a target-
|
||||
* calendar picker and import in bulk (dedup by UID), then a result summary.
|
||||
* Empty/failed files show a short message and close.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ImportScreen(
|
||||
uri: Uri,
|
||||
onClose: () -> Unit,
|
||||
onOpenSingle: (EventForm) -> Unit,
|
||||
viewModel: ImportViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(uri) { viewModel.load(uri) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
BackHandler(onBack = onClose)
|
||||
|
||||
// A single event isn't shown here — it opens the create form for review.
|
||||
LaunchedEffect(state) {
|
||||
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.import_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||
when (val s = state) {
|
||||
ImportUiState.Loading,
|
||||
ImportUiState.Importing,
|
||||
is ImportUiState.Single,
|
||||
-> CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
|
||||
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
|
||||
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
|
||||
// No writable calendar to import into — tell the user honestly.
|
||||
if (state.calendars.isEmpty()) {
|
||||
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
||||
return
|
||||
}
|
||||
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.import_target_header),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
state.calendars.forEach { calendar ->
|
||||
OptionCard(
|
||||
label = calendar.displayName,
|
||||
onClick = { selected = calendar.id },
|
||||
selected = calendar.id == selected,
|
||||
icon = null,
|
||||
)
|
||||
}
|
||||
state.warnings.forEach { WarningText(it) }
|
||||
Button(
|
||||
onClick = { onImport(selected) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) {
|
||||
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.import_done_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.import_done_imported,
|
||||
state.summary.imported,
|
||||
state.summary.imported,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
if (state.summary.skippedDuplicate > 0) {
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.import_done_skipped,
|
||||
state.summary.skippedDuplicate,
|
||||
state.summary.skippedDuplicate,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(stringResource(R.string.import_close))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WarningText(warning: IcsParseWarning) {
|
||||
val text = when (warning) {
|
||||
IcsParseWarning.ModifiedOccurrenceSkipped -> stringResource(R.string.import_warning_recurrence)
|
||||
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
||||
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
||||
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(message, style = MaterialTheme.typography.bodyLarge)
|
||||
if (onClose != null) {
|
||||
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.jeanlucmakiola.calendula.ui.imports
|
||||
|
||||
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.IcsImporter
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import javax.inject.Inject
|
||||
|
||||
/** What an opened/received `.ics` resolved to. */
|
||||
sealed interface ImportUiState {
|
||||
data object Loading : ImportUiState
|
||||
data object Importing : ImportUiState
|
||||
|
||||
/** The file held no importable event (or couldn't be read/parsed as one). */
|
||||
data object Empty : ImportUiState
|
||||
data object Failed : ImportUiState
|
||||
|
||||
/** Exactly one event → review it in the prefilled create form. */
|
||||
data class Single(val form: EventForm, val warnings: Set<IcsParseWarning>) : ImportUiState
|
||||
|
||||
/** Several events → pick a target calendar and bulk-import. */
|
||||
data class Many(
|
||||
val events: List<ParsedIcsEvent>,
|
||||
val warnings: Set<IcsParseWarning>,
|
||||
val calendars: List<CalendarSource>,
|
||||
) : ImportUiState
|
||||
|
||||
data class Done(val summary: IcsImportSummary) : ImportUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an `.ics` [Uri], parses it (pure [IcsParser]) and routes by event count:
|
||||
* one event opens the create form for review, many open the bulk-import picker.
|
||||
* The bulk import dedups by UID in the repository.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ImportViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val importer: IcsImporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
private val parser = IcsParser()
|
||||
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
||||
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||
private var started = false
|
||||
|
||||
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
|
||||
fun load(uri: Uri) {
|
||||
if (started) return
|
||||
started = true
|
||||
viewModelScope.launch {
|
||||
val parsed = withContext(io) {
|
||||
importer.readText(uri)?.let(parser::parse)
|
||||
}
|
||||
_state.value = when {
|
||||
parsed == null -> ImportUiState.Failed
|
||||
parsed.events.isEmpty() -> ImportUiState.Empty
|
||||
parsed.events.size == 1 -> ImportUiState.Single(
|
||||
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
||||
warnings = parsed.warnings,
|
||||
)
|
||||
else -> ImportUiState.Many(
|
||||
events = parsed.events,
|
||||
warnings = parsed.warnings,
|
||||
calendars = repository.calendars().first().filter { it.canModifyContents },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
|
||||
fun import(targetCalendarId: Long) {
|
||||
val many = _state.value as? ImportUiState.Many ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = ImportUiState.Importing
|
||||
_state.value = try {
|
||||
ImportUiState.Done(repository.importEvents(targetCalendarId, many.events))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
ImportUiState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,18 @@ internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
||||
|
||||
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
|
||||
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
||||
if (isAllDay) {
|
||||
// All-day events live at UTC midnights with an exclusive end. Compare
|
||||
// calendar dates in UTC and step the exclusive end back to the last
|
||||
// covered day (mirroring the detail/edit views), so a one-day event
|
||||
// covers exactly its single date. Slicing the day in the device zone
|
||||
// would push the exclusive end a few hours into the next local day
|
||||
// east of UTC, making the event leak onto day + 1.
|
||||
val startDate = start.toLocalDateTime(TimeZone.UTC).date
|
||||
val endExclusive = end.toLocalDateTime(TimeZone.UTC).date
|
||||
val lastDay = maxOf(startDate, endExclusive.minus(1, DateTimeUnit.DAY))
|
||||
return day in startDate..lastDay
|
||||
}
|
||||
val dayStart = day.atStartOfDayIn(zone)
|
||||
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||
return start < dayEnd && end > dayStart
|
||||
|
||||
@@ -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,41 @@
|
||||
<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>
|
||||
<!-- Import (.ics) -->
|
||||
<string name="import_title">Termine importieren</string>
|
||||
<string name="import_target_header">Zu Kalender hinzufügen</string>
|
||||
<string name="import_empty">In dieser Datei wurden keine Termine gefunden.</string>
|
||||
<string name="import_failed">Datei konnte nicht gelesen werden.</string>
|
||||
<string name="import_no_calendar">Kein beschreibbarer Kalender zum Importieren. Lege zuerst einen lokalen Kalender an.</string>
|
||||
<string name="import_done_title">Import abgeschlossen</string>
|
||||
<string name="import_close">Schließen</string>
|
||||
<string name="import_warning_recurrence">Einige geänderte Einzeltermine wiederkehrender Termine wurden übersprungen.</string>
|
||||
<string name="import_warning_no_start">Ein Termin ohne Startzeit wurde übersprungen.</string>
|
||||
<string name="import_warning_attendees">Gästelisten wurden nicht importiert.</string>
|
||||
<string name="import_warning_timezone">Eine unbekannte Zeitzone wurde durch die deines Geräts ersetzt.</string>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="one">%d Termin in dieser Datei.</item>
|
||||
<item quantity="other">%d Termine in dieser Datei.</item>
|
||||
</plurals>
|
||||
<plurals name="import_action">
|
||||
<item quantity="one">%d Termin importieren</item>
|
||||
<item quantity="other">%d Termine importieren</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_imported">
|
||||
<item quantity="one">%d Termin importiert.</item>
|
||||
<item quantity="other">%d Termine importiert.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_skipped">
|
||||
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
|
||||
<item quantity="other">%d bereits in diesem Kalender übersprungen.</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,43 @@
|
||||
<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>
|
||||
<!-- Import (.ics) -->
|
||||
<string name="import_title">Import events</string>
|
||||
<string name="import_target_header">Add to calendar</string>
|
||||
<string name="import_empty">No events found in this file.</string>
|
||||
<string name="import_failed">Couldn\'t read this file.</string>
|
||||
<string name="import_no_calendar">No writable calendar to import into. Create a local calendar first.</string>
|
||||
<string name="import_done_title">Import complete</string>
|
||||
<string name="import_close">Close</string>
|
||||
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
|
||||
<string name="import_warning_no_start">An event without a start time was skipped.</string>
|
||||
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
|
||||
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="one">%d event in this file.</item>
|
||||
<item quantity="other">%d events in this file.</item>
|
||||
</plurals>
|
||||
<plurals name="import_action">
|
||||
<item quantity="one">Import %d event</item>
|
||||
<item quantity="other">Import %d events</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_imported">
|
||||
<item quantity="one">Imported %d event.</item>
|
||||
<item quantity="other">Imported %d events.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_skipped">
|
||||
<item quantity="one">Skipped %d already in this calendar.</item>
|
||||
<item quantity="other">Skipped %d already in this calendar.</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>
|
||||
@@ -445,4 +445,35 @@ class CalendarRepositoryImplTest {
|
||||
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
|
||||
assertThat(repo.eventColorPalette(8L)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importEvents skips events whose UID already exists and inserts the rest`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
existingUidsResult = setOf("dup@x")
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val events = listOf(
|
||||
parsedEvent("dup@x"), // already present → skipped
|
||||
parsedEvent("new@x"), // inserted
|
||||
parsedEvent(null), // no UID → always inserted
|
||||
)
|
||||
|
||||
val summary = repo.importEvents(targetCalendarId = 3L, events = events)
|
||||
|
||||
assertThat(summary.imported).isEqualTo(2)
|
||||
assertThat(summary.skippedDuplicate).isEqualTo(1)
|
||||
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("new@x", null)
|
||||
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
|
||||
}
|
||||
|
||||
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
|
||||
uid = uid,
|
||||
summary = "E",
|
||||
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||
isAllDay = false,
|
||||
zoneId = "UTC",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ 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 de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
|
||||
/**
|
||||
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
||||
@@ -16,6 +18,9 @@ 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()
|
||||
/** UIDs the target calendar already holds, for import dedup. */
|
||||
var existingUidsResult: Set<String> = emptySet()
|
||||
/** Set to make the next write call throw. */
|
||||
var writeError: Exception? = null
|
||||
/** Id returned by the next [insertEvent]. */
|
||||
@@ -49,6 +54,18 @@ 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 existingUids(calendarId: Long): Set<String> = existingUidsResult
|
||||
|
||||
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
|
||||
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
|
||||
|
||||
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||
writeError?.let { throw it }
|
||||
importedEvents += event to calendarId
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||
writeError?.let { throw it }
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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 `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,28 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class IcsDurationTest {
|
||||
|
||||
@Test
|
||||
fun `parses the single-unit 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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `is sign-aware for before-start VALARM triggers`() {
|
||||
assertThat(parseRfc2445DurationMillis("-PT15M")).isEqualTo(-900_000L)
|
||||
assertThat(parseRfc2445DurationMillis("PT0M")).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unparseable input is zero`() {
|
||||
assertThat(parseRfc2445DurationMillis(null)).isEqualTo(0L)
|
||||
assertThat(parseRfc2445DurationMillis("")).isEqualTo(0L)
|
||||
assertThat(parseRfc2445DurationMillis("nonsense")).isEqualTo(0L)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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 IcsParserTest {
|
||||
|
||||
private val parser = IcsParser(deviceZone = TimeZone.of("Europe/Berlin"))
|
||||
private val writer = IcsWriter()
|
||||
private val stamp = LocalDateTime(2026, 6, 18, 9, 30, 0).toInstant(TimeZone.UTC)
|
||||
|
||||
private fun roundTrip(event: IcsEvent): ParsedIcsEvent {
|
||||
val text = writer.writeCalendar(listOf(event), stamp)
|
||||
val result = parser.parse(text)
|
||||
assertThat(result.events).hasSize(1)
|
||||
return result.events.single()
|
||||
}
|
||||
|
||||
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 `round-trips a timed one-off event`() {
|
||||
val event = IcsEvent(
|
||||
uid = "u1@calendula",
|
||||
summary = "Lunch; with, friends",
|
||||
start = instantUtc(2026, 6, 18, 11, 0),
|
||||
end = instantUtc(2026, 6, 18, 12, 0),
|
||||
isAllDay = false,
|
||||
zoneId = "Europe/Berlin",
|
||||
location = "Café",
|
||||
availability = Availability.Free,
|
||||
status = EventStatus.Tentative,
|
||||
)
|
||||
val parsed = roundTrip(event)
|
||||
assertThat(parsed.uid).isEqualTo("u1@calendula")
|
||||
assertThat(parsed.summary).isEqualTo("Lunch; with, friends")
|
||||
assertThat(parsed.start).isEqualTo(event.start)
|
||||
assertThat(parsed.end).isEqualTo(event.end)
|
||||
assertThat(parsed.isAllDay).isFalse()
|
||||
assertThat(parsed.location).isEqualTo("Café")
|
||||
assertThat(parsed.availability).isEqualTo(Availability.Free)
|
||||
assertThat(parsed.status).isEqualTo(EventStatus.Tentative)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trips a recurring TZID event to the same instant`() {
|
||||
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 parsed = roundTrip(event)
|
||||
assertThat(parsed.start).isEqualTo(event.start)
|
||||
assertThat(parsed.end).isEqualTo(event.end)
|
||||
assertThat(parsed.zoneId).isEqualTo("Europe/Berlin")
|
||||
assertThat(parsed.recurrenceRule).isEqualTo("FREQ=WEEKLY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trips an all-day event`() {
|
||||
val event = IcsEvent(
|
||||
uid = "u3@calendula",
|
||||
summary = "Holiday",
|
||||
start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC),
|
||||
end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
zoneId = "UTC",
|
||||
)
|
||||
val parsed = roundTrip(event)
|
||||
assertThat(parsed.isAllDay).isTrue()
|
||||
assertThat(parsed.start).isEqualTo(event.start)
|
||||
assertThat(parsed.end).isEqualTo(event.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trips reminders as before-start lead minutes`() {
|
||||
val event = IcsEvent(
|
||||
uid = "u4@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),
|
||||
)
|
||||
val parsed = roundTrip(event)
|
||||
assertThat(parsed.reminderMinutes).containsExactly(15, 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tolerates folded lines and a missing UID`() {
|
||||
val ics = buildString {
|
||||
append("BEGIN:VCALENDAR\r\n")
|
||||
append("VERSION:2.0\r\n")
|
||||
append("BEGIN:VEVENT\r\n")
|
||||
// Folded DESCRIPTION (continuation line begins with a space).
|
||||
append("DESCRIPTION:This is a long descriptio\r\n n that was folded\r\n")
|
||||
append("SUMMARY:No UID here\r\n")
|
||||
append("DTSTART:20260618T090000Z\r\n")
|
||||
append("DTEND:20260618T100000Z\r\n")
|
||||
append("END:VEVENT\r\n")
|
||||
append("END:VCALENDAR\r\n")
|
||||
}
|
||||
val parsed = parser.parse(ics).events.single()
|
||||
assertThat(parsed.uid).isNull()
|
||||
assertThat(parsed.description).isEqualTo("This is a long description that was folded")
|
||||
assertThat(parsed.summary).isEqualTo("No UID here")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips a RECURRENCE-ID override and reports it`() {
|
||||
val ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:x\r\n" +
|
||||
"RECURRENCE-ID:20260618T090000Z\r\nDTSTART:20260618T090000Z\r\n" +
|
||||
"SUMMARY:Override\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
val result = parser.parse(ics)
|
||||
assertThat(result.events).isEmpty()
|
||||
assertThat(result.warnings).contains(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports ignored attendees but still imports the event`() {
|
||||
val ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:y\r\n" +
|
||||
"DTSTART:20260618T090000Z\r\nSUMMARY:Has guests\r\n" +
|
||||
"ATTENDEE;CN=Bob:mailto:bob@example.com\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
val result = parser.parse(ics)
|
||||
assertThat(result.events).hasSize(1)
|
||||
assertThat(result.warnings).contains(IcsParseWarning.AttendeesIgnored)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses multiple events and carries the calendar name`() {
|
||||
val events = listOf(
|
||||
IcsEvent("a@c", "One", instantUtc(2026, 6, 18, 9, 0), instantUtc(2026, 6, 18, 10, 0),
|
||||
false, "UTC", calendarName = "Personal"),
|
||||
IcsEvent("b@c", "Two", instantUtc(2026, 6, 19, 9, 0), instantUtc(2026, 6, 19, 10, 0),
|
||||
false, "UTC", calendarName = "Personal"),
|
||||
)
|
||||
val text = writer.writeCalendar(events, stamp)
|
||||
val result = parser.parse(text)
|
||||
assertThat(result.events).hasSize(2)
|
||||
assertThat(result.events.map { it.calendarName }).containsExactly("Personal", "Personal")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed event does not sink the rest of the file`() {
|
||||
// First VEVENT has no DTSTART (skipped); second is valid.
|
||||
val ics = "BEGIN:VCALENDAR\r\n" +
|
||||
"BEGIN:VEVENT\r\nUID:bad\r\nSUMMARY:No start\r\nEND:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\nUID:good\r\nDTSTART:20260618T090000Z\r\nSUMMARY:Fine\r\nEND:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
val result = parser.parse(ics)
|
||||
assertThat(result.events.map { it.uid }).containsExactly("good")
|
||||
assertThat(result.warnings).contains(IcsParseWarning.EventWithoutStartSkipped)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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
|
||||
|
||||
class ParsedIcsFormTest {
|
||||
|
||||
private val berlin = TimeZone.of("Europe/Berlin")
|
||||
|
||||
@Test
|
||||
fun `timed event maps to wall-clock form times in the device zone`() {
|
||||
val event = ParsedIcsEvent(
|
||||
uid = "u@x",
|
||||
summary = "Call",
|
||||
start = LocalDateTime(2026, 6, 18, 13, 0, 0).toInstant(TimeZone.UTC),
|
||||
end = LocalDateTime(2026, 6, 18, 14, 0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = false,
|
||||
zoneId = "UTC",
|
||||
reminderMinutes = listOf(10, 10, 5),
|
||||
recurrenceRule = "FREQ=WEEKLY",
|
||||
)
|
||||
val form = event.toEventForm(berlin)
|
||||
|
||||
assertThat(form.calendarId).isNull()
|
||||
assertThat(form.title).isEqualTo("Call")
|
||||
// 13:00 UTC == 15:00 Berlin (summer).
|
||||
assertThat(form.start).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
|
||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
|
||||
assertThat(form.reminders).containsExactly(5, 10).inOrder()
|
||||
assertThat(form.rrule).isEqualTo("FREQ=WEEKLY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event shows the last covered day, not the exclusive end`() {
|
||||
val event = ParsedIcsEvent(
|
||||
uid = null,
|
||||
summary = "Trip",
|
||||
start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC),
|
||||
end = LocalDate(2026, 6, 20).atStartOfDayIn(TimeZone.UTC), // exclusive
|
||||
isAllDay = true,
|
||||
zoneId = "UTC",
|
||||
)
|
||||
val form = event.toEventForm(berlin)
|
||||
|
||||
assertThat(form.isAllDay).isTrue()
|
||||
assertThat(form.start.date).isEqualTo(LocalDate(2026, 6, 18))
|
||||
assertThat(form.end.date).isEqualTo(LocalDate(2026, 6, 19)) // last covered day
|
||||
}
|
||||
}
|
||||
@@ -62,13 +62,28 @@ class WeekLayoutTest {
|
||||
assertThat(ev.coversDay(wed, zone)).isTrue()
|
||||
assertThat(ev.coversDay(mon, zone)).isFalse()
|
||||
|
||||
val multiDay = event(at(mon, 22), at(wed, 2), allDay = true)
|
||||
// All-day: UTC midnights, end exclusive. Mon..Tue covers Mon and Tue
|
||||
// but not Wed (the Wed-midnight end is exclusive).
|
||||
val multiDay = event(at(mon, 0), at(wed, 0), allDay = true)
|
||||
assertThat(multiDay.coversDay(mon, zone)).isTrue()
|
||||
assertThat(multiDay.coversDay(LocalDate(2026, 6, 9), zone)).isTrue()
|
||||
assertThat(multiDay.coversDay(wed, zone)).isTrue()
|
||||
assertThat(multiDay.coversDay(wed, zone)).isFalse()
|
||||
assertThat(multiDay.coversDay(LocalDate(2026, 6, 12), zone)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single-day all-day event does not leak into the next day east of UTC`() {
|
||||
// A birthday on Wed: the provider stores UTC midnights with an exclusive
|
||||
// end (Thu 00:00 UTC). In a zone east of UTC the device-local day must
|
||||
// still resolve to Wed only — never Thu. Regression for the all-day
|
||||
// event appearing on two days in the views.
|
||||
val berlin = TimeZone.of("Europe/Berlin") // UTC+2 in June
|
||||
val ev = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true)
|
||||
assertThat(ev.coversDay(wed, berlin)).isTrue()
|
||||
assertThat(ev.coversDay(wed.plusDays(1), berlin)).isFalse()
|
||||
assertThat(ev.coversDay(wed.plusDays(-1), berlin)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single timed event gets one lane`() {
|
||||
val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone)
|
||||
|
||||
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.
|
||||
122
docs/superpowers/plans/2026-06-18-06-ics-import.md
Normal file
122
docs/superpowers/plans/2026-06-18-06-ics-import.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Calendula - Plan 06: ICS Import (v2.7, Branch 2 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 Lese-Hälfte des `.ics`-Themas, aufgesetzt auf den Writer aus
|
||||
Branch 1 (`feat/ics-export`, gemerged in `release/v2.7.0`). Calendula parst
|
||||
RFC-5545-Dateien und führt sie über zwei Wege ein: eine einzelne `VEVENT` öffnet
|
||||
das vorausgefüllte Erstellen-Formular (Review vor dem Speichern), eine Datei mit
|
||||
vielen Events geht in einen Bulk-Import mit Ziel-Kalender-Auswahl und
|
||||
Ergebnis-Report. Damit schließt sich der Backup→Restore-Kreis für lokale
|
||||
Kalender. Beide Branches landen in **einem** Release v2.7.0.
|
||||
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
|
||||
On-Device-Review.
|
||||
|
||||
**Architecture:** `IcsParser` lebt rein in `domain/ics/` neben `IcsWriter` —
|
||||
kein Android, JVM-testbar, symmetrisch zum Writer. Ausgabe ist ein
|
||||
`IcsParseResult` (`events: List<ParsedIcsEvent>` + `warnings: List<String>`).
|
||||
`ParsedIcsEvent` ist die Parse-Variante von `IcsEvent` (gleiche Felder, aber
|
||||
`uid: String?` — eine eingelesene `VEVENT` kann ohne UID kommen). Zwei Adapter:
|
||||
`ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`) und eine
|
||||
Repository-Methode `importEvents(targetCalendarId, events)` → `ImportSummary`
|
||||
(Bulk). Datei-IO (`Uri` lesen, Intent) liegt in `data/ics/` bzw. der
|
||||
Activity/Compose-Schicht; Routing 1-vs-viele entscheidet anhand der geparsten
|
||||
Event-Anzahl.
|
||||
|
||||
**Liberal-in/strict-out (Leitprinzip):** unbekannte Properties, fremde
|
||||
`VTIMEZONE`-Blöcke und `RECURRENCE-ID`-Ausnahmen werden **übersprungen und im
|
||||
Report vermerkt**, nie still verschluckt; ein einzelnes kaputtes `VEVENT` lässt
|
||||
den Rest der Datei durch.
|
||||
|
||||
**Leitentscheidungen:**
|
||||
|
||||
1. **Parse-Mechanik (Umkehr von `IcsText`):** zuerst **Unfolding** (CRLF +
|
||||
Space/Tab → wegfalten), dann pro Zeile `NAME[;params]:value` zerlegen,
|
||||
TEXT-Werte **unescapen** (`\\` `\;` `\,` `\n`). Eine reine, einzeln getestete
|
||||
Schicht (`IcsLineParser`), nicht ad hoc im Walker.
|
||||
2. **Datum/Zeit-Parsing (Umkehr der Writer-Zeitzonenregel):**
|
||||
- `VALUE=DATE` (`YYYYMMDD`) → all-day, Instant auf UTC-Mitternacht (wie der
|
||||
Provider all-day speichert), exklusives `DTEND` bleibt exklusiv.
|
||||
- `…T…Z` → UTC-Instant.
|
||||
- `…T…` mit `TZID=<zone>` → lokale Wandzeit in der Zone, aufgelöst gegen die
|
||||
**OS-tz-Datenbank** (`TimeZone.of`); unbekannte/fehlende `TZID` →
|
||||
Gerätezone als Fallback (+ Warnung).
|
||||
- Kein `VTIMEZONE`-Parsing — `TZID` wird gegen die OS-DB aufgelöst (s. Branch-1
|
||||
Entscheidung); ein `VTIMEZONE`-Block wird übersprungen (Warnung nur, wenn
|
||||
seine `TZID` nicht in der OS-DB ist).
|
||||
3. **Routing 1-vs-viele:** genau **eine** `VEVENT` → vorausgefülltes
|
||||
Erstellen-Formular (`ParsedIcsEvent.toEventForm`, `calendarId=null` →
|
||||
Formular wählt wie gehabt den zuletzt genutzten Kalender vor). **Mehr als
|
||||
eine** → Bulk-Import-Screen (Ziel-Kalender-Picker, nur beschreibbare). Leere
|
||||
Datei → freundlicher „nichts gefunden"-Hinweis.
|
||||
4. **UID-Dedup beim Bulk-Import:** vor dem Insert die vorhandenen
|
||||
`Events.UID_2445` des Ziel-Kalenders lesen; eine eingelesene UID, die schon
|
||||
existiert, wird **übersprungen** (gezählt als „bereits vorhanden"). v1:
|
||||
skip-not-update — kein Überschreiben, das hält den Restore idempotent und
|
||||
verlustfrei. Events ohne UID bekommen beim Insert eine frische
|
||||
(`UUID@calendula`, wie `insertEvent`).
|
||||
5. **Empfang via Intent:** Manifest-`ACTION_VIEW`/`SEND` mit MIME `text/calendar`
|
||||
(+ `.ics`-Pfadmuster für `file`/`content`-Schemes). `MainActivity`
|
||||
(`singleTop`, wie beim Reminder-Tap) liest den `Uri`, parst, und reicht das
|
||||
Ergebnis als Compose-State an `CalendarHost` (gleiches Key-Muster wie der
|
||||
Notification-Deep-Link).
|
||||
6. **Reminder/Status/Transp zurück:** `VALARM` `TRIGGER` (negatives `-PT…`,
|
||||
`PT0…`) → Lead-Minuten; `STATUS`/`TRANSP` → `EventStatus`/`Availability`.
|
||||
`DURATION`-statt-`DTEND` über `parseRfc2445DurationMillis` (existiert aus
|
||||
Branch 1). Attendees werden **nicht** importiert (kein Modell; Warnung wenn
|
||||
vorhanden).
|
||||
|
||||
**Recherche-Befunde (Codebase, 2026-06-18 — aus Branch 1):**
|
||||
- `IcsText.escapeText`/`foldLine` + `IcsWriter` existieren; Parser spiegelt sie.
|
||||
- `parseRfc2445DurationMillis` (in `IcsExportMapper.kt`) parst die
|
||||
Provider-`DURATION`-Formen inkl. des nicht-standardkonformen `P<n>S`.
|
||||
- `EventForm` (Domain): Zeiten als `LocalDateTime` in Gerätezone, `calendarId`
|
||||
nullable; das Formular wählt bei `null` den zuletzt genutzten Kalender vor.
|
||||
- `insertEvent` schreibt bereits `Events.UID_2445`; für den Import muss eine
|
||||
**vorgegebene** UID durchgereicht werden (Insert-Variante / Parameter).
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
**Parser-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
|
||||
- [x] `IcsText`: `unescapeText` + `unfold(lines)` ergänzen (+ Test)
|
||||
- [x] `IcsLineParser`: `NAME;PARAM=v;PARAM=v:VALUE` → (name, params-map, value);
|
||||
Param-Werte ggf. in Quotes; Test (Kantenfälle: Doppelpunkt im Wert,
|
||||
gequotete Params)
|
||||
- [x] `ParsedIcsEvent` (wie `IcsEvent`, aber `uid: String?`) + Datum/Zeit-Parser
|
||||
(`VALUE=DATE` / `…Z` / `TZID` → Instant + isAllDay + zoneId)
|
||||
- [x] `IcsParser.parse(text)` → `IcsParseResult(events, warnings)`: VCALENDAR/
|
||||
VEVENT-Walk, skip-and-report für `RECURRENCE-ID`/unbekannte `VTIMEZONE`/
|
||||
Attendees; ein defektes VEVENT killt nicht den Rest. **Round-trip-Test**
|
||||
gegen `IcsWriter`-Ausgabe (all-day, getimt, wiederkehrend+TZID, Reminder)
|
||||
+ Fremd-Quirks (gefaltete Zeilen, fehlende UID, `PT…`-Trigger)
|
||||
|
||||
**Datenschicht (`data/calendar/` + `data/ics/`):**
|
||||
- [x] `ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`); Test
|
||||
- [x] Datasource: `existingUids(calendarId)` (Query `Events.UID_2445`) +
|
||||
`insertImported(event, calendarId)` (Insert mit vorgegebener/erzeugter UID)
|
||||
- [x] Repository `importEvents(targetCalendarId, events)` → `ImportSummary`
|
||||
(imported / skippedDuplicate / skippedUnsupported); UID-Dedup; Test mit
|
||||
Fake-Datasource
|
||||
- [x] `IcsImporter` (`data/ics/`): `Uri` → Text lesen (UTF-8, `contentResolver`)
|
||||
|
||||
**Intent + Routing:**
|
||||
- [x] Manifest: `ACTION_VIEW`/`ACTION_SEND`, MIME `text/calendar`, `.ics`-
|
||||
Pfadmuster (`file`/`content`); `MainActivity` parst eingehenden `Uri`
|
||||
- [x] Routing in `CalendarHost`: 1 Event → Erstellen-Formular vorausgefüllt;
|
||||
>1 → Bulk-Import-Screen; 0 → Hinweis
|
||||
|
||||
**UI:**
|
||||
- [x] Bulk-Import-Screen: Ziel-Kalender-Picker (OptionCard, nur beschreibbare),
|
||||
Anzahl/Vorschau, Import-Button → `ImportSummary` als Ergebnis
|
||||
- [x] Einzel-Öffnen: `EventEditScreen` mit vorausgefülltem Formular (neuer
|
||||
Prefill-Pfad im `EventEditViewModel`, ohne `eventId`)
|
||||
- [x] Strings DE+EN: Import-Titel, Ziel-Auswahl, Ergebnis-Plurals
|
||||
(importiert / übersprungen-vorhanden / übersprungen-nicht-unterstützt),
|
||||
leere-Datei-Hinweis
|
||||
|
||||
**Abschluss:**
|
||||
- [x] `./gradlew lint test assembleDebug` grün
|
||||
- [ ] CHANGELOG done; ROADMAP/STATE pending; v2.7 cut **erst** wenn beide
|
||||
Branches gemerged sind und On-Device-Review durch ist
|
||||
@@ -7,7 +7,7 @@ coreKtx = "1.19.0"
|
||||
appcompat = "1.7.1"
|
||||
lifecycleRuntime = "2.10.0"
|
||||
activityCompose = "1.13.0"
|
||||
composeBom = "2026.05.01"
|
||||
composeBom = "2026.06.00"
|
||||
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
|
||||
# Pin explicitly to override the BOM (which ships stable 1.4.0).
|
||||
# Re-evaluate when 1.5.0 stable lands.
|
||||
|
||||
56
renovate.json5
Normal file
56
renovate.json5
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||
|
||||
extends: [
|
||||
"config:recommended",
|
||||
// chore(deps): … — match the repo's conventional-commit style.
|
||||
":semanticCommits",
|
||||
],
|
||||
|
||||
// No automerge: a dependency bump goes through the same review (and, for
|
||||
// anything touching the build, the same on-device check) as a feature
|
||||
// before it can ride a release — see docs/RELEASING.md and the
|
||||
// "hold release for approval" rule.
|
||||
automerge: false,
|
||||
|
||||
// One reviewable surface; the dashboard issue lists everything pending.
|
||||
dependencyDashboard: true,
|
||||
labels: ["dependencies"],
|
||||
prConcurrentLimit: 5,
|
||||
prHourlyLimit: 0,
|
||||
|
||||
// Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml,
|
||||
// Mondays) — no internal `schedule` here, so the two don't double-gate and
|
||||
// silently skip a run.
|
||||
|
||||
// Gitea Actions workflows live under .gitea/workflows, not .github — extend
|
||||
// the github-actions manager (same syntax) to watch them too.
|
||||
"github-actions": {
|
||||
fileMatch: ["^\\.gitea/workflows/[^/]+\\.ya?ml$"],
|
||||
},
|
||||
|
||||
packageRules: [
|
||||
// material3 is deliberately pinned to the 1.5 *alpha* line for the
|
||||
// Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train
|
||||
// but keep it in its own PR, reviewed in isolation; revisit the pin when
|
||||
// 1.5.0 stable lands.
|
||||
{
|
||||
matchPackageNames: ["androidx.compose.material3:material3"],
|
||||
ignoreUnstable: false,
|
||||
groupName: "material3 (alpha)",
|
||||
},
|
||||
// Test-only deps: group into one low-noise PR.
|
||||
{
|
||||
matchPackageNames: [
|
||||
"org.junit.jupiter:**",
|
||||
"org.junit.platform:**",
|
||||
"com.google.truth:**",
|
||||
"app.cash.turbine:**",
|
||||
"androidx.test:**",
|
||||
"androidx.test.espresso:**",
|
||||
"androidx.test.ext:**",
|
||||
],
|
||||
groupName: "test dependencies",
|
||||
},
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user