feat(store): import the dmfs database and make Room the default
Phase 4 of docs/OWN-STORE.md. OneShotImport reads databases/tasks.db directly — read-only, no provider, no ContentResolver — and writes it into Room in one verified transaction. dmfs row ids are remapped in two passes, because a parent can carry a higher _id than its child. The archive happens before the import, not after, and the import always replaces. That is what actually closes the crash window the plan's "flag *and* rename" is meant to cover: renaming last leaves the flag unset with tasks.db still in place, so the next launch imports a second copy. In this order every kill point re-enters correctly. Recurrence overrides are carried across as master_id/recurrence_id rather than ignored. dmfs stores them as ordinary rows sharing their master's _uid, so importing one as a second master would collide on the unique index and abort the whole import. autoMode now answers OWN, and a stored LOCAL reads as OWN — after the import the dmfs file has been renamed away, so someone who chose local storage explicitly must land on the store their data is now in. StartupGate holds the first store read until the mode has landed and the import has run; showing an upgrading user an empty app is the worst thing this migration could do. The backup rules take the database with its WAL sidecars and exclude the archive, and the app checkpoints on ON_STOP.
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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<Preferences>
|
||||
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<String, TaskEntity> =
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,13 @@ class SettingsPrefs @Inject constructor(
|
||||
* upgrading Posture A user pointed at the provider that holds their data.
|
||||
*/
|
||||
val storageMode: Flow<StorageMode?> = 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 }
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<Unit>()
|
||||
|
||||
/** 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()
|
||||
}
|
||||
@@ -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 <T> observing(load: () -> T): Flow<T> = 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<Unit>(Channel.CONFLATED)
|
||||
val handle = dataSource.registerObserver { ticks.trySend(Unit) }
|
||||
ticks.trySend(Unit) // prime the initial emission
|
||||
|
||||
@@ -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<Preferences>,
|
||||
) {
|
||||
|
||||
/** Whether the import has run. Set before the rename, so both guards hold. */
|
||||
val isDone: Flow<Boolean> = 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<ImportCounts> {
|
||||
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<LegacyList>()
|
||||
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<LegacyTaskRow>()
|
||||
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<LegacyAlarm>()
|
||||
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<Long, Long>()
|
||||
val inserted = mutableListOf<Pair<LegacyTaskRow, TaskEntity>>()
|
||||
val seen = mutableSetOf<Triple<Long, String, Instant?>>()
|
||||
|
||||
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<LegacyList>,
|
||||
val tasks: List<LegacyTaskRow>,
|
||||
val alarms: List<LegacyAlarm>,
|
||||
)
|
||||
|
||||
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?,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<!-- No file-based backups; settings live in DataStore which is backed up by default. -->
|
||||
<!--
|
||||
Agendula's own task store. Room runs in WAL mode and Auto Backup copies
|
||||
files without checkpointing, so the `-wal` sidecar can hold writes the
|
||||
`.db` alone does not — all three go in together, and the app checkpoints
|
||||
on ON_STOP so a restore is consistent either way.
|
||||
-->
|
||||
<include domain="database" path="agendula-tasks.db" />
|
||||
<include domain="database" path="agendula-tasks.db-wal" />
|
||||
<include domain="database" path="agendula-tasks.db-shm" />
|
||||
|
||||
<!--
|
||||
The archived dmfs database. Kept on the device for one release as the
|
||||
import's rollback path, but it is a copy of data that has already been
|
||||
imported, so backing it up would double every task in the archive.
|
||||
-->
|
||||
<exclude domain="database" path="tasks.db.imported" />
|
||||
|
||||
<!-- Settings live in DataStore, which is backed up by default. -->
|
||||
</full-backup-content>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- Allow DataStore backup, exclude nothing extra. -->
|
||||
<!-- See backup_rules.xml: the WAL sidecars travel with the database. -->
|
||||
<include domain="database" path="agendula-tasks.db" />
|
||||
<include domain="database" path="agendula-tasks.db-wal" />
|
||||
<include domain="database" path="agendula-tasks.db-shm" />
|
||||
<exclude domain="database" path="tasks.db.imported" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<include domain="database" path="agendula-tasks.db" />
|
||||
<include domain="database" path="agendula-tasks.db-wal" />
|
||||
<include domain="database" path="agendula-tasks.db-shm" />
|
||||
<exclude domain="database" path="tasks.db.imported" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user