Files
agendula/app
makiolaj 003abcce79 feat(storage): a way onto the new store, and a visible failure when there isn't
Two halves of the same problem: the migration this branch was built around
serves nobody, and when it does run and fail it says nothing.

**The copy.** `OneShotImport` reads a bundled dmfs provider's
`databases/tasks.db`, and no release ever bundled that provider —
`git tag --contains` on the commit that added `:provider` comes back empty,
because it was added and deleted inside this same unreleased cycle. Every
existing install therefore keeps its tasks in OpenTasks or tasks.org, which
`autoMode()` correctly keeps them on, and the only route to the store this
release is named after was to retype everything by hand.

`ExternalImport` copies the external provider's lists, tasks and alarms into
Room in one transaction with verified counts — the same discipline as the
legacy import, for the same reason: a partial copy is worse than none, because
the user cannot tell which half is missing. Task `uid`s survive, so these rows
can be attached to a CalDAV collection once sync lands instead of duplicating
server-side.

Offered as Settings → Storage → "Copy tasks from …", behind a confirm that
names the real task count, and only while a provider is installed and
permitted and no copy has succeeded yet. A second run would leave two of
everything: what it writes is indistinguishable from hand-typed tasks the
moment it finishes, so there is nothing to reconcile against.

One-directional on purpose. Writing a *list* into a third-party provider means
impersonating its sync adapter, and the external store is already the one that
can sync. What does not come across is documented on the class, because the
read seam is `exportTasks` and that is shaped for iCalendar: per-occurrence
overrides, EXDATE, CLASS, DURATION, the per-task timezone, and a task's exact
PRIORITY digit.

Switching stores at all now asks first. Neither store hands its rows to the
other, so the app looks emptied to anyone who expected a move — the picker's
hint said so in passing, which is not where someone reads it.

**The failure.** `runIfNeeded` returned a fully-specified `ImportResult` —
`Failed(cause)`, `Imported(counts)` — and the one caller threw it away.
`StartupGate` called it inside a `runCatching` whose value it discarded, so an
upgrading user whose import failed got an empty app, no message, and their
tasks in a file only a developer could name. The class comment said as much:
"reading before the import lands shows an upgrading user an empty app, which is
the single worst thing this migration could do." It is now recorded and logged,
the completion flag deliberately left unset so the next launch retries, and
Settings → Storage offers the retry — which is what finally makes
`reimportFromArchive()` reachable from the app rather than only "by a targeted
fix release". Near-zero blast radius, given the above, but it is the one
irreversible path in the app, and the contrast was hard to defend: the export
path has a localized failure enum for a failure that costs nothing.

`@ExternalStore` binds the external source so the copy can name a store other
than the active one, and so it can be tested against a fake. Nine instrumented
tests cover the write half: id remapping with a child before its parent, the
duplicate-uid case that would otherwise abort on the unique index, the START
alarm reference, device-only lists, the empty source, a mid-read failure
rolling back with the guard left open, and preview with and without a provider.
2026-09-21 13:37:17 +02:00
..