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>
This commit is contained in:
2026-08-02 21:27:36 +02:00
co-authored by Claude Opus 5
parent f978c3727c
commit c5041d3f29
11 changed files with 889 additions and 0 deletions
@@ -0,0 +1,109 @@
package de.jeanlucmakiola.agendula.data.export
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import java.io.IOException
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import javax.inject.Inject
import javax.inject.Singleton
/** Where an export ended up, for the UI to report. */
data class ExportResult(val fileCount: Int, val taskListNames: List<String>)
/** The export could not be written. Carries a cause worth showing a user. */
class ExportFailedException(message: String, cause: Throwable? = null) : IOException(message, cause)
/**
* Writes [ExportDocument]s to a user-chosen location through the Storage Access
* Framework.
*
* No storage permission anywhere: SAF hands us a `Uri` the user picked
* themselves, which is both the modern approach and the only one that still works
* on scoped storage. The caller owns launching `ACTION_CREATE_DOCUMENT` (for
* [writeZip]) or `ACTION_OPEN_DOCUMENT_TREE` (for [writeToTree]) and passes the
* result here.
*
* Marked in `docs/STORAGE-AND-SYNC.md` as floret-kit material — the plumbing is
* not task-domain and Calendula will want the same thing. Kept app-local for now
* on the kit's own stated principle of not extracting until a second consumer
* actually exists; the seam is here, so moving it later is a file move.
*/
@Singleton
class ExportWriter @Inject constructor(
@ApplicationContext private val context: Context,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* Writes every document into [treeUri], a directory the user picked.
*
* Overwrites same-named files rather than letting SAF append " (1)" — an
* export is a snapshot, and silently accumulating `Groceries-3 (4).ics` makes
* the folder useless as a backup.
*/
suspend fun writeToTree(treeUri: Uri, documents: List<ExportDocument>): ExportResult =
withContext(io) {
val tree = DocumentFile.fromTreeUri(context, treeUri)
?: throw ExportFailedException("Cannot open the chosen folder")
if (!tree.canWrite()) throw ExportFailedException("The chosen folder is not writable")
documents.forEach { document ->
tree.findFile(document.fileName)?.delete()
val file = tree.createFile(MIME_ICALENDAR, document.fileName)
?: throw ExportFailedException("Cannot create ${document.fileName}")
write(file.uri, document.content)
}
ExportResult(documents.size, documents.map { it.fileName })
}
/**
* Writes every document into a single zip at [target].
*
* The one-file form, for sharing or for a backup the user filed somewhere
* themselves — one attachment rather than one per list.
*/
suspend fun writeZip(target: Uri, documents: List<ExportDocument>): ExportResult =
withContext(io) {
runCatching {
context.contentResolver.openOutputStream(target, "wt")?.use { raw ->
ZipOutputStream(raw.buffered()).use { zip ->
documents.forEach { document ->
zip.putNextEntry(ZipEntry(document.fileName))
zip.write(document.content)
zip.closeEntry()
}
}
} ?: throw ExportFailedException("Cannot write to the chosen file")
}.getOrElse { throw asExportFailure(it) }
ExportResult(documents.size, documents.map { it.fileName })
}
private fun write(target: Uri, bytes: ByteArray) {
runCatching {
// "wt" truncates. Without it a shorter export leaves the tail of the
// previous, longer one behind and produces a corrupt file.
context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) }
?: throw ExportFailedException("Cannot write to the chosen file")
}.getOrElse { throw asExportFailure(it) }
}
private fun asExportFailure(cause: Throwable): Throwable = when (cause) {
is ExportFailedException -> cause
// A SAF grant can be revoked between the picker and the write (the volume
// was unmounted, the provider's process died, the user cleared the grant).
is SecurityException -> ExportFailedException("Lost access to the chosen location", cause)
is IOException -> ExportFailedException(cause.message ?: "Could not write the export", cause)
else -> cause
}
private companion object {
const val MIME_ICALENDAR = "text/calendar"
}
}
@@ -0,0 +1,76 @@
package de.jeanlucmakiola.agendula.data.export
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
import de.jeanlucmakiola.agendula.domain.export.ExportList
import de.jeanlucmakiola.agendula.domain.export.ICalendarWriter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Turns the user's task lists into `.ics` documents.
*
* Export is a v1 feature rather than a nicety because of where the data now
* lives: our own provider is inside the app's private storage, so in Local mode a
* 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.
*
* **One document per list**, because a list is a CalDAV collection and that is the
* unit every other client understands. Bundling everything into a single file
* would flatten the lists away, and list membership is not recoverable from a
* VTODO afterwards.
*/
@Singleton
class TaskExporter @Inject constructor(
private val dataSource: TasksDataSource,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* Serialises [listIds] — every visible list when null.
*
* A list with no tasks still produces a document. An empty `.ics` is a real
* answer ("this list is empty"), whereas a missing file is indistinguishable
* from the export having gone wrong.
*/
suspend fun export(listIds: Set<Long>? = null): List<ExportDocument> = withContext(io) {
dataSource.taskLists()
.filter { listIds == null || it.id in listIds }
.map { list ->
val document = ExportList(
listId = list.id,
name = list.name,
accountName = list.accountName,
tasks = dataSource.exportTasks(list.id),
)
ExportDocument(
fileName = fileNameFor(list.name, list.id),
content = ICalendarWriter.write(document).toByteArray(Charsets.UTF_8),
)
}
}
companion object {
/**
* A file name derived from the list name, safe on every filesystem the
* user might pick through SAF (including FAT32 on an SD card).
*
* The list id is appended rather than trusted to be redundant: two lists on
* different accounts may share a name, and two exports landing on the same
* file would silently lose one of them.
*/
fun fileNameFor(listName: String, listId: Long): String {
val safe = listName
.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '-' }
.joinToString("")
.trim('-')
.take(60)
.ifBlank { "list" }
return "$safe-$listId.ics"
}
}
}
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
@@ -84,6 +85,26 @@ class AndroidTasksDataSource @Inject constructor(
} ?: emptyList()
}
override fun exportTasks(listId: Long): List<ExportTask> {
// projection = null for the same reason queryInstances uses it: the tasks
// table's shape varies across provider versions, and the by-name mapper
// reads what's there.
val uri = TasksContract.tasksUri(authority())
return resolver.query(
uri,
null,
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
// the user is concerned, so exporting it would resurrect deleted tasks
// in the backup.
"${Tasks.LIST_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
arrayOf(listId.toString()),
null,
)?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
} ?: emptyList()
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long {
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlin.time.Instant
@@ -52,6 +53,42 @@ object TaskMapper {
)
}
/**
* Maps a row of the **`tasks` table** — a master task, not an occurrence.
*
* Export reads there rather than from `instances` on purpose: in the instances
* view a recurring task appears once per occurrence with its times already
* resolved and no rule attached, so exporting from it would write the same
* task many times over and drop the RRULE that produced them. Here each task
* appears exactly once, carrying the rule itself.
*/
fun exportTask(r: ColumnReader): ExportTask {
fun instant(name: String): Instant? =
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
return ExportTask(
taskId = r.getLong(Tasks.ID) ?: 0L,
uid = r.getString(Tasks.UID),
title = r.getString(Tasks.TITLE).orEmpty(),
description = r.getString(Tasks.DESCRIPTION),
location = r.getString(Tasks.LOCATION),
url = r.getString(Tasks.URL),
priority = priorityFromICal(r.getInt(Tasks.PRIORITY)),
status = statusFromInt(r.getInt(Tasks.STATUS)),
percentComplete = r.getInt(Tasks.PERCENT_COMPLETE),
// The task's own columns, not the instance view's resolved ones.
start = instant(Tasks.DTSTART),
due = instant(Tasks.DUE),
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
completedAt = instant(Tasks.COMPLETED),
created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED),
rrule = r.getString(Tasks.RRULE),
rdate = r.getString(Tasks.RDATE),
parentId = r.getLong(Tasks.PARENT_ID)?.takeIf { it > 0 },
)
}
fun taskList(r: ColumnReader): TaskList = TaskList(
id = r.getLong(Lists.ID) ?: 0L,
name = r.getString(Lists.NAME).orEmpty(),
@@ -42,6 +42,13 @@ interface TasksDataSource {
/** Every task's reminder lead, by task id. One query, for the scheduler. */
fun alarms(): Map<Long, Int>
/**
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
* not occurrences — see [TaskMapper.exportTask] for why that distinction
* matters. Excludes rows the provider has flagged deleted-but-unsynced.
*/
fun exportTasks(listId: Long): List<de.jeanlucmakiola.agendula.domain.export.ExportTask>
fun setCompleted(taskId: Long, completed: Boolean)
fun deleteTask(taskId: Long)
fun createLocalList(name: String, color: Int): Long
@@ -0,0 +1,66 @@
package de.jeanlucmakiola.agendula.domain.export
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant
/**
* One task as it goes out to iCalendar — a **master** task, not an occurrence.
*
* Deliberately not [de.jeanlucmakiola.agendula.domain.Task]. That model is read
* from the `instances` view, where a recurring task appears once per occurrence
* with resolved times and no rule; exporting from it would write the same task
* fifty times and lose the RRULE that generated them. Export reads the `tasks`
* table instead, and needs two fields the UI never asks for ([uid], [rrule]).
*/
data class ExportTask(
/** `tasks._id` — the fallback identity when [uid] is absent. */
val taskId: Long,
/**
* The iCalendar UID, or `null` for a task created on this device and never
* synced. The dmfs provider only lets a *sync adapter* assign one, so in Local
* mode this is null for everything — see [ICalendarWriter.uidFor], which
* synthesises a stable substitute rather than emitting a VTODO with no UID.
*/
val uid: String?,
val title: String,
val description: String?,
val location: String?,
val url: String?,
val priority: Priority,
val status: TaskStatus,
val percentComplete: Int?,
val start: Instant?,
val due: Instant?,
val isAllDay: Boolean,
val completedAt: Instant?,
val created: Instant?,
val lastModified: Instant?,
/** Raw `RRULE` value as stored, without the `RRULE:` name. Null when non-recurring. */
val rrule: String?,
/** Raw `RDATE` value as stored. Null when absent. */
val rdate: String?,
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
val parentId: Long?,
)
/** A task list and everything in it, ready to become one `.ics` document. */
data class ExportList(
val listId: Long,
val name: String,
val accountName: String,
val tasks: List<ExportTask>,
)
/** A single file the export produced: [fileName] and its finished bytes. */
data class ExportDocument(
val fileName: String,
val content: ByteArray,
) {
// ByteArray gets identity equals/hashCode, which makes this data class lie.
override fun equals(other: Any?): Boolean =
this === other ||
(other is ExportDocument && fileName == other.fileName && content.contentEquals(other.content))
override fun hashCode(): Int = 31 * fileName.hashCode() + content.contentHashCode()
}
@@ -0,0 +1,193 @@
package de.jeanlucmakiola.agendula.domain.export
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.calendarDate
import de.jeanlucmakiola.agendula.domain.toICal
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.time.Instant
/**
* Writes a task list as an RFC 5545 `VCALENDAR` of `VTODO` components.
*
* Pure Kotlin and deliberately free of any Android type, so the format — the part
* that decides whether an exported backup can actually be read again — is
* unit-testable on the JVM. Serialising tasks is task-domain and stays here; the
* SAF/file plumbing that carries the bytes out is not, and lives in the data
* layer (and is the piece `docs/STORAGE-AND-SYNC.md` marks as a floret-kit
* candidate).
*
* **Times are always written in UTC.** Emitting a local `TZID` would oblige us to
* also emit a matching `VTIMEZONE` component with its full transition rules, and
* a `TZID` referencing an absent definition is what actually breaks importers. UTC
* is unambiguous and universally accepted, so the exported instant is exact even
* though the original wall-clock zone is not carried. All-day values keep their
* `VALUE=DATE` form and stay date-only, which is the only representation that
* survives a timezone change intact.
*/
object ICalendarWriter {
private const val PRODUCT_ID = "-//Jean-Luc Makiola//Agendula//EN"
/** RFC 5545 caps a content line at 75 octets, excluding the CRLF. */
private const val MAX_LINE_OCTETS = 75
private val DATE = DateTimeFormatter.ofPattern("yyyyMMdd")
private val DATE_TIME_UTC = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
/** Serialises [list] to a complete `.ics` document. */
fun write(list: ExportList): String = buildString {
line("BEGIN:VCALENDAR")
line("VERSION:2.0")
line("PRODID:$PRODUCT_ID")
line("CALSCALE:GREGORIAN")
// Non-standard but near-universally understood, and the only way the list's
// name survives into a calendar app. Importers that don't know it skip it.
property("X-WR-CALNAME", list.name)
// Parents must be addressable by UID, and a subtask may appear before its
// parent in the list, so resolve every id up front.
val uidsById = list.tasks.associate { it.taskId to uidFor(it) }
list.tasks.forEach { task -> writeTask(task, uidsById) }
line("END:VCALENDAR")
}
/**
* The UID to write for [task].
*
* Local tasks have none: the dmfs provider only permits a sync adapter to
* assign `_uid`, so in Local mode every task arrives here with `uid == null`.
* A VTODO without a UID is invalid and, worse, un-mergeable — re-importing a
* backup would duplicate every task instead of matching it. So we synthesise
* one from the row id, which is stable for as long as the row is, and tag it
* with our own domain so a synthesised UID is recognisable as such.
*/
fun uidFor(task: ExportTask): String =
task.uid?.takeIf { it.isNotBlank() } ?: "agendula-${task.taskId}@jeanlucmakiola.de"
private fun StringBuilder.writeTask(task: ExportTask, uidsById: Map<Long, String>) {
line("BEGIN:VTODO")
property("UID", uidFor(task))
// DTSTAMP is mandatory. It means "when this representation was written",
// which for an export is now — not the task's own timestamps.
property("DTSTAMP", formatUtc(Instant.fromEpochMilliseconds(System.currentTimeMillis())))
property("SUMMARY", task.title)
task.description?.takeIf { it.isNotBlank() }?.let { property("DESCRIPTION", it) }
task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) }
// URL is a URI, not TEXT: it must not be escaped like one.
task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) }
task.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
task.due?.let { dateProperty("DUE", it, task.isAllDay) }
task.created?.let { rawProperty("CREATED", formatUtc(it)) }
task.lastModified?.let { rawProperty("LAST-MODIFIED", formatUtc(it)) }
// COMPLETED is defined as UTC date-time even for an all-day task.
task.completedAt?.let { rawProperty("COMPLETED", formatUtc(it)) }
rawProperty("STATUS", task.status.toICalName())
if (task.priority != Priority.NONE) rawProperty("PRIORITY", task.priority.toICal().toString())
task.percentComplete?.coerceIn(0, 100)?.let { rawProperty("PERCENT-COMPLETE", it.toString()) }
// Passed through as stored. The provider keeps these in iCalendar form
// already, and re-deriving them would risk changing what the user's
// recurrence actually means.
task.rrule?.takeIf { it.isNotBlank() }?.let { rawProperty("RRULE", it) }
task.rdate?.takeIf { it.isNotBlank() }?.let { rawProperty("RDATE", it) }
// Only emit the link when the parent is in this same document; a
// RELATED-TO pointing outside the file would dangle on import.
task.parentId?.let { uidsById[it] }?.let {
property("RELATED-TO;RELTYPE=PARENT", it)
}
line("END:VTODO")
}
private fun StringBuilder.dateProperty(name: String, instant: Instant, allDay: Boolean) {
if (allDay) {
// Read in UTC, matching the storage convention (see AllDayTime): an
// all-day value *is* UTC midnight of the intended calendar date.
rawProperty("$name;VALUE=DATE", instant.calendarDate(allDay = true).format(DATE))
} else {
rawProperty(name, formatUtc(instant))
}
}
private fun formatUtc(instant: Instant): String =
java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds())
.atZone(ZoneOffset.UTC)
.format(DATE_TIME_UTC)
/** A property whose value is TEXT, and so must be escaped. */
private fun StringBuilder.property(name: String, value: String) =
line("$name:${escapeText(value)}")
/** A property whose value is already in its final form (dates, numbers, URIs, rules). */
private fun StringBuilder.rawProperty(name: String, value: String) = line("$name:$value")
private fun StringBuilder.line(content: String) {
append(fold(content))
append(CRLF)
}
/**
* Escapes a TEXT value per RFC 5545 §3.3.11. Backslash first, or it would
* double the backslashes introduced by the later replacements.
*/
internal fun escapeText(value: String): String = value
.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\r\n", "\\n")
.replace("\n", "\\n")
.replace("\r", "\\n")
/**
* Folds a content line to at most [MAX_LINE_OCTETS] octets, continuing with
* CRLF + a single space.
*
* Counted in **octets, not characters** — the limit is defined that way, and an
* emoji in a task title is four of them. Splits are kept on character
* boundaries so folding can never cut a UTF-8 sequence in half and corrupt the
* text; an importer unfolds by removing CRLF + leading whitespace, recovering
* the original exactly.
*/
internal fun fold(content: String): String {
if (content.utf8Size() <= MAX_LINE_OCTETS) return content
val out = StringBuilder()
var octets = 0
// First line takes the full budget; every continuation loses one octet to
// the leading space.
var budget = MAX_LINE_OCTETS
var index = 0
while (index < content.length) {
val codePoint = content.codePointAt(index)
val charCount = Character.charCount(codePoint)
val size = String(Character.toChars(codePoint)).utf8Size()
if (octets + size > budget) {
out.append(CRLF).append(' ')
octets = 0
budget = MAX_LINE_OCTETS - 1
}
out.append(content, index, index + charCount)
octets += size
index += charCount
}
return out.toString()
}
private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size
private fun TaskStatus.toICalName(): String = when (this) {
TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION"
TaskStatus.IN_PROCESS -> "IN-PROCESS"
TaskStatus.COMPLETED -> "COMPLETED"
TaskStatus.CANCELLED -> "CANCELLED"
}
private const val CRLF = "\r\n"
}