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:
+317
@@ -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 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user