diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fd13d5c..8768112 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -163,6 +163,7 @@ dependencies { ksp(libs.hilt.compiler) implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.documentfile) implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt new file mode 100644 index 0000000..0376c58 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt @@ -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) + +/** 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): 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): 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" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt new file mode 100644 index 0000000..dcf40a8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt @@ -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? = null): List = 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" + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt index 7487a95..a7d46fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt @@ -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 { + // 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 { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt index 11273e8..73cfa1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt @@ -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(), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt index 6054f09..33a08ec 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt @@ -42,6 +42,13 @@ interface TasksDataSource { /** Every task's reminder lead, by task id. One query, for the scheduler. */ fun alarms(): Map + /** + * 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 + fun setCompleted(taskId: Long, completed: Boolean) fun deleteTask(taskId: Long) fun createLocalList(name: String, color: Int): Long diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt new file mode 100644 index 0000000..153a34c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt @@ -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, +) + +/** 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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt new file mode 100644 index 0000000..e310bdf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt @@ -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) { + 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" +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt new file mode 100644 index 0000000..b477620 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.agendula.data.export + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The export file name. The user picks the folder, so whatever comes out of here + * is what they will be looking at in a file manager a year from now. + */ +class TaskExporterTest { + + private fun name(listName: String, id: Long = 3L) = TaskExporter.fileNameFor(listName, id) + + @Test + fun `keeps a plain name readable`() { + assertThat(name("Groceries")).isEqualTo("Groceries-3.ics") + } + + @Test + fun `replaces characters a filesystem would reject`() { + // SAF can land on FAT32 (an SD card), where these are simply illegal. + val result = name("Work / Home: notes?") + assertThat(result).doesNotContain("/") + assertThat(result).doesNotContain(":") + assertThat(result).doesNotContain("?") + assertThat(result).endsWith("-3.ics") + } + + @Test + fun `keeps the id so same-named lists cannot collide`() { + // Two accounts may each have a list called "Personal"; without the id one + // export would silently overwrite the other. + assertThat(name("Personal", 1)).isNotEqualTo(name("Personal", 2)) + } + + @Test + fun `falls back when the name has nothing usable in it`() { + assertThat(name("///")).isEqualTo("list-3.ics") + assertThat(name("")).isEqualTo("list-3.ics") + } + + @Test + fun `does not leave dangling separators`() { + assertThat(name(" Shopping ")).isEqualTo("Shopping-3.ics") + } + + @Test + fun `caps the length`() { + // Many filesystems stop at 255 bytes for a name; a pathological list title + // should not be the thing that fails an export. + assertThat(name("x".repeat(500)).length).isAtMost(80) + } + + @Test + fun `keeps non-latin names instead of blanking them`() { + // isLetterOrDigit is Unicode-aware, so these survive rather than collapsing + // to the "list" fallback. + assertThat(name("Einkäufe")).isEqualTo("Einkäufe-3.ics") + assertThat(name("買い物")).isEqualTo("買い物-3.ics") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt new file mode 100644 index 0000000..86c6b8c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt @@ -0,0 +1,313 @@ +package de.jeanlucmakiola.agendula.domain.export + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.time.LocalDate +import kotlin.time.Instant + +/** + * The export format. Worth testing closely: an export is only as good as its + * ability to be read back, and nothing about a malformed `.ics` is obvious until + * someone actually needs the backup. + */ +class ICalendarWriterTest { + + private fun task( + taskId: Long = 1L, + uid: String? = null, + title: String = "Buy oat milk", + description: String? = null, + location: String? = null, + url: String? = null, + priority: Priority = Priority.NONE, + status: TaskStatus = TaskStatus.NEEDS_ACTION, + percentComplete: Int? = null, + start: Instant? = null, + due: Instant? = null, + isAllDay: Boolean = false, + completedAt: Instant? = null, + created: Instant? = null, + lastModified: Instant? = null, + rrule: String? = null, + rdate: String? = null, + parentId: Long? = null, + ) = ExportTask( + taskId, uid, title, description, location, url, priority, status, percentComplete, + start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId, + ) + + private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String = + ICalendarWriter.write(ExportList(1L, name, "local", tasks.toList())) + + /** Unfolds the way an importer does, so assertions can read logical lines. */ + private fun String.unfolded(): String = replace("\r\n ", "") + + private fun linesOf(ics: String): List = ics.unfolded().split("\r\n").filter { it.isNotEmpty() } + + @Nested + inner class Structure { + + @Test + fun `wraps the todos in a calendar`() { + val lines = linesOf(write(task())) + assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR") + assertThat(lines.last()).isEqualTo("END:VCALENDAR") + assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO") + } + + @Test + fun `uses CRLF line endings`() { + // RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected + // by a strict importer while looking perfectly fine in an editor. + val ics = write(task()) + assertThat(ics).contains("\r\n") + assertThat(ics.replace("\r\n", "")).doesNotContain("\n") + } + + @Test + fun `carries the list name`() { + assertThat(linesOf(write(task(), name = "Shopping"))).contains("X-WR-CALNAME:Shopping") + } + + @Test + fun `every todo has a UID and a DTSTAMP`() { + // Both are mandatory; a VTODO missing either is invalid. + val lines = linesOf(write(task(), task(taskId = 2))) + assertThat(lines.count { it.startsWith("UID:") }).isEqualTo(2) + assertThat(lines.count { it.startsWith("DTSTAMP:") }).isEqualTo(2) + } + } + + @Nested + inner class Identity { + + @Test + fun `prefers the synced UID`() { + assertThat(linesOf(write(task(uid = "abc-123@example.org")))) + .contains("UID:abc-123@example.org") + } + + @Test + fun `synthesises a stable UID for a local task`() { + // Local tasks never get a UID from the provider (only a sync adapter may + // assign one), and re-importing UID-less todos would duplicate rather + // than match them. + val first = ICalendarWriter.uidFor(task(taskId = 42)) + val second = ICalendarWriter.uidFor(task(taskId = 42)) + assertThat(first).isEqualTo(second) + assertThat(first).contains("42") + } + + @Test + fun `distinct tasks get distinct UIDs`() { + assertThat(ICalendarWriter.uidFor(task(taskId = 1))) + .isNotEqualTo(ICalendarWriter.uidFor(task(taskId = 2))) + } + + @Test + fun `blank stored UID falls back to the synthesised one`() { + assertThat(ICalendarWriter.uidFor(task(taskId = 7, uid = " "))).contains("7") + } + } + + @Nested + inner class Dates { + + private val noon = Instant.fromEpochMilliseconds(1_754_136_000_000L) // 2025-08-02T12:00:00Z + + @Test + fun `timed values are written in UTC`() { + assertThat(linesOf(write(task(due = noon)))).contains("DUE:20250802T120000Z") + } + + @Test + fun `all-day values are date-only`() { + // A DATE-TIME here would drift by a day for anyone east or west of UTC — + // the exact bug the AllDayTime convention exists to prevent. + val due = allDayInstantOf(LocalDate.of(2026, 8, 2)) + val lines = linesOf(write(task(due = due, isAllDay = true))) + assertThat(lines).contains("DUE;VALUE=DATE:20260802") + } + + @Test + fun `completion is always a UTC date-time even when all-day`() { + val lines = linesOf( + write(task(isAllDay = true, status = TaskStatus.COMPLETED, completedAt = noon)), + ) + assertThat(lines).contains("COMPLETED:20250802T120000Z") + } + + @Test + fun `absent dates emit no property at all`() { + val lines = linesOf(write(task())) + assertThat(lines.none { it.startsWith("DUE") }).isTrue() + assertThat(lines.none { it.startsWith("DTSTART") }).isTrue() + } + } + + @Nested + inner class Fields { + + @Test + fun `maps every status to its iCalendar name`() { + fun statusLine(status: TaskStatus) = + linesOf(write(task(status = status))).first { it.startsWith("STATUS:") } + + assertThat(statusLine(TaskStatus.NEEDS_ACTION)).isEqualTo("STATUS:NEEDS-ACTION") + assertThat(statusLine(TaskStatus.IN_PROCESS)).isEqualTo("STATUS:IN-PROCESS") + assertThat(statusLine(TaskStatus.COMPLETED)).isEqualTo("STATUS:COMPLETED") + assertThat(statusLine(TaskStatus.CANCELLED)).isEqualTo("STATUS:CANCELLED") + } + + @Test + fun `omits priority when there is none`() { + // PRIORITY:0 means "undefined" but reads as a real value to some + // importers; leaving it out is unambiguous. + assertThat(linesOf(write(task())).none { it.startsWith("PRIORITY") }).isTrue() + assertThat(linesOf(write(task(priority = Priority.HIGH)))).contains("PRIORITY:1") + } + + @Test + fun `clamps percent complete into range`() { + assertThat(linesOf(write(task(percentComplete = 140)))).contains("PERCENT-COMPLETE:100") + assertThat(linesOf(write(task(percentComplete = -5)))).contains("PERCENT-COMPLETE:0") + } + + @Test + fun `passes recurrence through unchanged`() { + val lines = linesOf(write(task(rrule = "FREQ=WEEKLY;BYDAY=MO,WE"))) + assertThat(lines).contains("RRULE:FREQ=WEEKLY;BYDAY=MO,WE") + } + + @Test + fun `does not escape a URL`() { + // URL is a URI, not TEXT. Escaping its commas would corrupt the address. + val lines = linesOf(write(task(url = "https://example.org/a,b;c"))) + assertThat(lines).contains("URL:https://example.org/a,b;c") + } + + @Test + fun `skips blank optional fields`() { + val lines = linesOf(write(task(description = " ", location = "", url = ""))) + assertThat(lines.none { it.startsWith("DESCRIPTION") }).isTrue() + assertThat(lines.none { it.startsWith("LOCATION") }).isTrue() + assertThat(lines.none { it.startsWith("URL") }).isTrue() + } + } + + @Nested + inner class Subtasks { + + @Test + fun `links a child to its parent by UID`() { + val parent = task(taskId = 1, uid = "parent@example.org") + val child = task(taskId = 2, parentId = 1) + assertThat(linesOf(write(parent, child))) + .contains("RELATED-TO;RELTYPE=PARENT:parent@example.org") + } + + @Test + fun `resolves a parent that appears after the child`() { + // Nothing guarantees provider order, and a forward reference must still + // resolve or half the hierarchy silently disappears. + val child = task(taskId = 2, parentId = 1) + val parent = task(taskId = 1, uid = "parent@example.org") + assertThat(linesOf(write(child, parent))) + .contains("RELATED-TO;RELTYPE=PARENT:parent@example.org") + } + + @Test + fun `drops a link to a parent outside this list`() { + // A RELATED-TO pointing at a UID not in the file would dangle on import. + val orphan = task(taskId = 2, parentId = 999) + assertThat(linesOf(write(orphan)).none { it.startsWith("RELATED-TO") }).isTrue() + } + } + + @Nested + inner class Escaping { + + @Test + fun `escapes the special characters`() { + assertThat(ICalendarWriter.escapeText("a;b,c")).isEqualTo("a\\;b\\,c") + assertThat(ICalendarWriter.escapeText("line\nbreak")).isEqualTo("line\\nbreak") + assertThat(ICalendarWriter.escapeText("CRLF\r\nhere")).isEqualTo("CRLF\\nhere") + } + + @Test + fun `escapes backslashes first`() { + // Doing it later would re-escape the backslashes the other rules add, + // turning "a;b" into "a\\;b". + assertThat(ICalendarWriter.escapeText("back\\slash")).isEqualTo("back\\\\slash") + assertThat(ICalendarWriter.escapeText("a\\;b")).isEqualTo("a\\\\\\;b") + } + + @Test + fun `a multiline description stays one logical line`() { + val ics = write(task(description = "first\nsecond")) + assertThat(ics.unfolded()).contains("DESCRIPTION:first\\nsecond") + } + } + + @Nested + inner class Folding { + + @Test + fun `short lines are untouched`() { + assertThat(ICalendarWriter.fold("SUMMARY:short")).isEqualTo("SUMMARY:short") + } + + @Test + fun `long lines are folded to 75 octets`() { + val folded = ICalendarWriter.fold("SUMMARY:" + "a".repeat(200)) + folded.split("\r\n").forEachIndexed { index, segment -> + val octets = segment.toByteArray(Charsets.UTF_8).size + assertThat(octets).isAtMost(if (index == 0) 75 else 76) // 75 + the leading space + } + } + + @Test + fun `folding round-trips`() { + val original = "DESCRIPTION:" + "long text ".repeat(40) + assertThat(ICalendarWriter.fold(original).replace("\r\n ", "")).isEqualTo(original) + } + + @Test + fun `never splits a multi-byte character`() { + // The limit is in octets but an emoji is four of them; splitting mid + // sequence would emit invalid UTF-8 and mangle the title. + val emoji = "SUMMARY:" + "🌼".repeat(40) + val folded = ICalendarWriter.fold(emoji) + assertThat(folded.replace("\r\n ", "")).isEqualTo(emoji) + folded.split("\r\n").forEach { segment -> + // A broken surrogate pair round-trips through UTF-8 as U+FFFD. + assertThat(segment.toByteArray(Charsets.UTF_8).toString(Charsets.UTF_8)) + .isEqualTo(segment) + } + } + + @Test + fun `a long title survives the full write`() { + val title = "Remember to ".repeat(20) + assertThat(write(task(title = title)).unfolded()).contains("SUMMARY:$title") + } + } + + @Nested + inner class EmptyList { + + @Test + fun `still produces a valid calendar`() { + // An empty list is a real answer; a missing file is indistinguishable + // from a failed export. + val lines = linesOf(ICalendarWriter.write(ExportList(1L, "Empty", "local", emptyList()))) + assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR") + assertThat(lines.last()).isEqualTo("END:VCALENDAR") + assertThat(lines.none { it == "BEGIN:VTODO" }).isTrue() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b72b303..c9f5f9b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,8 @@ composeBom = "2026.05.01" # 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" @@ -72,6 +74,9 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", v # 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" }