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.
This commit is contained in:
2026-09-21 13:37:17 +02:00
parent 8994dcb0aa
commit 003abcce79
8 changed files with 988 additions and 7 deletions
@@ -0,0 +1,317 @@
package de.jeanlucmakiola.agendula.data.tasks.transfer
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import java.io.File
import javax.inject.Provider
import kotlin.time.Instant
/**
* The copy out of an external provider and into Room — the upgrade path every
* released install actually needs, since no release ever bundled the provider
* `OneShotImport` reads (see `docs/OWN-STORE.md`).
*
* The source is a fake [TasksDataSource] rather than a live OpenTasks: what is
* worth testing is the write half — id remapping, uid collisions, verified
* counts, the once-only guard — and pinning that to a device with a third-party
* app installed would mean it never ran. Instrumented all the same, because the
* destination is a real Room database in a real transaction.
*/
@RunWith(AndroidJUnit4::class)
class ExternalImportTest {
@get:Rule
val temp = TemporaryFolder()
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var scope: CoroutineScope
private lateinit var prefs: DataStore<Preferences>
private lateinit var db: TasksDatabase
private lateinit var source: FakeExternalStore
private lateinit var importer: ExternalImport
@Before
fun setUp() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
prefs = PreferenceDataStoreFactory.create(scope = scope) {
temp.newFile("transfer-${counter++}.preferences_pb").also(File::delete)
}
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
.allowMainThreadQueries()
.build()
source = FakeExternalStore()
importer = ExternalImport(
external = Provider { source },
resolver = ProviderResolver(NoProviderInstalled),
database = db,
dataStore = prefs,
io = Dispatchers.IO,
)
}
@After
fun tearDown() {
db.close()
scope.cancel()
}
@Test
fun copiesListsTasksAndAlarms() = runBlocking {
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
source.tasks = mapOf(
7L to listOf(task(100, "Milk"), task(101, "Bread")),
9L to listOf(task(200, "Invoice")),
)
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
val result = importer.run()
assertThat(result).isEqualTo(
TransferResult.Copied(TransferCounts(lists = 2, tasks = 3, alarms = 1)),
)
assertThat(db.taskLists().lists().map { it.list.name })
.containsExactly("Errands", "Work")
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.title })
.containsExactly("Milk", "Bread", "Invoice")
assertThat(importer.hasRun.first()).isTrue()
}
/** Every list arrives device-only: the account belongs to the sync app. */
@Test
fun importedListsAreDeviceOnly() = runBlocking {
source.lists = listOf(list(7, "Shared", accountName = "me@example.org"))
source.tasks = mapOf(7L to listOf(task(100, "Milk")))
importer.run()
assertThat(db.taskLists().lists().single().list.accountId).isNull()
}
/** Provider row ids are the source's; Room mints its own and the link follows. */
@Test
fun remapsParentIdsOntoTheNewRowIds() = runBlocking {
source.lists = listOf(list(7, "Errands"))
// Child before parent, so a naive single pass would not find the parent.
source.tasks = mapOf(
7L to listOf(task(100, "Subtask", parentId = 200), task(200, "Parent")),
)
importer.run()
val rows = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task }
val parent = rows.single { it.title == "Parent" }
val child = rows.single { it.title == "Subtask" }
assertThat(child.parentId).isEqualTo(parent.id)
assertThat(child.parentId).isNotEqualTo(200L)
}
/**
* A `RECURRENCE-ID` override reaches the read seam as another master-shaped row
* sharing its series' uid. The unique index on (list, uid, recurrence_id)
* would reject it and take the whole copy down, so it gets a fresh uid.
*/
@Test
fun aDuplicateUidDoesNotAbortTheCopy() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(
7L to listOf(
task(100, "Weekly", uid = "shared-uid"),
task(101, "Weekly, that one week", uid = "shared-uid"),
),
)
val result = importer.run()
assertThat(result).isInstanceOf(TransferResult.Copied::class.java)
val uids = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.uid }
assertThat(uids).hasSize(2)
assertThat(uids.toSet()).hasSize(2)
assertThat(uids).contains("shared-uid")
}
/** A START-referenced reminder must not come across as a before-due one. */
@Test
fun preservesTheAlarmReference() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.tasks = mapOf(7L to listOf(task(100, "Standup")))
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 10, fromStart = true))
importer.run()
val alarm = db.alarms().all().single()
assertThat(alarm.reference).isEqualTo(AlarmReference.START)
assertThat(alarm.minutesBefore).isEqualTo(10)
}
@Test
fun anEmptySourceWritesNothingAndIsNotMarkedDone() = runBlocking {
val result = importer.run()
assertThat(result).isEqualTo(TransferResult.NothingToCopy)
assertThat(db.taskLists().lists()).isEmpty()
// Still on offer: there was nothing to copy, not a copy that happened.
assertThat(importer.hasRun.first()).isFalse()
}
/** A read that blows up must leave Room exactly as it was. */
@Test
fun aFailedReadRollsBackAndLeavesTheGuardOpen() = runBlocking {
source.lists = listOf(list(7, "Errands"))
source.failOnExport = true
val result = importer.run()
assertThat(result).isInstanceOf(TransferResult.Failed::class.java)
assertThat(db.taskLists().lists()).isEmpty()
assertThat(importer.hasRun.first()).isFalse()
}
@Test
fun previewCountsWhatARunWouldWrite() = runBlocking {
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
source.tasks = mapOf(
7L to listOf(task(100, "Milk"), task(101, "Bread")),
9L to listOf(task(200, "Invoice")),
)
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
// preview() resolves the provider itself, so it needs one to be installed.
val withProvider = ExternalImport(
external = Provider { source },
resolver = ProviderResolver(OpenTasksInstalledAndGranted),
database = db,
dataStore = prefs,
io = Dispatchers.IO,
)
assertThat(withProvider.preview())
.isEqualTo(TransferCounts(lists = 2, tasks = 3, alarms = 1))
}
@Test
fun previewIsNullWithoutAReadableProvider() = runBlocking {
assertThat(importer.preview()).isNull()
}
// --- fixtures --------------------------------------------------------------
private fun list(id: Long, name: String, accountName: String = "Device") = TaskList(
id = id,
name = name,
color = 0xFF7E57C2.toInt(),
accountName = accountName,
accountType = "org.dmfs.account.LOCAL",
isSynced = true,
isVisible = true,
owner = null,
)
private fun task(
id: Long,
title: String,
uid: String? = "uid-$id",
parentId: Long? = null,
) = ExportTask(
taskId = id,
uid = uid,
title = title,
description = null,
location = null,
url = null,
priority = Priority.NONE,
status = TaskStatus.NEEDS_ACTION,
percentComplete = null,
start = null,
due = Instant.fromEpochMilliseconds(1_800_000_000_000),
isAllDay = false,
completedAt = null,
created = null,
lastModified = null,
rrule = null,
rdate = null,
parentId = parentId,
)
private companion object {
var counter = 0
}
}
/** Only the three reads the copy makes; everything else is out of scope. */
private class FakeExternalStore : TasksDataSource {
var lists: List<TaskList> = emptyList()
var tasks: Map<Long, List<ExportTask>> = emptyMap()
var alarms: Map<Long, TaskReminder> = emptyMap()
var failOnExport = false
override fun taskLists(): List<TaskList> = lists
override fun exportTasks(listId: Long): List<ExportTask> {
if (failOnExport) error("provider went away mid-read")
return tasks[listId].orEmpty()
}
override fun alarms(): Map<Long, TaskReminder> = alarms
override fun tasks(query: TaskQuery): List<Task> = unused()
override fun task(taskId: Long): Task? = unused()
override fun subtasks(parentTaskId: Long): List<Task> = unused()
override fun insertTask(form: TaskForm): Long = unused()
override fun updateTask(taskId: Long, form: TaskForm) = unused()
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
override fun deleteTask(taskId: Long) = unused()
override fun createLocalList(name: String, color: Int): Long = unused()
override fun updateList(listId: Long, name: String, color: Int) = unused()
override fun deleteList(listId: Long) = unused()
override fun registerObserver(onChange: () -> Unit): AutoCloseable = unused()
private fun unused(): Nothing = error("the copy does not call this")
}
/** No tasks provider on the device: `preview()` has nothing to read. */
private object NoProviderInstalled : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = null
override fun isGranted(permission: String): Boolean = false
override fun appLabel(packageName: String): String? = null
}
private object OpenTasksInstalledAndGranted : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? =
"org.dmfs.tasks".takeIf { authority == "org.dmfs.tasks" }
override fun isGranted(permission: String): Boolean = permission.startsWith("org.dmfs.permission.")
override fun appLabel(packageName: String): String = "OpenTasks"
}
@@ -44,6 +44,14 @@ abstract class DataBindModule {
@Binds
@Singleton
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
// Deliberately unqualified-free of the routing above: this is the external
// store itself, for the one caller that has to read it while another store is
// the active one.
@Binds
@Singleton
@ExternalStore
abstract fun bindExternalTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
}
@Module
@@ -16,3 +16,17 @@ annotation class IoDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope
/**
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
* whichever store the active mode selects.
*
* Only the one-time copy into our own store needs to name a store this way;
* everything else goes through the routed source and must keep doing so. Having
* it as a binding rather than depending on the concrete class is also what lets
* that copy be tested against a fake.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ExternalStore
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.agendula.data.tasks.legacy
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
@@ -68,6 +69,17 @@ class OneShotImport @Inject constructor(
/** Whether the import has run. Set before the rename, so both guards hold. */
val isDone: Flow<Boolean> = dataStore.data.map { it[IMPORT_DONE] ?: false }
/**
* Whether the last attempt failed and the archived source is still sitting
* there unread.
*
* Recorded because the alternative is what this class used to do: return an
* [ImportResult.Failed] that every caller dropped on the floor, leaving an
* upgrading user with an empty app, no message, and their tasks in a file
* only a developer could find. Settings → Storage offers the retry.
*/
val lastAttemptFailed: Flow<Boolean> = dataStore.data.map { it[IMPORT_FAILED] ?: false }
/**
* Steps 18 of the plan: archive `databases/tasks.db`, import it, record
* completion. Safe to call on every launch.
@@ -94,7 +106,7 @@ class OneShotImport @Inject constructor(
return@withContext ImportResult.NothingToImport
}
val counts = runCatching { importFrom(source, replaceExisting = true) }
.getOrElse { return@withContext ImportResult.Failed(it) }
.getOrElse { return@withContext recordFailure(it) }
markDone()
ImportResult.Imported(counts)
}
@@ -107,7 +119,7 @@ class OneShotImport @Inject constructor(
suspend fun reimportFromArchive(): ImportResult = withContext(Dispatchers.IO) {
val source = archivedSource() ?: return@withContext ImportResult.NothingToImport
val counts = runCatching { importFrom(source, replaceExisting = true) }
.getOrElse { return@withContext ImportResult.Failed(it) }
.getOrElse { return@withContext recordFailure(it) }
markDone()
ImportResult.Imported(counts)
}
@@ -341,8 +353,22 @@ class OneShotImport @Inject constructor(
return true
}
/**
* Leaves a breadcrumb the UI can act on, and one in logcat for a bug report.
* The completion flag is deliberately *not* set: the next launch retries on
* its own, and the archived source is still where it was.
*/
private suspend fun recordFailure(cause: Throwable): ImportResult.Failed {
Log.e(TAG, "Importing the legacy task database failed; source left in place", cause)
dataStore.edit { it[IMPORT_FAILED] = true }
return ImportResult.Failed(cause)
}
private suspend fun markDone() {
dataStore.edit { it[IMPORT_DONE] = true }
dataStore.edit {
it[IMPORT_DONE] = true
it.remove(IMPORT_FAILED)
}
}
private fun CursorColumnReader.instant(name: String): Instant? =
@@ -356,6 +382,8 @@ class OneShotImport @Inject constructor(
private const val REFERENCE_START = "2"
private val SIDECARS = listOf("-journal", "-wal", "-shm")
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
private val IMPORT_FAILED = booleanPreferencesKey("legacy_import_failed")
private const val TAG = "OneShotImport"
}
}
@@ -0,0 +1,277 @@
package de.jeanlucmakiola.agendula.data.tasks.transfer
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import de.jeanlucmakiola.agendula.data.di.ExternalStore
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.toICal
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/** How much one copy moved. */
data class TransferCounts(val lists: Int, val tasks: Int, val alarms: Int)
/** The outcome of [ExternalImport.run]. */
sealed interface TransferResult {
/** The external store holds no list to copy — nothing was written. */
data object NothingToCopy : TransferResult
data class Copied(val counts: TransferCounts) : TransferResult
/** Nothing landed: the transaction rolled back and the source is untouched. */
data class Failed(val cause: Throwable) : TransferResult
}
/**
* Copies an external provider's tasks (OpenTasks, tasks.org) into Agendula's own
* Room store, once, when the user asks for it in Settings → Storage.
*
* This is the upgrade path 1.0.0 needs and [de.jeanlucmakiola.agendula.data
* .tasks.legacy.OneShotImport] does not provide: that one moves a *bundled dmfs
* provider's* SQLite file, which no released version ever shipped, so every
* existing install's tasks are in a third-party provider instead. Without this
* the only way onto the new store is to retype everything by hand.
*
* **A copy, not a sync, and deliberately one-directional.** The source is left
* exactly as it is — whatever syncs it (DAVx5 and friends) keeps doing so, and
* the two sets of rows drift apart from the moment this finishes. The reverse
* direction is not offered: 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**, because the read seam is
* [TasksDataSource.exportTasks] and that is shaped for iCalendar output:
* per-occurrence `RECURRENCE-ID` overrides (a series arrives as its master plus
* its rule, so an edited single occurrence reverts to the series' own values),
* `EXDATE`, `CLASS`, `DURATION`, the per-task timezone, and a task's exact
* `PRIORITY` digit — [de.jeanlucmakiola.agendula.domain.Priority] buckets 14 as
* HIGH, so a `PRIORITY:3` lands as `1`. Nothing the app itself displays is lost.
*
* Task `uid`s *are* preserved, which is what lets these rows be re-attached to a
* CalDAV collection once sync lands rather than duplicating server-side.
*/
@Singleton
class ExternalImport @Inject constructor(
@ExternalStore private val external: Provider<TasksDataSource>,
private val resolver: ProviderResolver,
private val database: TasksDatabase,
private val dataStore: DataStore<Preferences>,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* Whether a copy has already succeeded. The UI uses this to stop offering the
* action, because a second run would duplicate every row: the rows it writes
* are ordinary tasks afterwards, indistinguishable from ones typed by hand, so
* there is nothing to reconcile a re-run against.
*/
val hasRun: Flow<Boolean> = dataStore.data.map { it[TRANSFER_DONE] ?: false }
/**
* What a copy would move, for the confirmation to name real numbers — or
* `null` when there is no readable external provider to copy from.
*
* Counts masters, the same rows [run] writes, so the number the user agrees to
* is the number they get.
*/
suspend fun preview(): TransferCounts? = withContext(io) {
val provider = resolver.resolveExternal() ?: return@withContext null
if (!resolver.hasPermission(provider)) return@withContext null
runCatching {
val source = external.get()
val lists = source.taskLists()
val tasks = lists.sumOf { source.exportTasks(it.id).size }
TransferCounts(lists = lists.size, tasks = tasks, alarms = source.alarms().size)
}.getOrNull()
}
/**
* Reads the external store, then writes everything 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.
*
* The flag is written only after the transaction commits. A crash in between
* leaves the rows in place and the action still on offer, which duplicates on
* a second run — the lesser of the two evils, since the alternative is
* claiming a copy that never happened.
*/
suspend fun run(): TransferResult = withContext(io) {
val snapshot = runCatching { read() }
.getOrElse { return@withContext TransferResult.Failed(it) }
if (snapshot.lists.isEmpty()) return@withContext TransferResult.NothingToCopy
val counts = runCatching {
database.runInTransaction<TransferCounts> {
val baseline = tableCounts()
val written = write(snapshot)
verify(written, baseline)
written
}
}.getOrElse { return@withContext TransferResult.Failed(it) }
markDone()
TransferResult.Copied(counts)
}
// --- reading the external provider ----------------------------------------
private fun read(): Snapshot {
val source = external.get()
val lists = source.taskLists()
return Snapshot(
lists = lists,
tasksByList = lists.associate { it.id to source.exportTasks(it.id) },
alarms = source.alarms(),
)
}
// --- writing into Room ----------------------------------------------------
/**
* Provider row ids are the source's own, and Room mints its own on insert, so
* `parentId` is remapped through the ids the inserts hand back. Tasks go in
* with their parent cleared and a second pass sets it, because a parent may
* sort after its child.
*
* Every list arrives as **device-only** (`account_id IS NULL`), including one
* that sat under a CalDAV account in the provider: the account belongs to the
* sync app, not to us, and claiming it here would suggest Agendula syncs it.
*/
private fun write(snapshot: Snapshot): TransferCounts {
val listDao = database.taskLists()
val taskDao = database.tasks()
val alarmDao = database.alarms()
val listIds = snapshot.lists.associate { list ->
list.id to listDao.insert(
TaskListEntity(
name = list.name,
color = list.color,
accountId = null,
isVisible = list.isVisible,
isSynced = false,
owner = list.owner,
),
)
}
val taskIds = mutableMapOf<Long, Long>()
val inserted = mutableListOf<Pair<ExportTask, TaskEntity>>()
// A `RECURRENCE-ID` override reaches us as another master-shaped row
// sharing its series' UID, which the unique index would reject and take
// the whole copy down with it. A fresh uid costs that row nothing it
// still has.
val seen = mutableSetOf<Pair<Long, String>>()
for ((sourceListId, tasks) in snapshot.tasksByList) {
val listId = listIds[sourceListId] ?: continue
for (task in tasks) {
val uid = task.uid?.takeIf { seen.add(listId to it) } ?: UUID.randomUUID().toString()
val entity = task.toEntity(listId, uid)
val newId = taskDao.insert(entity)
taskIds[task.taskId] = newId
inserted += task to entity.copy(id = newId)
}
}
for ((task, entity) in inserted) {
val parentId = task.parentId?.let(taskIds::get) ?: continue
taskDao.update(entity.copy(parentId = parentId))
}
var alarmCount = 0
for ((sourceTaskId, reminder) in snapshot.alarms) {
val taskId = taskIds[sourceTaskId] ?: continue
alarmDao.insert(reminder.toEntity(taskId))
alarmCount++
}
return TransferCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount)
}
private fun ExportTask.toEntity(listId: Long, uid: String) = TaskEntity(
listId = listId,
uid = uid,
title = title,
description = description,
location = location,
url = url,
status = status,
percentComplete = percentComplete,
completedAt = completedAt,
priority = priority.toICal(),
dtstart = start,
due = due,
isAllDay = isAllDay,
rrule = rrule,
rdate = rdate,
createdAt = created,
lastModified = lastModified,
)
private fun TaskReminder.toEntity(taskId: Long) = TaskAlarmEntity(
taskId = taskId,
minutesBefore = minutesBefore,
reference = if (fromStart) AlarmReference.START else AlarmReference.DUE,
)
private fun verify(written: TransferCounts, before: TransferCounts) {
val after = tableCounts()
check(after.lists - before.lists == written.lists) {
"list count mismatch: ${after.lists - before.lists} != ${written.lists}"
}
check(after.tasks - before.tasks == written.tasks) {
"task count mismatch: ${after.tasks - before.tasks} != ${written.tasks}"
}
check(after.alarms - before.alarms == written.alarms) {
"alarm count mismatch: ${after.alarms - before.alarms} != ${written.alarms}"
}
}
private fun tableCounts() = TransferCounts(
lists = count("task_lists"),
tasks = count("tasks"),
alarms = count("task_alarms"),
)
private fun count(table: String): Int =
database.query("SELECT COUNT(*) FROM $table", null).use {
if (it.moveToFirst()) it.getInt(0) else 0
}
private suspend fun markDone() {
dataStore.edit { it[TRANSFER_DONE] = true }
}
/** Clears the guard so the action is offered again. Test and support hook. */
suspend fun clearCompletion() {
dataStore.edit { it.remove(TRANSFER_DONE) }
}
private class Snapshot(
val lists: List<TaskList>,
val tasksByList: Map<Long, List<ExportTask>>,
val alarms: Map<Long, TaskReminder>,
)
private companion object {
val TRANSFER_DONE = booleanPreferencesKey("external_copy_done")
}
}
@@ -7,12 +7,17 @@ import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskProvider
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.legacy.OneShotImport
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.data.tasks.transfer.ExternalImport
import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferCounts
import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferResult
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.reminders.ReminderOverride
@@ -46,6 +51,38 @@ data class StorageUiState(
val external: TaskProvider? = null,
/** That provider's own app name, for a row that names what it is switching to. */
val externalLabel: String? = null,
/**
* Whether copying that provider's tasks into our own store is still on offer:
* a provider is installed and permitted, and no copy has succeeded yet.
*/
val canCopyFromExternal: Boolean = false,
/**
* Whether the one-shot legacy import failed and is still retryable. Almost
* always false: the bundled provider it reads from never shipped in a release.
*/
val legacyImportFailed: Boolean = false,
)
/** How the last copy ended — kept on screen rather than flashed past. */
sealed interface TransferOutcome {
data class Copied(val counts: TransferCounts) : TransferOutcome
data object NothingToCopy : TransferOutcome
data object Failed : TransferOutcome
}
/**
* The copy-from-external flow: a confirmation that names real numbers, then the
* run, then a receipt that stays put.
*
* [preview] is null while the counts are still being read — the dialog opens
* first and fills in, because counting means querying every list in the provider
* and that is not instant on a big store.
*/
data class TransferUiState(
val confirming: Boolean = false,
val preview: TransferCounts? = null,
val running: Boolean = false,
val outcome: TransferOutcome? = null,
)
/**
@@ -57,6 +94,9 @@ class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
private val resolver: ProviderResolver,
private val environment: ProviderEnvironment,
private val externalImport: ExternalImport,
private val oneShotImport: OneShotImport,
private val reminderScheduler: ReminderScheduler,
@IoDispatcher io: CoroutineDispatcher,
repository: TasksRepository,
) : ViewModel() {
@@ -81,7 +121,12 @@ class SettingsViewModel @Inject constructor(
// name the wrong store for the first frames. flowOn, because every field here
// costs a PackageManager lookup or a permission check.
val storage: StateFlow<StorageUiState?> =
combine(prefs.storageMode, providerProbe) { stored, _ ->
combine(
prefs.storageMode,
providerProbe,
externalImport.hasRun,
oneShotImport.lastAttemptFailed,
) { stored, _, copied, legacyFailed ->
val external = resolver.resolveExternal()
StorageUiState(
// No stored choice is the normal state; show what autoMode resolves
@@ -89,6 +134,10 @@ class SettingsViewModel @Inject constructor(
mode = stored ?: resolver.autoMode(),
external = external,
externalLabel = external?.packageName?.let(environment::appLabel),
canCopyFromExternal = !copied &&
external != null &&
resolver.hasPermission(external),
legacyImportFailed = legacyFailed,
)
}.flowOn(io).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@@ -97,6 +146,69 @@ class SettingsViewModel @Inject constructor(
fun setStorageMode(mode: StorageMode) = viewModelScope.launch { prefs.setStorageMode(mode) }
// --- copying an external provider's tasks into our own store ---------------
private val transferState = MutableStateFlow(TransferUiState())
val transfer: StateFlow<TransferUiState> = transferState
/** Open the confirmation and start counting what a copy would move. */
fun startCopyFromExternal() {
if (transferState.value.running) return
transferState.update { it.copy(confirming = true, preview = null, outcome = null) }
viewModelScope.launch {
val counts = externalImport.preview()
// The dialog may already be gone — dismissing while the count runs is
// the normal way out of a store too big to count quickly.
transferState.update { if (it.confirming) it.copy(preview = counts) else it }
}
}
fun dismissCopyFromExternal() = transferState.update { it.copy(confirming = false) }
/**
* Run the copy, then re-arm reminders: the tasks that just landed carry their
* own alarm rows, and nothing else would notice them — the scheduler is driven
* by explicit syncs, not by a store observer.
*/
fun confirmCopyFromExternal() {
if (transferState.value.running) return
transferState.update { it.copy(confirming = false, running = true, outcome = null) }
viewModelScope.launch {
val result = externalImport.run()
if (result is TransferResult.Copied) runCatching { reminderScheduler.sync() }
transferState.update {
it.copy(
running = false,
outcome = when (result) {
is TransferResult.Copied -> TransferOutcome.Copied(result.counts)
TransferResult.NothingToCopy -> TransferOutcome.NothingToCopy
is TransferResult.Failed -> TransferOutcome.Failed
},
)
}
}
}
/**
* Retry the legacy import against the archived `tasks.db.imported`. Truncates
* and replaces rather than merging, so tapping twice cannot double-import; on
* success the flag clears and the row offering this disappears with it.
*/
fun retryLegacyImport() {
if (retryingLegacyImport.value) return
retryingLegacyImport.value = true
viewModelScope.launch {
runCatching { oneShotImport.reimportFromArchive() }
runCatching { reminderScheduler.sync() }
retryingLegacyImport.value = false
}
}
private val retryingLegacyImport = MutableStateFlow(false)
val legacyImportRetrying: StateFlow<Boolean> = retryingLegacyImport
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
@@ -5,22 +5,34 @@ import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.MoveDown
import androidx.compose.material.icons.rounded.PhoneAndroid
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
@@ -28,6 +40,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskProvider
import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferCounts
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
@@ -47,8 +60,13 @@ internal fun StorageScreen(
) {
val context = LocalContext.current
val storage by viewModel.storage.collectAsStateWithLifecycle()
val transfer by viewModel.transfer.collectAsStateWithLifecycle()
val retryingLegacyImport by viewModel.legacyImportRetrying.collectAsStateWithLifecycle()
var showPicker by remember { mutableStateOf(false) }
var denied by remember { mutableStateOf(false) }
// The store the user picked and has not yet confirmed. Switching stores moves
// nothing, so it is worth one dialog rather than an empty app and a guess.
var pendingMode by remember { mutableStateOf<StorageMode?>(null) }
// The mode is committed only once the grant is in — switching first drops the
// user on the app-wide permission gate.
@@ -80,6 +98,19 @@ internal fun StorageScreen(
}
},
)
if (storage?.canCopyFromExternal == true) {
GroupedRow(
title = stringResource(
R.string.settings_copy_from_external,
storage?.let { externalTitle(it.external, it.externalLabel) }.orEmpty(),
),
summary = stringResource(R.string.settings_copy_from_external_hint),
position = Position.Middle,
leading = { Icon(Icons.Rounded.MoveDown, contentDescription = null) },
// Inert while a copy is in flight; the receipt below says so.
onClick = if (transfer.running) null else viewModel::startCopyFromExternal,
)
}
GroupedRow(
title = stringResource(R.string.settings_export),
summary = stringResource(R.string.settings_export_hint),
@@ -87,6 +118,26 @@ internal fun StorageScreen(
onClick = onOpenExport,
)
TransferStatus(transfer)
// Only ever shown to someone whose upgrade import failed — their tasks are
// still in the archived file, and this is the one way back to them.
if (storage?.legacyImportFailed == true) {
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.settings_legacy_import_failed),
summary = stringResource(
if (retryingLegacyImport) {
R.string.settings_legacy_import_retrying
} else {
R.string.settings_legacy_import_failed_hint
},
),
position = Position.Alone,
onClick = if (retryingLegacyImport) null else viewModel::retryLegacyImport,
)
}
if (denied) {
Spacer(Modifier.height(16.dp))
GroupedRow(
@@ -102,7 +153,21 @@ internal fun StorageScreen(
if (showPicker) {
StorePicker(
storage = state,
onSelect = { mode ->
// Picking the store already in use is not a switch — no dialog.
onSelect = { mode -> if (mode != state.mode) pendingMode = mode },
onDismiss = { showPicker = false },
)
}
pendingMode?.let { mode ->
SwitchStoreDialog(
target = mode,
otherStore = when (mode) {
StorageMode.OWN -> externalTitle(state.external, state.externalLabel)
StorageMode.EXTERNAL -> stringResource(R.string.settings_store_own)
},
onConfirm = {
pendingMode = null
val external = state.external
if (mode == StorageMode.EXTERNAL && external != null) {
// Asked even when the grant looks held: an already-granted
@@ -114,12 +179,146 @@ internal fun StorageScreen(
viewModel.setStorageMode(mode)
}
},
onDismiss = { showPicker = false },
onDismiss = { pendingMode = null },
)
}
if (transfer.confirming) {
CopyFromExternalDialog(
source = externalTitle(state.external, state.externalLabel),
counts = transfer.preview,
onConfirm = viewModel::confirmCopyFromExternal,
onDismiss = viewModel::dismissCopyFromExternal,
)
}
}
}
/**
* The switch itself, behind a confirm. Neither store hands its rows to the other
* when the mode changes — the tasks stay where they were written — so the app
* looks emptied to anyone who expected a move. Saying so once is cheaper than
* the support thread.
*/
@Composable
private fun SwitchStoreDialog(
target: StorageMode,
otherStore: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Rounded.SwapHoriz, contentDescription = null) },
title = { Text(stringResource(R.string.settings_store_switch_title)) },
text = {
Text(
stringResource(
when (target) {
StorageMode.OWN -> R.string.settings_store_switch_to_own
StorageMode.EXTERNAL -> R.string.settings_store_switch_to_external
},
otherStore,
),
)
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.settings_store_switch_confirm)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
/**
* The copy, behind a confirm that names real numbers — it is irreversible in the
* only sense that matters: nothing merges the result back, so running it twice
* would leave two of everything. Confirm stays disabled until the count lands.
*/
@Composable
private fun CopyFromExternalDialog(
source: String,
counts: TransferCounts?,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Rounded.MoveDown, contentDescription = null) },
title = { Text(stringResource(R.string.settings_copy_confirm_title)) },
text = {
Text(
text = when {
counts == null -> stringResource(R.string.settings_copy_counting)
counts.tasks == 0 -> stringResource(R.string.settings_copy_confirm_empty, source)
else -> pluralStringResource(
R.plurals.settings_copy_confirm_message,
counts.tasks,
counts.tasks,
source,
)
},
)
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = counts != null && counts.tasks > 0) {
Text(stringResource(R.string.settings_copy_confirm_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
/** The running spinner, then whatever the last copy ended as — it stays put. */
@Composable
private fun TransferStatus(state: TransferUiState) {
val outcome = state.outcome
when {
state.running -> Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(Modifier.size(18.dp))
Text(
text = stringResource(R.string.settings_copy_running),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
outcome is TransferOutcome.Copied -> StatusText(
text = pluralStringResource(
R.plurals.settings_copy_done,
outcome.counts.tasks,
outcome.counts.tasks,
outcome.counts.lists,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
outcome is TransferOutcome.NothingToCopy -> StatusText(
text = stringResource(R.string.settings_copy_nothing),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
outcome is TransferOutcome.Failed -> StatusText(
text = stringResource(R.string.settings_copy_failed),
color = MaterialTheme.colorScheme.error,
)
}
}
@Composable
private fun StatusText(text: String, color: Color) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = color,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
/**
* The two stores, as rows. External is offered only when a provider is actually
* installed — dimmed and inert otherwise, because a mode with nothing behind it
+27 -1
View File
@@ -252,7 +252,7 @@
<string name="settings_section_storage">Storage</string>
<string name="settings_storage_subtitle">Where tasks are kept, and export</string>
<string name="settings_task_store">Task store</string>
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — export first if you want a copy.</string>
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them.</string>
<string name="settings_store_own">On this device</string>
<string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string>
<string name="settings_store_external">Another task app</string>
@@ -260,6 +260,32 @@
<string name="settings_store_external_missing">No compatible task app is installed</string>
<string name="settings_store_permission_denied">Permission denied</string>
<string name="settings_store_permission_denied_hint">The other app\'s tasks stay unreachable until you allow access. Tap to open app settings.</string>
<!-- Switching stores, and copying tasks from another task app into ours -->
<string name="settings_store_switch_title">Switch task store?</string>
<string name="settings_store_switch_to_own">Your tasks stay in %1$s — they are not moved. Agendula will show its own storage, which starts out empty unless you copy them over.</string>
<string name="settings_store_switch_to_external">Your tasks stay in %1$s — they are not moved. Agendula will show the other app\'s tasks instead.</string>
<string name="settings_store_switch_confirm">Switch</string>
<string name="settings_copy_from_external">Copy tasks from %1$s</string>
<string name="settings_copy_from_external_hint">Bring them into Agendula\'s own storage. A one-time copy — the originals stay where they are.</string>
<string name="settings_copy_confirm_title">Copy tasks over?</string>
<string name="settings_copy_counting">Counting what there is to copy…</string>
<string name="settings_copy_confirm_empty">%1$s holds no tasks to copy.</string>
<plurals name="settings_copy_confirm_message">
<item quantity="one">%1$d task from %2$s will be copied into Agendula\'s own storage. The originals stay where they are, and the two stop matching from here on — so this is offered only once.</item>
<item quantity="other">%1$d tasks from %2$s will be copied into Agendula\'s own storage. The originals stay where they are, and the two stop matching from here on — so this is offered only once.</item>
</plurals>
<string name="settings_copy_confirm_action">Copy</string>
<string name="settings_copy_running">Copying tasks…</string>
<plurals name="settings_copy_done">
<item quantity="one">Copied %1$d task into %2$d list. Pick “On this device” above to see them.</item>
<item quantity="other">Copied %1$d tasks into %2$d lists. Pick “On this device” above to see them.</item>
</plurals>
<string name="settings_copy_nothing">There was nothing to copy</string>
<string name="settings_copy_failed">The tasks could not be copied. Nothing was changed — your originals are untouched.</string>
<string name="settings_legacy_import_failed">Your earlier tasks could not be moved</string>
<string name="settings_legacy_import_failed_hint">They are still saved and nothing was lost. Tap to try moving them again.</string>
<string name="settings_legacy_import_retrying">Moving them now…</string>
<string name="settings_export">Export tasks</string>
<string name="settings_export_hint">Save your lists as iCalendar files</string>
<string name="export_hint">One .ics file per list, readable by other task and calendar apps. The ticked lists go to a folder you pick, or into a single zip.</string>