Files
agendula/gradle/libs.versions.toml
Jean-Luc Makiola c5041d3f29 feat(export): write task lists out as iCalendar
Step 3 of docs/STORAGE-AND-SYNC.md. Now that our own provider holds the data in
the app's private storage, a Local-mode user's tasks exist in exactly one place
and uninstalling deletes them — on Play, where most people will never have a sync
engine, that is the majority case. So export is a v1 feature, not a nicety.

One .ics per list, because a list is a CalDAV collection and that is the unit
other clients understand; folding everything into one file would flatten the
lists away, and list membership is not recoverable from a VTODO afterwards.
ExportWriter can put them in a folder (ACTION_OPEN_DOCUMENT_TREE) or a single zip
(ACTION_CREATE_DOCUMENT). No storage permission either way — SAF hands us a Uri
the user picked.

Two things needed care:

Export reads the tasks table, not the instances view the rest of the app reads
from. In the instances view a recurring task appears once per occurrence with its
times resolved and no rule attached, so exporting from there would write the same
task fifty times and lose the RRULE that generated them.

And local tasks have no UID. The dmfs provider only lets a sync adapter assign
one, so in Local mode every task arrives with _uid null — and a VTODO without a
UID is both invalid and un-mergeable, meaning a re-imported backup would
duplicate every task rather than match it. ICalendarWriter synthesises one from
the row id, stable across exports and tagged so it is recognisable as synthetic.

Times go out in UTC rather than with a TZID. Emitting TZID obliges us to emit a
matching VTIMEZONE with its transition rules, and a TZID referencing an absent
definition is what actually breaks importers. All-day values keep VALUE=DATE, the
only form that survives a timezone change intact.

The writer is pure Kotlin with no Android in it and is covered by 40 tests —
line folding counted in octets and never splitting a UTF-8 sequence, TEXT
escaping, forward references from a subtask to a parent later in the file, and
CRLF endings. An export is only as good as its ability to be read back, and
nothing about a malformed .ics is obvious until someone needs the backup.

The SAF plumbing is marked in the storage doc as floret-kit material. Kept
app-local for now on the kit's own stated principle of not extracting before a
second consumer exists; the seam is in place, so moving it is a file move.

Backend only — no UI yet; that comes with the frontend pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 21:27:36 +02:00

132 lines
6.9 KiB
TOML

[versions]
agp = "9.2.1"
kotlin = "2.3.21"
ksp = "2.3.9"
hilt = "2.59.2"
coreKtx = "1.19.0"
appcompat = "1.7.1"
lifecycleRuntime = "2.10.0"
activityCompose = "1.13.0"
composeBom = "2026.05.01"
# 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.
material3 = "1.5.0-alpha21"
datastore = "1.2.1"
# SAF directory writing for export/backup (DocumentFile).
documentfile = "1.1.0"
junit = "6.1.0"
junitPlatform = "6.1.0"
truth = "1.4.5"
androidxJunit = "1.3.0"
espressoCore = "3.7.0"
kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.10.2"
turbine = "1.2.0"
hiltNavigationCompose = "1.3.0"
navigationCompose = "2.9.0"
lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
glance = "1.1.1"
# --- :provider (vendored dmfs task provider) ---------------------------------
# Versions the upstream 1.4.2 source was written against. These are its runtime
# dependencies, not ours — nothing above the data layer touches them, and they
# only move when we deliberately resync the fork. See provider/PROVENANCE.md.
dmfsJems = "1.43"
dmfsRfc5545Datetime = "0.2.4"
dmfsLibRecur = "0.12.2"
# The provider's own test suite is JUnit 4 + Robolectric, unlike the app's
# JUnit 5. Kept as upstream wrote it (rewriting ~13 test classes would forfeit
# the regression coverage that makes vendoring safe), but on current versions:
# upstream pins Robolectric 3.5.1, which predates AGP's resource handling.
robolectric = "4.16"
junit4 = "4.13.2"
hamcrest = "3.0"
mockito = "5.20.0"
[libraries]
# AndroidX core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
# Compose BOM + libs
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
# Material 3 (Expressive lives in this artifact for 1.5+)
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
# DataStore
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
# SAF — writing the export into a user-chosen folder
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
# Unit tests
junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" }
junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" }
junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatform" }
truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
# Android tests
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
# Domain time
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
# Coroutines (transitively pulled by hilt-android, pinned explicit)
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Test - Flow assertions
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
# Hilt navigation-compose (for hiltViewModel() in Composables)
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
# Navigation-compose (the NavHost / back stack)
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
# Lifecycle compose (for collectAsStateWithLifecycle)
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }
# Glance — Jetpack home-screen widgets (Compose-like RemoteViews)
androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" }
androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" }
# Android tests - GrantPermissionRule
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
# :provider — vendored dmfs task provider (see provider/PROVENANCE.md)
dmfs-jems = { group = "org.dmfs", name = "jems", version.ref = "dmfsJems" }
dmfs-jems-testing = { group = "org.dmfs", name = "jems-testing", version.ref = "dmfsJems" }
dmfs-rfc5545-datetime = { group = "org.dmfs", name = "rfc5545-datetime", version.ref = "dmfsRfc5545Datetime" }
dmfs-lib-recur = { group = "org.dmfs", name = "lib-recur", version.ref = "dmfsLibRecur" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
hamcrest = { group = "org.hamcrest", name = "hamcrest", version.ref = "hamcrest" }
mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }