diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt new file mode 100644 index 0000000..0f38d5e --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt @@ -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 + 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 = emptyList() + var tasks: Map> = emptyMap() + var alarms: Map = emptyMap() + var failOnExport = false + + override fun taskLists(): List = lists + + override fun exportTasks(listId: Long): List { + if (failOnExport) error("provider went away mid-read") + return tasks[listId].orEmpty() + } + + override fun alarms(): Map = alarms + + override fun tasks(query: TaskQuery): List = unused() + override fun task(taskId: Long): Task? = unused() + override fun subtasks(parentTaskId: Long): List = 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" +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt index b8f3448..3cef068 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt index e42dfcf..aa51f70 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt index 0bf99ee..efaf87a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt @@ -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 = 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 = dataStore.data.map { it[IMPORT_FAILED] ?: false } + /** * Steps 1–8 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" } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt new file mode 100644 index 0000000..18f1704 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt @@ -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 1–4 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, + private val resolver: ProviderResolver, + private val database: TasksDatabase, + private val dataStore: DataStore, + @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 = 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 { + 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() + val inserted = mutableListOf>() + // 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>() + + 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, + val tasksByList: Map>, + val alarms: Map, + ) + + private companion object { + val TRANSFER_DONE = booleanPreferencesKey("external_copy_done") + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index b607be0..0cf2518 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -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 = - 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 = 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 = 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) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt index bc7c494..1f93b62 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt @@ -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(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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd0c5a8..178c689 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -252,7 +252,7 @@ Storage Where tasks are kept, and export Task store - Each store keeps its own tasks. Switching does not move them across — export first if you want a copy. + Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them. On this device Agendula\'s own storage. Nothing else to install. Another task app @@ -260,6 +260,32 @@ No compatible task app is installed Permission denied The other app\'s tasks stay unreachable until you allow access. Tap to open app settings. + + + Switch task store? + 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. + Your tasks stay in %1$s — they are not moved. Agendula will show the other app\'s tasks instead. + Switch + Copy tasks from %1$s + Bring them into Agendula\'s own storage. A one-time copy — the originals stay where they are. + Copy tasks over? + Counting what there is to copy… + %1$s holds no tasks to copy. + + %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. + %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. + + Copy + Copying tasks… + + Copied %1$d task into %2$d list. Pick “On this device” above to see them. + Copied %1$d tasks into %2$d lists. Pick “On this device” above to see them. + + There was nothing to copy + The tasks could not be copied. Nothing was changed — your originals are untouched. + Your earlier tasks could not be moved + They are still saved and nothing was lost. Tap to try moving them again. + Moving them now… Export tasks Save your lists as iCalendar files 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.