diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6f0249f..7473816 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -154,6 +154,7 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt new file mode 100644 index 0000000..c040421 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt @@ -0,0 +1,290 @@ +package de.jeanlucmakiola.agendula.data.tasks.legacy + +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 androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import de.jeanlucmakiola.agendula.domain.TaskStatus +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 java.util.UUID +import kotlin.time.Instant + +/** + * The one-shot import, against `assets/tasks-v23.db` — the dmfs v23 fixture + * `scripts/make_import_fixture.py` seeds. Instrumented because both halves need + * a real SQLite: the source file and Room. + */ +@RunWith(AndroidJUnit4::class) +class OneShotImportTest { + + @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 importer: OneShotImport + + @Before + fun setUp() { + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + prefs = PreferenceDataStoreFactory.create(scope = scope) { + temp.newFile("import-${counter++}.preferences_pb").also(File::delete) + } + db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java) + .allowMainThreadQueries() + .build() + importer = OneShotImport(context, db, prefs) + legacyFile().delete() + archiveFile().delete() + } + + @After + fun tearDown() { + db.close() + scope.cancel() + legacyFile().delete() + archiveFile().delete() + } + + private fun legacyFile() = context.getDatabasePath(OneShotImport.LEGACY_NAME) + private fun archiveFile() = context.getDatabasePath(OneShotImport.ARCHIVE_NAME) + + /** The fixture, copied out of the test APK's assets. */ + private fun fixture(target: File = temp.newFile("tasks-v23-copy.db")): File { + InstrumentationRegistry.getInstrumentation().context.assets.open(FIXTURE).use { source -> + target.outputStream().use(source::copyTo) + } + return target + } + + private fun taskRows(): Map = + db.tasks().tasks(null, includeCompleted = true).associate { it.task.title!! to it.task } + + // --- what lands ----------------------------------------------------------- + + @Test + fun importsEveryLiveTaskAndLeavesTheDeletedOneBehind() { + val counts = importer.importFrom(fixture()) + + assertThat(counts).isEqualTo(ImportCounts(lists = 3, tasks = 8, alarms = 2)) + assertThat(taskRows().keys).containsExactly( + "Buy milk", + "Call the dentist", + "Gather receipts", + "Renew domain", + "Water the plants", + "Team offsite", + "Task in a hidden list", + "Ship the release", + ) + } + + @Test + fun importsEveryListAsADeviceOnlyListWithItsFlags() { + importer.importFrom(fixture()) + + val lists = db.taskLists().lists().associateBy { it.list.name } + assertThat(lists.keys).containsExactly("Personal", "Hidden list", "Work") + assertThat(lists.values.map { it.list.accountId }).containsExactly(null, null, null) + assertThat(lists.getValue("Personal").list.isVisible).isTrue() + assertThat(lists.getValue("Hidden list").list.isVisible).isFalse() + // The list that sat under a real account: still imported, owner kept. + assertThat(lists.getValue("Work").list.owner).isEqualTo("Me") + assertThat(lists.getValue("Work").list.color).isEqualTo(0xFF2244AA.toInt()) + } + + @Test + fun carriesTheTaskFieldsAcross() { + importer.importFrom(fixture()) + val tasks = taskRows() + + val milk = tasks.getValue("Buy milk") + assertThat(milk.due).isEqualTo(Instant.fromEpochMilliseconds(T0 + DAY)) + assertThat(milk.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(milk.createdAt).isEqualTo(Instant.fromEpochMilliseconds(T0)) + + val dentist = tasks.getValue("Call the dentist") + assertThat(dentist.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(dentist.percentComplete).isEqualTo(40) + + val domain = tasks.getValue("Renew domain") + assertThat(domain.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(domain.completedAt).isEqualTo(Instant.fromEpochMilliseconds(T0 - DAY)) + + val plants = tasks.getValue("Water the plants") + assertThat(plants.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH") + assertThat(plants.timezone).isEqualTo("Europe/Berlin") + assertThat(plants.dtstart).isEqualTo(Instant.fromEpochMilliseconds(T0)) + + assertThat(tasks.getValue("Team offsite").isAllDay).isTrue() + } + + // --- uids ----------------------------------------------------------------- + + @Test + fun keepsExistingUidsAndMintsOneWhereTheLegacyRowHadNone() { + importer.importFrom(fixture()) + val tasks = taskRows() + + assertThat(tasks.getValue("Buy milk").uid).isEqualTo("a1b2c3d4-0000-4000-8000-000000000001") + // The external-account row's uid is what lets it be re-attached later. + assertThat(tasks.getValue("Ship the release").uid) + .isEqualTo("a1b2c3d4-0000-4000-8000-000000000009") + + val minted = tasks.getValue("Call the dentist").uid + assertThat(minted).isNotEmpty() + assertThat(UUID.fromString(minted).version()).isEqualTo(4) + assertThat(tasks.values.map { it.uid }.toSet()).hasSize(tasks.size) + } + + // --- the id remap --------------------------------------------------------- + + @Test + fun remapsListIdsOntoTheNewRowIds() { + importer.importFrom(fixture()) + + val lists = db.taskLists().lists().associateBy { it.list.name } + val byList = db.tasks().tasks(null, includeCompleted = true) + .groupBy { it.task.listId } + .mapValues { (_, rows) -> rows.size } + + assertThat(byList[lists.getValue("Personal").list.id]).isEqualTo(6) + assertThat(byList[lists.getValue("Hidden list").list.id]).isEqualTo(1) + assertThat(byList[lists.getValue("Work").list.id]).isEqualTo(1) + // No task kept a dmfs row id that Room never handed out. + assertThat(byList.keys).containsExactlyElementsIn(lists.values.map { it.list.id }) + } + + @Test + fun remapsParentIdsOntoTheNewRowIds() { + importer.importFrom(fixture()) + val tasks = taskRows() + + val parent = tasks.getValue("Buy milk") + val child = tasks.getValue("Gather receipts") + assertThat(child.parentId).isEqualTo(parent.id) + assertThat(db.tasks().subtasks(parent.id).map { it.task.title }).containsExactly("Gather receipts") + assertThat(tasks.values.filter { it.parentId != null }).hasSize(1) + } + + // --- alarms --------------------------------------------------------------- + + @Test + fun importsAlarmsAndSkipsEveryOtherProperty() { + importer.importFrom(fixture()) + val tasks = taskRows() + + assertThat(db.alarms().all()).hasSize(2) + + val milk = db.alarms().forTask(tasks.getValue("Buy milk").id).single() + assertThat(milk.minutesBefore).isEqualTo(30) + assertThat(milk.reference).isEqualTo(AlarmReference.DUE) + assertThat(milk.message).isNull() + + val release = db.alarms().forTask(tasks.getValue("Ship the release").id).single() + assertThat(release.minutesBefore).isEqualTo(1440) + assertThat(release.reference).isEqualTo(AlarmReference.DUE) + assertThat(release.message).isEqualTo("Ship it") + + // The category property on task 1 is not an alarm. + assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands") + } + + // --- running it ----------------------------------------------------------- + + @Test + fun runIfNeededImportsArchivesTheSourceAndThenDoesNothing() = runBlocking { + fixture(legacyFile()) + + val first = importer.runIfNeeded() + + assertThat(first).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2))) + assertThat(legacyFile().exists()).isFalse() + assertThat(archiveFile().exists()).isTrue() + assertThat(importer.isDone.first()).isTrue() + + val second = importer.runIfNeeded() + + assertThat(second).isEqualTo(ImportResult.AlreadyDone) + assertThat(taskRows()).hasSize(8) + } + + @Test + fun anInterruptedImportResumesFromTheArchiveWithoutDoubling() = runBlocking { + // The process dying between the commit and the flag write is the one gap + // the DataStore flag cannot cover on its own. Because the rename happens + // first and the import always replaces, the next run finds the archive and + // redoes the same work rather than importing a second copy. + fixture(legacyFile()) + importer.runIfNeeded() + importer.clearCompletion() + + val resumed = importer.runIfNeeded() + + assertThat(resumed).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2))) + assertThat(taskRows()).hasSize(8) + assertThat(db.taskLists().lists()).hasSize(3) + assertThat(db.alarms().all()).hasSize(2) + } + + @Test + fun runIfNeededMarksItselfDoneWhenThereIsNoLegacyDatabase() = runBlocking { + assertThat(importer.runIfNeeded()).isEqualTo(ImportResult.NothingToImport) + assertThat(importer.isDone.first()).isTrue() + assertThat(taskRows()).isEmpty() + } + + @Test + fun reimportFromTheArchiveReplacesRatherThanMerges() = runBlocking { + fixture(legacyFile()) + importer.runIfNeeded() + + val again = importer.reimportFromArchive() + + assertThat(again).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2))) + assertThat(db.taskLists().lists()).hasSize(3) + assertThat(taskRows()).hasSize(8) + assertThat(db.alarms().all()).hasSize(2) + assertThat(archiveFile().exists()).isTrue() + } + + @Test + fun replacingTwiceFromTheSameFileLeavesOneCopy() { + importer.importFrom(fixture()) + val counts = importer.importFrom(fixture(temp.newFile("second.db")), replaceExisting = true) + + assertThat(counts).isEqualTo(ImportCounts(3, 8, 2)) + assertThat(taskRows()).hasSize(8) + assertThat(db.taskLists().lists()).hasSize(3) + assertThat(db.alarms().all()).hasSize(2) + } + + private companion object { + const val FIXTURE = "tasks-v23.db" + const val T0 = 1_768_467_600_000L + const val DAY = 86_400_000L + var counter = 0 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt index 0adb920..1b8c87f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt @@ -1,13 +1,15 @@ package de.jeanlucmakiola.agendula import android.app.Application +import androidx.lifecycle.ProcessLifecycleOwner import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler -import de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder +import de.jeanlucmakiola.agendula.data.tasks.StartupGate +import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import kotlinx.coroutines.CoroutineScope @@ -38,16 +40,17 @@ class AgendulaApp : Application() { ) val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java) val scheduler = entryPoint.reminderScheduler() - // Start mirroring the stored storage mode into ProviderResolver before - // anything reads a provider. - val storageModeHolder = entryPoint.storageModeHolder() - storageModeHolder.start() + // Mirror the stored storage mode into ProviderResolver and import a + // v0.3.x install's tasks, both before anything reads a store. + val startupGate = entryPoint.startupGate() + startupGate.start() + ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint()) CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { - // Wait for the stored mode to land first. Rescheduling alarms against - // whichever provider autoMode happens to pick would arm them off the - // wrong store for a user who chose the other one. + // Wait for the stored mode and the import to land first. Rescheduling + // alarms against whichever store autoMode happens to pick would arm + // them off the wrong one — or off an empty one, mid-import. runCatching { - storageModeHolder.awaitReady() + startupGate.awaitReady() scheduler.sync() } } @@ -57,6 +60,7 @@ class AgendulaApp : Application() { @InstallIn(SingletonComponent::class) interface AppEntryPoint { fun reminderScheduler(): ReminderScheduler - fun storageModeHolder(): StorageModeHolder + fun startupGate(): StartupGate + fun databaseCheckpoint(): DatabaseCheckpoint } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index 1644fc5..48c9401 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -95,7 +95,13 @@ class SettingsPrefs @Inject constructor( * upgrading Posture A user pointed at the provider that holds their data. */ val storageMode: Flow = dataStore.data.map { p -> - p[STORAGE_MODE]?.let { runCatching { StorageMode.valueOf(it) }.getOrNull() } + p[STORAGE_MODE] + ?.let { runCatching { StorageMode.valueOf(it) }.getOrNull() } + // A stored LOCAL means "the bundled dmfs provider", and after the + // one-shot import that store no longer holds the user's tasks — its + // file has been renamed away. Read it as OWN so someone who chose + // local storage explicitly lands on the store their data is now in. + ?.let { if (it == StorageMode.LOCAL) StorageMode.OWN else it } } suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt index 26678ba..10e6f86 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt @@ -86,14 +86,14 @@ class ProviderResolver @Inject constructor( * permission**. That is a dangerous permission — it can only be there because * a previous version asked and the user agreed, which is precisely the * definition of "this person is an existing Posture A user". A fresh install - * never holds it, and gets local-first storage. + * never holds it, and gets our own store. * * Deliberately cheap and synchronous: a PackageManager lookup and a permission * check, no database probe. Settings overrides it either way. */ fun autoMode(): StorageMode { val external = resolveExternal() - return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.LOCAL + return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.OWN } /** The first installed external candidate, or `null` when none is present. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt new file mode 100644 index 0000000..fe160f6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt @@ -0,0 +1,46 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.tasks.legacy.OneShotImport +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The work that has to finish before anything reads a task store: the stored + * [StorageMode] has to reach [ProviderResolver], and a v0.3.x install's tasks + * have to be imported out of the dmfs provider's file into Room. + * + * Both are startup races with the same shape. Reading before the mode lands + * answers from `autoMode()` instead of the user's choice; reading before the + * import lands shows an upgrading user an empty app, which is the single worst + * thing this migration could do. + */ +@Singleton +class StartupGate @Inject constructor( + private val storageModeHolder: StorageModeHolder, + private val oneShotImport: OneShotImport, + @ApplicationScope private val scope: CoroutineScope, +) { + + private val ready = CompletableDeferred() + + /** Call once, from `Application.onCreate`. */ + fun start() { + storageModeHolder.start() + scope.launch { + // Opens the gate even on failure: a store that cannot be imported is + // still better shown empty than not shown at all, and the source file + // is left where it was either way. + runCatching { + storageModeHolder.awaitReady() + oneShotImport.runIfNeeded() + } + ready.complete(Unit) + } + } + + suspend fun awaitReady() = ready.await() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index e1fc33b..8bdd073 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -28,6 +28,7 @@ import kotlin.time.Instant class TasksRepositoryImpl @Inject constructor( private val dataSource: TasksDataSource, private val providerResolver: ProviderResolver, + private val startupGate: StartupGate, @IoDispatcher private val io: CoroutineDispatcher, ) : TasksRepository { @@ -132,11 +133,15 @@ class TasksRepositoryImpl @Inject constructor( } /** - * Emits an initial load, then re-loads on every provider change. The observer + * Emits an initial load, then re-loads on every store change. The observer * callback (main thread) only pokes a conflated channel; the actual blocking * query runs on [io]. */ private fun observing(load: () -> T): Flow = callbackFlow { + // Nothing reads a store before the stored mode has landed and a v0.3.x + // install has been imported — otherwise the first emission comes from the + // wrong store, or from an empty one. + startupGate.awaitReady() val ticks = Channel(Channel.CONFLATED) val handle = dataSource.registerObserver { ticks.trySend(Unit) } ticks.trySend(Unit) // prime the initial emission 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 new file mode 100644 index 0000000..0bf99ee --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt @@ -0,0 +1,384 @@ +package de.jeanlucmakiola.agendula.data.tasks.legacy + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.data.tasks.CursorColumnReader +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.PRIORITY_NONE +import de.jeanlucmakiola.agendula.domain.statusFromInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Instant + +/** How much one import moved. */ +data class ImportCounts(val lists: Int, val tasks: Int, val alarms: Int) + +/** The outcome of [OneShotImport.runIfNeeded] or [OneShotImport.reimportFromArchive]. */ +sealed interface ImportResult { + /** The DataStore flag was already set; nothing was read. */ + data object AlreadyDone : ImportResult + + /** No legacy database on disk — a fresh install, or one already archived. */ + data object NothingToImport : ImportResult + + data class Imported(val counts: ImportCounts) : ImportResult + + /** Nothing landed: the transaction rolled back and the source is untouched. */ + data class Failed(val cause: Throwable) : ImportResult +} + +/** + * Moves a v0.3.x install's tasks out of the bundled dmfs provider's SQLite file + * and into Room, once (`docs/OWN-STORE.md`, "Migrating existing users"). + * + * The file is opened read-only and directly — no provider, no ContentResolver — + * so this keeps working after `:provider` is deleted. Everything lands in one + * Room transaction with verified counts, so a failure leaves Room exactly as it + * was and the source file exactly where it was. + * + * dmfs accounts are not carried over: every list is imported as a device-only + * list (`account_id IS NULL`), including one that sat under a real account — + * only reachable if the user had pointed DAVx5 at our authority. Their task + * `uid`s are preserved, which is what lets those rows be re-attached to an + * account once sync lands. + */ +@Singleton +class OneShotImport @Inject constructor( + @ApplicationContext private val context: Context, + private val database: TasksDatabase, + private val dataStore: DataStore, +) { + + /** Whether the import has run. Set before the rename, so both guards hold. */ + val isDone: Flow = dataStore.data.map { it[IMPORT_DONE] ?: false } + + /** + * Steps 1–8 of the plan: archive `databases/tasks.db`, import it, record + * completion. Safe to call on every launch. + * + * The archive happens *before* the import, and the import always replaces, so + * that every point this can be killed at re-enters correctly: + * + * - killed after the rename, before the import — the next run finds the + * archive, imports it, and nothing is lost; + * - killed after the import commits, before the flag is written — the next + * run truncates and re-imports the same archive, so the result is the same + * rather than doubled. + * + * Renaming last would leave that second window open: the flag would be unset + * and `tasks.db` still in place, and the next launch would import it a second + * time on top of the first. That is the window the plan's "guarded by a + * DataStore flag *and* by the rename" is meant to close, and only this order + * actually closes it. + */ + suspend fun runIfNeeded(): ImportResult = withContext(Dispatchers.IO) { + if (isDone.first()) return@withContext ImportResult.AlreadyDone + val source = archivedSource() ?: run { + markDone() + return@withContext ImportResult.NothingToImport + } + val counts = runCatching { importFrom(source, replaceExisting = true) } + .getOrElse { return@withContext ImportResult.Failed(it) } + markDone() + ImportResult.Imported(counts) + } + + /** + * The rollback path: re-run against the archived `tasks.db.imported`, + * truncating the Room tables first so a second attempt replaces rather than + * merges. Reached by a targeted fix release, not by the app on its own. + */ + 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) } + markDone() + ImportResult.Imported(counts) + } + + /** + * The legacy database as `tasks.db.imported`, archiving it first if it is + * still under its live name. `null` when there is nothing to import. + */ + private fun archivedSource(): File? { + val archive = context.getDatabasePath(ARCHIVE_NAME) + if (archive.exists()) return archive + val live = context.getDatabasePath(LEGACY_NAME) + if (!live.exists()) return null + return if (archive(live)) archive else live + } + + /** Clears the completion flag so [runIfNeeded] will import again. */ + suspend fun clearCompletion() { + dataStore.edit { it.remove(IMPORT_DONE) } + } + + /** + * Steps 2–6 against an arbitrary dmfs database: read it read-only, then write + * everything in one Room transaction whose counts are verified before it + * commits. Blocking — call it off the main thread. + */ + fun importFrom(source: File, replaceExisting: Boolean = false): ImportCounts { + val snapshot = SQLiteDatabase.openDatabase(source.path, null, SQLiteDatabase.OPEN_READONLY) + .use(::read) + return database.runInTransaction { + if (replaceExisting) truncate() + val baseline = tableCounts() + val written = write(snapshot) + verify(written, baseline) + written + } + } + + // --- reading the dmfs file ------------------------------------------------ + + private fun read(db: SQLiteDatabase): LegacySnapshot { + val lists = mutableListOf() + db.rawQuery("SELECT * FROM Lists ORDER BY _id", null).use { cursor -> + val r = CursorColumnReader(cursor) + while (cursor.moveToNext()) { + val id = r.getLong("_id") ?: continue + lists += LegacyList( + id = id, + entity = TaskListEntity( + name = r.getString("list_name").orEmpty(), + color = r.getInt("list_color") ?: 0, + accountId = null, + isVisible = r.getBoolean("visible"), + isSynced = r.getBoolean("sync_enabled"), + owner = r.getString("list_owner"), + ), + ) + } + } + + val rows = mutableListOf() + db.rawQuery("SELECT * FROM Tasks WHERE _deleted IS NULL OR _deleted = 0 ORDER BY _id", null) + .use { cursor -> + val r = CursorColumnReader(cursor) + while (cursor.moveToNext()) { + val id = r.getLong("_id") ?: continue + rows += LegacyTaskRow( + id = id, + listId = r.getLong("list_id") ?: continue, + parentId = r.getLong("parent_id"), + masterId = r.getLong("original_instance_id"), + recurrenceId = r.instant("original_instance_time"), + entity = TaskEntity( + listId = 0, + uid = r.getString("_uid") ?: UUID.randomUUID().toString(), + title = r.getString("title"), + description = r.getString("description"), + location = r.getString("location"), + url = r.getString("url"), + color = r.getInt("task_color"), + status = statusFromInt(r.getInt("status")), + percentComplete = r.getInt("percent_complete"), + completedAt = r.instant("completed"), + priority = r.getInt("priority") ?: PRIORITY_NONE, + classification = r.getInt("class"), + dtstart = r.instant("dtstart"), + due = r.instant("due"), + duration = r.getString("duration"), + isAllDay = r.getBoolean("is_allday"), + timezone = r.getString("tz"), + rrule = r.getString("rrule"), + rdate = r.getString("rdate"), + exdate = r.getString("exdate"), + createdAt = r.instant("created"), + lastModified = r.instant("last_modified"), + ), + ) + } + } + + val alarms = mutableListOf() + db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null) + .use { cursor -> + val r = CursorColumnReader(cursor) + while (cursor.moveToNext()) { + if (r.getString("mimetype") != ALARM_MIMETYPE) continue + val taskId = r.getLong("task_id") ?: continue + val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue + alarms += LegacyAlarm( + taskId = taskId, + minutesBefore = minutes, + reference = if (r.getString("data1")?.trim() == REFERENCE_START) { + AlarmReference.START + } else { + AlarmReference.DUE + }, + message = r.getString("data2"), + ) + } + } + + return LegacySnapshot(lists, rows, alarms) + } + + // --- writing into Room ---------------------------------------------------- + + /** + * dmfs `list_id`, `parent_id` and `original_instance_id` are old row ids, and + * Room mints its own on insert, so every one of them is remapped through the + * ids the inserts hand back. Tasks are inserted with their links cleared and + * a second pass sets them, because a parent may be a higher `_id` than its + * child. + */ + private fun write(snapshot: LegacySnapshot): ImportCounts { + val listDao = database.taskLists() + val taskDao = database.tasks() + val alarmDao = database.alarms() + + val listIds = snapshot.lists.associate { it.id to listDao.insert(it.entity) } + + // A task whose list is missing is already invisible in dmfs — its tasks + // view inner-joins Lists — so dropping it loses nothing the user could see. + val importable = snapshot.tasks.filter { it.listId in listIds } + val importableIds = importable.mapTo(mutableSetOf()) { it.id } + val taskIds = mutableMapOf() + val inserted = mutableListOf>() + val seen = mutableSetOf>() + + for (row in importable) { + val listId = listIds.getValue(row.listId) + val overrides = row.masterId != null && row.masterId in importableIds + val recurrenceId = row.recurrenceId.takeIf { overrides } + // A duplicate (list, uid, recurrence) would abort the whole import on + // the unique index; a fresh uid costs the row nothing it still has. + val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) } + ?: UUID.randomUUID().toString() + val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId) + val newId = taskDao.insert(entity) + taskIds[row.id] = newId + inserted += row to entity.copy(id = newId) + } + + for ((row, entity) in inserted) { + val parentId = row.parentId?.let(taskIds::get) + val masterId = row.masterId?.let(taskIds::get) + if (parentId == null && masterId == null) continue + taskDao.update(entity.copy(parentId = parentId, masterId = masterId)) + } + + var alarmCount = 0 + for (alarm in snapshot.alarms) { + val taskId = taskIds[alarm.taskId] ?: continue + alarmDao.insert( + TaskAlarmEntity( + taskId = taskId, + minutesBefore = alarm.minutesBefore, + reference = alarm.reference, + message = alarm.message, + ), + ) + alarmCount++ + } + + return ImportCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount) + } + + private fun verify(written: ImportCounts, before: ImportCounts) { + 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}" + } + } + + /** Dropping the lists takes their tasks and alarms with them, by cascade. */ + private fun truncate() { + val listDao = database.taskLists() + listDao.lists().forEach { listDao.delete(it.list.id) } + } + + private fun tableCounts() = ImportCounts( + 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 + } + + // --- the source file ------------------------------------------------------ + + /** + * Renames the dmfs file, sidecars included, to `tasks.db.imported`. Never + * deletes it: for one release it is the only way back if the import turns out + * to be wrong on someone's device. + */ + private fun archive(source: File): Boolean { + val target = File(source.parentFile, ARCHIVE_NAME) + if (!source.renameTo(target)) return false + for (suffix in SIDECARS) { + val sidecar = File(source.path + suffix) + if (sidecar.exists()) sidecar.renameTo(File(target.path + suffix)) + } + return true + } + + private suspend fun markDone() { + dataStore.edit { it[IMPORT_DONE] = true } + } + + private fun CursorColumnReader.instant(name: String): Instant? = + getLong(name)?.let(Instant::fromEpochMilliseconds) + + companion object { + const val LEGACY_NAME = "tasks.db" + const val ARCHIVE_NAME = "tasks.db.imported" + + private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm" + private const val REFERENCE_START = "2" + private val SIDECARS = listOf("-journal", "-wal", "-shm") + private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done") + } +} + +private class LegacySnapshot( + val lists: List, + val tasks: List, + val alarms: List, +) + +private class LegacyList(val id: Long, val entity: TaskListEntity) + +private class LegacyTaskRow( + val id: Long, + val listId: Long, + val parentId: Long?, + val masterId: Long?, + val recurrenceId: Instant?, + val entity: TaskEntity, +) + +private class LegacyAlarm( + val taskId: Long, + val minutesBefore: Int, + val reference: AlarmReference, + val message: String?, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt new file mode 100644 index 0000000..4c3487d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt @@ -0,0 +1,35 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Folds the write-ahead log back into the database file when the app goes to the + * background. + * + * Room runs in WAL mode, and Auto Backup copies files without checkpointing — so + * a `-wal` sidecar can hold writes the backed-up `.db` does not. The backup rules + * carry all three files, which already makes a restore consistent; this narrows + * the window further by ensuring the `.db` alone is usually current, which is what + * a restore onto a device that drops the sidecars falls back to. + */ +@Singleton +class DatabaseCheckpoint @Inject constructor( + private val database: TasksDatabase, + @ApplicationScope private val scope: CoroutineScope, +) : DefaultLifecycleObserver { + + override fun onStop(owner: LifecycleOwner) { + scope.launch(Dispatchers.IO) { + runCatching { + database.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").close() + } + } + } +} diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 87d1f20..dbfacf5 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,4 +1,21 @@ - + + + + + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index c6ba7b9..81ba3d0 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,8 +1,16 @@ - + + + + + + + + + diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt index edf4494..fd6a171 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -66,8 +66,8 @@ class ProviderResolverTest { inner class AutoMode { @Test - fun `a fresh install with nothing else present is local`() { - assertThat(resolver().autoMode()).isEqualTo(StorageMode.LOCAL) + fun `a fresh install with nothing else present gets our own store`() { + assertThat(resolver().autoMode()).isEqualTo(StorageMode.OWN) } @Test @@ -83,8 +83,8 @@ class ProviderResolverTest { @Test fun `OpenTasks merely installed is not enough`() { // Someone who has OpenTasks for unrelated reasons, and never granted us - // anything, has no data with us there. Local-first is right for them. - assertThat(resolver(installed = openTasksInstalled).autoMode()).isEqualTo(StorageMode.LOCAL) + // anything, has no data with us there. Our own store is right for them. + assertThat(resolver(installed = openTasksInstalled).autoMode()).isEqualTo(StorageMode.OWN) } @Test @@ -93,7 +93,7 @@ class ProviderResolverTest { installed = openTasksInstalled, granted = setOf(openTasks.readPermission), ) - assertThat(resolver.autoMode()).isEqualTo(StorageMode.LOCAL) + assertThat(resolver.autoMode()).isEqualTo(StorageMode.OWN) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b40b5e..cd55c5d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -113,6 +113,8 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio # Lifecycle compose (for collectAsStateWithLifecycle) androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" } +# ProcessLifecycleOwner — the WAL checkpoint hangs off ON_STOP. +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntime" } # Glance — Jetpack home-screen widgets (Compose-like RemoteViews) androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" }