Merge remote-tracking branch 'origin/release/v1.0.0' into feat/caldav-sync

# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt
#	app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt
#	app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt
#	app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt
#	docs/PRIVACY.md
#	gradle/libs.versions.toml
This commit is contained in:
2026-09-23 13:52:33 +02:00
45 changed files with 1343 additions and 174 deletions
+13 -16
View File
@@ -27,10 +27,11 @@ android {
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
versionCode = 400
versionName = "0.4.0"
// PATCH from versionName, e.g. 1.0.0 -> 10000). Releases were flagged as
// pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the
// pipeline graduates it on its own. See docs/RELEASING.md.
versionCode = 10000
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -190,6 +191,7 @@ dependencies {
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler)
@@ -202,9 +204,7 @@ dependencies {
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
// RFC 5545 recurrence expansion, in-process. Pinned at 0.12.2 — 0.16.0
// removed RecurrenceSet. rfc5545-datetime comes with it and is part of its
// API surface, so it isn't declared separately.
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
implementation(libs.dmfs.lib.recur)
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
@@ -225,17 +225,14 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core)
implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
implementation(libs.floret.core.time)
implementation(libs.floret.core.reminders)
implementation(libs.floret.core.locale)
implementation(libs.floret.core.crash)
implementation(libs.floret.identity)
implementation(libs.floret.components)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
+4 -10
View File
@@ -5,17 +5,11 @@
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
# nothing calls it directly — Room then throws InstantiationException, reported
# as "Failed to create an instance of ...". We pull Room in transitively via
# Glance -> WorkManager, whose WorkDatabase is built by WorkManagerInitializer
# at startup, so the app died on launch in every minified build (issue #1).
# as "Failed to create an instance of ...". This first bit us through a
# transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup:
# issue #1); Glance is gone and Room is now our own task store, so the rule
# matters more, not less — TasksDatabase is built on the first store read.
-keep class * extends androidx.room.RoomDatabase { <init>(); }
# WorkManager likewise looks its workers up by name and calls this constructor
# reflectively — same pruning, but it only bites once a worker actually runs
# (Glance's widget updates), so keep it explicitly rather than wait for it.
-keep class * extends androidx.work.ListenableWorker {
<init>(android.content.Context, androidx.work.WorkerParameters);
}
# Compose Compiler may keep its own; defaults are fine
-dontwarn org.jetbrains.annotations.**
@@ -5,6 +5,7 @@ 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.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
@@ -319,12 +320,15 @@ class RoomTasksDataSourceTest {
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
val id = source.insertTask(form(due = now + 1.days))
// The whole reminder, not just the minute count: collapsing it to a bare
// Int is what fired an imported START-referenced alarm off DUE, and an
// alarm this seam sets from the UI is always due-referenced.
source.setAlarm(id, 30)
assertThat(source.alarms()[id]).isEqualTo(30)
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 30))
source.setAlarm(id, 60)
assertThat(db.alarms().forTask(id)).hasSize(1)
assertThat(source.alarms()[id]).isEqualTo(60)
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60))
source.setAlarm(id, null)
assertThat(source.alarms()).doesNotContainKey(id)
@@ -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"
}
+11 -3
View File
@@ -113,16 +113,24 @@
<!-- Re-sync reminders when an external provider changes — DAVx5 pulling
tasks while Agendula is backgrounded. External mode only: in OWN mode
nothing outside the app can change our data, and Room's
InvalidationTracker covers our own writes. An intent-filter host must
be a literal, so both external authorities are listed. -->
InvalidationTracker covers our own writes.
An intent-filter host must be a literal, so both external authorities
are listed — one filter each. Two <data> tags in a single filter would
mean the same thing (Android takes the cross product of every data
attribute in a filter), but reads as if it might not, which is what
lint's IntentFilterUniqueDataAttributes warns about. -->
<receiver
android:name=".data.reminders.ProviderChangeReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PROVIDER_CHANGED" />
<data android:scheme="content" android:host="org.tasks.opentasks" />
<data android:scheme="content" android:host="org.dmfs.tasks" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PROVIDER_CHANGED" />
<data android:scheme="content" android:host="org.tasks.opentasks" />
</intent-filter>
</receiver>
<!-- Sync plumbing. The stub provider exists only to give the sync
@@ -12,7 +12,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
@@ -30,6 +30,7 @@ import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Provider
/**
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
@@ -39,7 +40,10 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var demoSeeder: DemoSeeder
// A Provider, not the seeder itself: the call site below is dead in a release
// build (`BuildConfig.DEBUG` is a compile-time false), and injecting the
// instance would still construct one on every launch of the shipped app.
@Inject lateinit var demoSeeder: Provider<DemoSeeder>
@Inject lateinit var accounts: AccountRepository
@@ -93,7 +97,7 @@ class MainActivity : ComponentActivity() {
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
// local (non-syncing) demo list once; no-op without the extra.
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
lifecycleScope.launch { runCatching { demoSeeder.get().seed() } }
}
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
@@ -103,6 +103,14 @@ abstract class DataBindModule {
@Binds
@Singleton
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
// 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
@@ -48,3 +48,17 @@ annotation class CredentialsDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncStateDataStore
/**
* 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
@@ -18,8 +18,9 @@ import javax.inject.Singleton
data class ExportResult(val fileCount: Int, val taskListNames: List<String>)
/**
* Why an export failed, as a value rather than a message: the UI ships in eleven
* locales, so the wording has to come from a string resource.
* Why an export failed, as a value rather than a message: the UI is translated
* (see `res/xml/locales_config.xml`), so the wording has to come from a string
* resource rather than being built here.
*/
enum class ExportFailure {
FOLDER_UNAVAILABLE,
@@ -79,7 +79,6 @@ class TaskNotifier @Inject constructor(
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.net.Uri
import androidx.core.net.toUri
/**
* The subset of the OpenTasks `TaskContract` that Agendula uses, vendored as
@@ -150,7 +151,7 @@ object TasksContract {
const val TYPE_MESSAGE = 1
}
fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}")
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
// --- status values (TaskColumns.STATUS_*) --------------------------------
const val STATUS_NEEDS_ACTION = 0
@@ -161,12 +162,12 @@ object TasksContract {
/** Priority 0 means "no priority"; 1 is highest, 9 lowest (iCalendar). */
const val PRIORITY_NONE = 0
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
fun authorityUri(authority: String): Uri = "content://$authority".toUri()
fun listsUri(authority: String): Uri = "content://$authority/${Lists.PATH}".toUri()
fun listUri(authority: String, listId: Long): Uri =
Uri.parse("content://$authority/${Lists.PATH}/$listId")
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
"content://$authority/${Lists.PATH}/$listId".toUri()
fun tasksUri(authority: String): Uri = "content://$authority/${Tasks.PATH}".toUri()
fun instancesUri(authority: String): Uri = "content://$authority/${Instances.PATH}".toUri()
/**
* A single occurrence. Updating through this URI is how a *recurring* task is
@@ -175,7 +176,7 @@ object TasksContract {
* the series anchor, which is what writing to `tasks/<id>` would do.
*/
fun instanceUri(authority: String, instanceId: Long): Uri =
Uri.parse("content://$authority/${Instances.PATH}/$instanceId")
"content://$authority/${Instances.PATH}/$instanceId".toUri()
/** Append the sync-adapter params required to write local-account rows. */
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
@@ -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")
}
}
@@ -20,7 +20,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.common.OnResume
@@ -36,7 +36,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.accounts.ProviderLogo
@@ -65,7 +65,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.Task
@@ -78,7 +78,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.time.DayWindow
@@ -47,7 +47,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.export.ExportFailure
@@ -72,7 +72,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.Priority
@@ -8,7 +8,7 @@ import androidx.compose.animation.scaleOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@@ -26,7 +26,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.export.ExportOutcome
@@ -20,7 +20,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
@@ -38,7 +38,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
@@ -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
@@ -87,7 +87,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.time.DayWindow
@@ -204,7 +204,7 @@ fun TaskListScreen(
},
bottomBar = {
if (showBottomAddBar) {
listId?.let { id -> QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, id) }) }
QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, listId) })
}
},
) { inner ->
-11
View File
@@ -15,9 +15,6 @@
<string name="smart_completed">Abgeschlossen</string>
<string name="task_detail_title">Aufgabe</string>
<string name="task_detail_not_found">Diese Aufgabe ist nicht mehr verfügbar.</string>
<string name="edit_task_title">Aufgabe bearbeiten</string>
<string name="new_task_title">Neue Aufgabe</string>
<string name="field_title">Titel</string>
<string name="edit_save_failed">Konnte nicht gespeichert werden. Versuche es erneut.</string>
<string name="add_task_hint">Aufgabe hinzufügen</string>
<string name="add_subtask_hint">Unteraufgabe hinzufügen</string>
@@ -36,11 +33,8 @@
<string name="priority_low">Niedrig</string>
<string name="priority_medium">Mittel</string>
<string name="priority_high">Hoch</string>
<string name="detail_mark_complete">Als abgeschlossen markieren</string>
<string name="detail_mark_incomplete">Als nicht abgeschlossen markieren</string>
<string name="detail_list">Liste</string>
<string name="detail_due">Fällig</string>
<string name="detail_start">Beginn</string>
<string name="detail_priority">Priorität</string>
<string name="detail_progress">Fortschritt</string>
<string name="detail_subtasks">Unteraufgaben</string>
@@ -98,7 +92,6 @@
<string name="smart_overdue">Überfällig</string>
<string name="smart_upcoming">Anstehend</string>
<string name="smart_all">Alle</string>
<string name="open_count">%1$d offen</string>
<string name="home_today_progress">%1$d von %2$d erledigt</string>
<string name="home_today_remaining">%1$d übrig</string>
<string name="home_today_all_done">Alles erledigt 🎉</string>
@@ -108,7 +101,6 @@
<string name="home_due_tomorrow">Morgen</string>
<string name="home_search_hint">Aufgaben suchen</string>
<string name="home_search_clear">Suche zurücksetzen</string>
<string name="home_search_close">Suche schließen</string>
<string name="home_search_empty">Keine Aufgaben passen zu „%1$s“</string>
<string name="reminder_due_at">Fälligkeiten %1$s</string>
<string name="reminder_channel_name">Aufgaben Erinnerungen</string>
@@ -118,8 +110,6 @@
<string name="onboarding_permission_title">Erlaube Zugriff auf deine Aufgaben</string>
<string name="onboarding_permission_body">Agendula benötigt die Berechtigung, deine Aufgaben zu lesen und zu schreiben. Das ist die einzige Berechtigung, die es jemals anfragt.</string>
<string name="onboarding_permission_button">Zugriff gewähren</string>
<string name="onboarding_install_opentasks">Installiere OpenTasks</string>
<string name="onboarding_install_tasksorg">Installiere tasks.org</string>
<string name="reminder_onboarding_title">Verpasse nichts, was fällig ist</string>
<string name="reminder_onboarding_body">Aufgaben-Apps senden selbst keine Erinnerungen, daher liefert Agendula sie für dich. Schalte sie ein, um eine Benachrichtigung zu erhalten, wenn eine Aufgabe fällig ist.</string>
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
@@ -162,7 +152,6 @@
<string name="settings_exact_alarms">Exakter Zeitpunkt</string>
<string name="settings_exact_alarms_allowed">Die Erinnerungen erfolgen zum exakten Zeitpunkt</string>
<string name="settings_exact_alarms_blocked">Blockiert – tippe, um exakte Erinnerungen zu erlauben</string>
<string name="settings_section_tasks">Aufgaben</string>
<string name="settings_default_list">Standard-Liste</string>
<string name="settings_default_list_first">Erste verfügbare Felder</string>
<string name="settings_add_subtask_row">Zeile \"Unteraufgaben hinzufügen\" anzeigen</string>
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Agendula</string>
<string name="app_tagline">Um app de tarefas em Material 3 Expressive. \nFlorescendo.</string>
<string name="task_untitled">Tarefa sem título</string>
<string name="back">Voltar</string>
<string name="save">Salvar</string>
@@ -14,9 +13,6 @@
<string name="task_detail_title">Tarefa</string>
<string name="task_detail_not_found">Esta tarefa não existe mais.</string>
<string name="edit">Editar</string>
<string name="edit_task_title">Editar tarefa</string>
<string name="new_task_title">Nova tarefa</string>
<string name="field_title">Título</string>
<string name="field_description">Descrição</string>
<string name="edit_save_failed">Não foi possível salvar. Tente novamente.</string>
<string name="add_task_hint">Adicionar tarefa</string>
@@ -37,11 +33,8 @@
<string name="priority_low">Baixa</string>
<string name="priority_medium">Média</string>
<string name="priority_high">Alta</string>
<string name="detail_mark_complete">Marcar como concluído</string>
<string name="detail_mark_incomplete">Marcar como não concluído</string>
<string name="detail_list">Lista</string>
<string name="detail_due">Prazo</string>
<string name="detail_start">Inicia</string>
<string name="detail_priority">Prioridade</string>
<string name="detail_progress">Progresso</string>
<string name="detail_subtasks">Subtarefas</string>
@@ -100,7 +93,6 @@
<string name="smart_overdue">Prazo expirado</string>
<string name="smart_upcoming">A seguir</string>
<string name="smart_all">Tudo</string>
<string name="open_count">%1$d abertos</string>
<string name="home_today_progress">%1$d de %2$d concluídos</string>
<string name="home_today_remaining">%1$d restantes</string>
<string name="home_today_all_done">Tudo pronto 🎉</string>
@@ -110,7 +102,6 @@
<string name="home_due_tomorrow">Amanhã</string>
<string name="home_search_hint">Buscar tarefas</string>
<string name="home_search_clear">Limpar buscas</string>
<string name="home_search_close">Fechar busca</string>
<string name="home_search_empty">Nenhuma tarefa corresponde a \"%1$s\"</string>
<string name="reminder_due_at">Termina em %1$s</string>
<string name="reminder_channel_name">Lembretes de tarefas</string>
@@ -120,8 +111,6 @@
<string name="onboarding_permission_title">Permitir acesso às suas tarefas</string>
<string name="onboarding_permission_body">O Agendula precisa de permissão para ler e salvar suas tarefas. É tudo o que ele pede.</string>
<string name="onboarding_permission_button">Conceder acesso às tarefas</string>
<string name="onboarding_install_opentasks">Instalar OpenTasks</string>
<string name="onboarding_install_tasksorg">Instalar tasks.org</string>
<string name="reminder_onboarding_title">Nunca perca seus prazos</string>
<string name="reminder_onboarding_body">Apps de tarefas não enviam lembretes sozinhos, então o Agendula os entrega para você. Ative para receber uma notificação de quando uma tarefa estiver no prazo.</string>
<string name="reminder_onboarding_enable_button">Ativar lembretes</string>
@@ -166,7 +155,6 @@
<string name="settings_exact_alarms">Na hora</string>
<string name="settings_exact_alarms_allowed">Lembretes disparam na mesma hora</string>
<string name="settings_exact_alarms_blocked">Bloqueado — toque para permitir lembretes na hora</string>
<string name="settings_section_tasks">Tarefas</string>
<string name="settings_default_list">Lista padrão</string>
<string name="settings_default_list_first">Primeira lista disponível</string>
<string name="settings_add_subtask_row">Mostrar uma linha \"adicionar subtarefa\"</string>
-1
View File
@@ -1,6 +1,5 @@
<resources>
<!-- Seed color: warm mauve. Material 3 derives Light & Dark schemes from this. -->
<color name="seed">#FF7A5C6B</color>
<!-- Adaptive icon background -->
<color name="ic_launcher_background">#FF7A5C6B</color>
</resources>
+27 -13
View File
@@ -1,6 +1,5 @@
<resources>
<string name="app_name">Agendula</string>
<string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</string>
<!-- Generic -->
<string name="task_untitled">Untitled task</string>
@@ -21,9 +20,6 @@
<string name="edit">Edit</string>
<!-- Task edit -->
<string name="edit_task_title">Edit task</string>
<string name="new_task_title">New task</string>
<string name="field_title">Title</string>
<string name="field_description">Description</string>
<string name="edit_save_failed">Could not save. Try again.</string>
@@ -52,11 +48,8 @@
<string name="priority_high">High</string>
<!-- Task detail -->
<string name="detail_mark_complete">Mark complete</string>
<string name="detail_mark_incomplete">Mark not complete</string>
<string name="detail_list">List</string>
<string name="detail_due">Due</string>
<string name="detail_start">Starts</string>
<string name="detail_priority">Priority</string>
<string name="detail_progress">Progress</string>
<string name="detail_subtasks">Subtasks</string>
@@ -160,7 +153,6 @@
<string name="smart_overdue">Overdue</string>
<string name="smart_upcoming">Upcoming</string>
<string name="smart_all">All</string>
<string name="open_count">%1$d open</string>
<!-- Home: Today progress hero -->
<string name="home_today_progress">%1$d of %2$d done</string>
@@ -176,7 +168,6 @@
<!-- Home: search -->
<string name="home_search_hint">Search tasks</string>
<string name="home_search_clear">Clear search</string>
<string name="home_search_close">Close search</string>
<string name="home_search_empty">No tasks match “%1$s”</string>
<!-- Reminders -->
@@ -190,8 +181,6 @@
<string name="onboarding_permission_title">Allow access to your tasks</string>
<string name="onboarding_permission_body">Agendula needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string>
<string name="onboarding_permission_button">Grant task access</string>
<string name="onboarding_install_opentasks">Install OpenTasks</string>
<string name="onboarding_install_tasksorg">Install tasks.org</string>
<!-- First run: welcome -->
<string name="onboarding_welcome_title">Everything you meant to do</string>
@@ -304,7 +293,6 @@
<string name="settings_exact_alarms">Exact timing</string>
<string name="settings_exact_alarms_allowed">Reminders fire at the exact time</string>
<string name="settings_exact_alarms_blocked">Blocked — tap to allow exact reminders</string>
<string name="settings_section_tasks">Tasks</string>
<string name="settings_default_list">Default list</string>
<string name="settings_default_list_first">First available list</string>
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
@@ -318,7 +306,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>
@@ -326,6 +314,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>