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:
@@ -37,6 +37,26 @@ jobs:
|
||||
- name: Reproducible-release invariant
|
||||
run: bash scripts/check_reproducible_release.sh
|
||||
|
||||
# Also cheap, also always-on. Two failures in one: the script exits
|
||||
# non-zero if this version's changelog is over the character limit
|
||||
# F-Droid truncates at, and the porcelain check below catches a
|
||||
# CHANGELOG.md edit whose generated fastlane file was never committed —
|
||||
# which used to degrade silently into "the official listing shows the
|
||||
# previous version's notes".
|
||||
- name: Changelog fits F-Droid, and is committed
|
||||
run: |
|
||||
set -e
|
||||
bash scripts/sync_changelog_to_fastlane.sh
|
||||
DIRTY=$(git status --porcelain fastlane/metadata/android/en-US/changelogs)
|
||||
if [ -n "$DIRTY" ]; then
|
||||
echo "$DIRTY"
|
||||
echo "ERROR: the generated fastlane changelog is not what is committed." >&2
|
||||
echo "Run scripts/sync_changelog_to_fastlane.sh and commit the result, so" >&2
|
||||
echo "the official F-Droid listing shows this version's notes rather than" >&2
|
||||
echo "the previous one's." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Decide whether anything that affects the app build changed. Docs, store
|
||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||
# the SDK + Gradle work below but still report a green `ci`.
|
||||
|
||||
@@ -110,6 +110,16 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
# Before a single Gradle task runs: F-Droid truncates the in-client
|
||||
# changelog, so an over-long one would reach users cut off mid-sentence.
|
||||
# The script exits non-zero past the limit. Cheap enough to sit in the
|
||||
# gate job, where failing costs nothing and publishes nothing — the step
|
||||
# further down that regenerates the file for the repo would otherwise be
|
||||
# the first thing to notice, after the build and the signing.
|
||||
- name: Changelog fits F-Droid
|
||||
if: steps.v.outputs.is_release == 'true'
|
||||
run: bash scripts/sync_changelog_to_fastlane.sh
|
||||
|
||||
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||
# re-uploads the existing index (recovery path).
|
||||
|
||||
@@ -7,6 +7,21 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0] - 2026-09-21
|
||||
|
||||
### Added
|
||||
- Agendula keeps your tasks itself now — nothing else to install. Manage lists
|
||||
in the app, repeat tasks, and export any list as iCalendar.
|
||||
- Settings → Storage picks where tasks live, and offers to copy them over from
|
||||
OpenTasks or tasks.org.
|
||||
|
||||
### Changed
|
||||
- Another task app is now optional. Already use one? Nothing changes on update.
|
||||
|
||||
### Fixed
|
||||
- Edited due times no longer revert, all-day tasks no longer drift a day, and
|
||||
per-task reminders are saved.
|
||||
|
||||
## [0.4.0] - 2026-08-31
|
||||
|
||||
### Added
|
||||
|
||||
@@ -38,6 +38,12 @@ apps, so there is no authority to clash over and no permission to grant. It
|
||||
the other, and if you already sync through a provider, that keeps working exactly
|
||||
as it did.
|
||||
|
||||
Switching between the two moves nothing — each store keeps its own tasks — so
|
||||
Settings → Storage asks before it switches, and offers to **copy** a provider's
|
||||
tasks into Agendula's own store when you want to move over. The copy is taken
|
||||
once and the originals stay where they are; it is not an ongoing sync in either
|
||||
direction.
|
||||
|
||||
Recurring tasks are expanded per RFC 5545, and everything the schema does not
|
||||
model is round-tripped verbatim rather than dropped — so passing your tasks
|
||||
through Agendula does not quietly lose fields a server sent.
|
||||
|
||||
+13
-16
@@ -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)
|
||||
|
||||
Vendored
+4
-10
@@ -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.**
|
||||
|
||||
+6
-2
@@ -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)
|
||||
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.transfer
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import javax.inject.Provider
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The copy out of an external provider and into Room — the upgrade path every
|
||||
* released install actually needs, since no release ever bundled the provider
|
||||
* `OneShotImport` reads (see `docs/OWN-STORE.md`).
|
||||
*
|
||||
* The source is a fake [TasksDataSource] rather than a live OpenTasks: what is
|
||||
* worth testing is the write half — id remapping, uid collisions, verified
|
||||
* counts, the once-only guard — and pinning that to a device with a third-party
|
||||
* app installed would mean it never ran. Instrumented all the same, because the
|
||||
* destination is a real Room database in a real transaction.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExternalImportTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var prefs: DataStore<Preferences>
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var source: FakeExternalStore
|
||||
private lateinit var importer: ExternalImport
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
prefs = PreferenceDataStoreFactory.create(scope = scope) {
|
||||
temp.newFile("transfer-${counter++}.preferences_pb").also(File::delete)
|
||||
}
|
||||
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
source = FakeExternalStore()
|
||||
importer = ExternalImport(
|
||||
external = Provider { source },
|
||||
resolver = ProviderResolver(NoProviderInstalled),
|
||||
database = db,
|
||||
dataStore = prefs,
|
||||
io = Dispatchers.IO,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copiesListsTasksAndAlarms() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
|
||||
source.tasks = mapOf(
|
||||
7L to listOf(task(100, "Milk"), task(101, "Bread")),
|
||||
9L to listOf(task(200, "Invoice")),
|
||||
)
|
||||
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
|
||||
|
||||
val result = importer.run()
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
TransferResult.Copied(TransferCounts(lists = 2, tasks = 3, alarms = 1)),
|
||||
)
|
||||
assertThat(db.taskLists().lists().map { it.list.name })
|
||||
.containsExactly("Errands", "Work")
|
||||
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.title })
|
||||
.containsExactly("Milk", "Bread", "Invoice")
|
||||
assertThat(importer.hasRun.first()).isTrue()
|
||||
}
|
||||
|
||||
/** Every list arrives device-only: the account belongs to the sync app. */
|
||||
@Test
|
||||
fun importedListsAreDeviceOnly() = runBlocking {
|
||||
source.lists = listOf(list(7, "Shared", accountName = "me@example.org"))
|
||||
source.tasks = mapOf(7L to listOf(task(100, "Milk")))
|
||||
|
||||
importer.run()
|
||||
|
||||
assertThat(db.taskLists().lists().single().list.accountId).isNull()
|
||||
}
|
||||
|
||||
/** Provider row ids are the source's; Room mints its own and the link follows. */
|
||||
@Test
|
||||
fun remapsParentIdsOntoTheNewRowIds() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
// Child before parent, so a naive single pass would not find the parent.
|
||||
source.tasks = mapOf(
|
||||
7L to listOf(task(100, "Subtask", parentId = 200), task(200, "Parent")),
|
||||
)
|
||||
|
||||
importer.run()
|
||||
|
||||
val rows = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task }
|
||||
val parent = rows.single { it.title == "Parent" }
|
||||
val child = rows.single { it.title == "Subtask" }
|
||||
assertThat(child.parentId).isEqualTo(parent.id)
|
||||
assertThat(child.parentId).isNotEqualTo(200L)
|
||||
}
|
||||
|
||||
/**
|
||||
* A `RECURRENCE-ID` override reaches the read seam as another master-shaped row
|
||||
* sharing its series' uid. The unique index on (list, uid, recurrence_id)
|
||||
* would reject it and take the whole copy down, so it gets a fresh uid.
|
||||
*/
|
||||
@Test
|
||||
fun aDuplicateUidDoesNotAbortTheCopy() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
source.tasks = mapOf(
|
||||
7L to listOf(
|
||||
task(100, "Weekly", uid = "shared-uid"),
|
||||
task(101, "Weekly, that one week", uid = "shared-uid"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = importer.run()
|
||||
|
||||
assertThat(result).isInstanceOf(TransferResult.Copied::class.java)
|
||||
val uids = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.uid }
|
||||
assertThat(uids).hasSize(2)
|
||||
assertThat(uids.toSet()).hasSize(2)
|
||||
assertThat(uids).contains("shared-uid")
|
||||
}
|
||||
|
||||
/** A START-referenced reminder must not come across as a before-due one. */
|
||||
@Test
|
||||
fun preservesTheAlarmReference() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
source.tasks = mapOf(7L to listOf(task(100, "Standup")))
|
||||
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 10, fromStart = true))
|
||||
|
||||
importer.run()
|
||||
|
||||
val alarm = db.alarms().all().single()
|
||||
assertThat(alarm.reference).isEqualTo(AlarmReference.START)
|
||||
assertThat(alarm.minutesBefore).isEqualTo(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmptySourceWritesNothingAndIsNotMarkedDone() = runBlocking {
|
||||
val result = importer.run()
|
||||
|
||||
assertThat(result).isEqualTo(TransferResult.NothingToCopy)
|
||||
assertThat(db.taskLists().lists()).isEmpty()
|
||||
// Still on offer: there was nothing to copy, not a copy that happened.
|
||||
assertThat(importer.hasRun.first()).isFalse()
|
||||
}
|
||||
|
||||
/** A read that blows up must leave Room exactly as it was. */
|
||||
@Test
|
||||
fun aFailedReadRollsBackAndLeavesTheGuardOpen() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
source.failOnExport = true
|
||||
|
||||
val result = importer.run()
|
||||
|
||||
assertThat(result).isInstanceOf(TransferResult.Failed::class.java)
|
||||
assertThat(db.taskLists().lists()).isEmpty()
|
||||
assertThat(importer.hasRun.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previewCountsWhatARunWouldWrite() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
|
||||
source.tasks = mapOf(
|
||||
7L to listOf(task(100, "Milk"), task(101, "Bread")),
|
||||
9L to listOf(task(200, "Invoice")),
|
||||
)
|
||||
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
|
||||
// preview() resolves the provider itself, so it needs one to be installed.
|
||||
val withProvider = ExternalImport(
|
||||
external = Provider { source },
|
||||
resolver = ProviderResolver(OpenTasksInstalledAndGranted),
|
||||
database = db,
|
||||
dataStore = prefs,
|
||||
io = Dispatchers.IO,
|
||||
)
|
||||
|
||||
assertThat(withProvider.preview())
|
||||
.isEqualTo(TransferCounts(lists = 2, tasks = 3, alarms = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previewIsNullWithoutAReadableProvider() = runBlocking {
|
||||
assertThat(importer.preview()).isNull()
|
||||
}
|
||||
|
||||
// --- fixtures --------------------------------------------------------------
|
||||
|
||||
private fun list(id: Long, name: String, accountName: String = "Device") = TaskList(
|
||||
id = id,
|
||||
name = name,
|
||||
color = 0xFF7E57C2.toInt(),
|
||||
accountName = accountName,
|
||||
accountType = "org.dmfs.account.LOCAL",
|
||||
isSynced = true,
|
||||
isVisible = true,
|
||||
owner = null,
|
||||
)
|
||||
|
||||
private fun task(
|
||||
id: Long,
|
||||
title: String,
|
||||
uid: String? = "uid-$id",
|
||||
parentId: Long? = null,
|
||||
) = ExportTask(
|
||||
taskId = id,
|
||||
uid = uid,
|
||||
title = title,
|
||||
description = null,
|
||||
location = null,
|
||||
url = null,
|
||||
priority = Priority.NONE,
|
||||
status = TaskStatus.NEEDS_ACTION,
|
||||
percentComplete = null,
|
||||
start = null,
|
||||
due = Instant.fromEpochMilliseconds(1_800_000_000_000),
|
||||
isAllDay = false,
|
||||
completedAt = null,
|
||||
created = null,
|
||||
lastModified = null,
|
||||
rrule = null,
|
||||
rdate = null,
|
||||
parentId = parentId,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
var counter = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the three reads the copy makes; everything else is out of scope. */
|
||||
private class FakeExternalStore : TasksDataSource {
|
||||
var lists: List<TaskList> = emptyList()
|
||||
var tasks: Map<Long, List<ExportTask>> = emptyMap()
|
||||
var alarms: Map<Long, TaskReminder> = emptyMap()
|
||||
var failOnExport = false
|
||||
|
||||
override fun taskLists(): List<TaskList> = lists
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||
if (failOnExport) error("provider went away mid-read")
|
||||
return tasks[listId].orEmpty()
|
||||
}
|
||||
|
||||
override fun alarms(): Map<Long, TaskReminder> = alarms
|
||||
|
||||
override fun tasks(query: TaskQuery): List<Task> = unused()
|
||||
override fun task(taskId: Long): Task? = unused()
|
||||
override fun subtasks(parentTaskId: Long): List<Task> = unused()
|
||||
override fun insertTask(form: TaskForm): Long = unused()
|
||||
override fun updateTask(taskId: Long, form: TaskForm) = unused()
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
|
||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
|
||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
|
||||
override fun deleteTask(taskId: Long) = unused()
|
||||
override fun createLocalList(name: String, color: Int): Long = unused()
|
||||
override fun updateList(listId: Long, name: String, color: Int) = unused()
|
||||
override fun deleteList(listId: Long) = unused()
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable = unused()
|
||||
|
||||
private fun unused(): Nothing = error("the copy does not call this")
|
||||
}
|
||||
|
||||
/** No tasks provider on the device: `preview()` has nothing to read. */
|
||||
private object NoProviderInstalled : ProviderEnvironment {
|
||||
override fun packageDeclaring(authority: String): String? = null
|
||||
override fun isGranted(permission: String): Boolean = false
|
||||
override fun appLabel(packageName: String): String? = null
|
||||
}
|
||||
|
||||
private object OpenTasksInstalledAndGranted : ProviderEnvironment {
|
||||
override fun packageDeclaring(authority: String): String? =
|
||||
"org.dmfs.tasks".takeIf { authority == "org.dmfs.tasks" }
|
||||
|
||||
override fun isGranted(permission: String): Boolean = permission.startsWith("org.dmfs.permission.")
|
||||
override fun appLabel(packageName: String): String = "OpenTasks"
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -332,6 +332,17 @@ gain.
|
||||
|
||||
## Migrating existing users
|
||||
|
||||
> ⚠️ **Corrected, 2026-09-21.** The premise below is wrong, and it was wrong when
|
||||
> it was written: **no release ever bundled the provider.** It was added and
|
||||
> deleted inside this one unreleased cycle, so `databases/tasks.db` exists on no
|
||||
> published install and `OneShotImport` finds nothing to do for every real user.
|
||||
> Anyone on 0.3.x or 0.4.0 keeps their tasks in OpenTasks or tasks.org, which
|
||||
> `ProviderResolver.autoMode()` correctly keeps them on. The path onto the new
|
||||
> store for those users is `ExternalImport` — see
|
||||
> [Copying from an external provider](#copying-from-an-external-provider) below.
|
||||
> Everything in this section still applies to a device that ran a dev build of
|
||||
> this branch, which is why the import, its fixture and its tests stay.
|
||||
|
||||
Anyone on v0.3.x has their tasks inside the bundled provider's SQLite file at
|
||||
`/data/data/de.jeanlucmakiola.agendula/databases/tasks.db` (dmfs schema
|
||||
version 23). Removing the Gradle module does **not** remove that file — an app
|
||||
@@ -384,6 +395,38 @@ still exist:
|
||||
3. Only after a release with no import defects reported does a subsequent
|
||||
version delete `tasks.db.imported`.
|
||||
|
||||
A failure is no longer invisible, either. `runIfNeeded` used to hand back an
|
||||
`ImportResult.Failed` that `StartupGate` discarded — an upgrading user got an
|
||||
empty app, no message, and their tasks in a file only a developer could name. The
|
||||
outcome is now recorded in DataStore and logged, and Settings → Storage offers the
|
||||
retry, which is what makes `reimportFromArchive()` reachable at all.
|
||||
|
||||
### Copying from an external provider
|
||||
|
||||
The migration that actually matters for 1.0.0, since the one above serves nobody.
|
||||
`ExternalImport` (`data/tasks/transfer/`) reads the external provider through the
|
||||
existing `exportTasks` seam and writes lists, tasks and alarms into Room in one
|
||||
transaction with verified counts — the same shape as `OneShotImport`, for the
|
||||
same reason. Task `uid`s are preserved, so the rows can be attached to a CalDAV
|
||||
collection once sync lands rather than duplicating server-side.
|
||||
|
||||
- **User-initiated, once.** Settings → Storage → *Copy tasks from …*, behind a
|
||||
confirm that names the real task count. Offered only while a provider is
|
||||
installed and permitted and no copy has succeeded; a second run would duplicate
|
||||
everything, because what it writes is indistinguishable from hand-typed tasks
|
||||
the moment it finishes.
|
||||
- **A copy, not a sync.** The source is untouched, whatever syncs it keeps
|
||||
syncing it, and the two sets drift from that moment. Switching stores now asks
|
||||
first for the same reason: neither store hands its rows to the other.
|
||||
- **One-directional.** 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 shaped for iCalendar
|
||||
output: per-occurrence `RECURRENCE-ID` overrides (a series arrives as master +
|
||||
rule), `EXDATE`, `CLASS`, `DURATION`, the per-task timezone, and a task's exact
|
||||
`PRIORITY` digit (`Priority` buckets 1–4 as HIGH). Nothing the app itself
|
||||
displays is lost.
|
||||
|
||||
This is the reason phase 5 (deleting `:provider`) ships *after* phase 4 rather
|
||||
than with it — and the reason the deletion is its own release.
|
||||
|
||||
|
||||
+16
-7
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Privacy Policy — Agendula
|
||||
description: What Agendula does with your data. No servers, no account, no analytics — your tasks stay on your device unless you add a CalDAV server yourself.
|
||||
updated: 2026-09-09
|
||||
updated: 2026-09-21
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -21,7 +21,7 @@ updated: 2026-09-09
|
||||
render on the published page.
|
||||
-->
|
||||
|
||||
**Last updated:** 9 September 2026
|
||||
**Last updated:** 21 September 2026
|
||||
Applies to the Android app **Agendula** (package `de.jeanlucmakiola.agendula`),
|
||||
all versions and all distribution channels.
|
||||
|
||||
@@ -37,7 +37,7 @@ nothing and no one else. Nothing is ever sent to the developer.
|
||||
IT-Dienstleister | Jean-Luc Makiola
|
||||
Mahlerstraße 10
|
||||
14772 Brandenburg an der Havel
|
||||
Email: [business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)
|
||||
Email: [support@jeanlucmakiola.de](mailto:support@jeanlucmakiola.de)
|
||||
|
||||
## 2. No data collection by the developer
|
||||
|
||||
@@ -144,9 +144,13 @@ stored locally on your device and are removed when you uninstall the app.
|
||||
|
||||
If Android Auto Backup is enabled on your device, your tasks and settings may
|
||||
be backed up to your own Google account, under Google's terms — the developer
|
||||
has no access to it. Two things are deliberately excluded from that backup:
|
||||
your stored CalDAV password, and Agendula's per-device sync bookkeeping. After
|
||||
restoring onto a new device you therefore sign in to your server again.
|
||||
has no access to it. What travels is Agendula's own task database and your
|
||||
settings, and nothing else: the backup rules name those explicitly, which makes
|
||||
everything not named — including the archived copy the app keeps of an older
|
||||
version's database — excluded by default. Two things are deliberately kept out
|
||||
of that backup: your stored CalDAV password, and Agendula's per-device sync
|
||||
bookkeeping. After restoring onto a new device you therefore sign in to your
|
||||
server again.
|
||||
|
||||
## 7. Crash reports
|
||||
|
||||
@@ -183,6 +187,10 @@ process — it only opens the address.
|
||||
|
||||
## 9. Permissions and why they exist
|
||||
|
||||
This is the complete list the released app declares — you can check it against
|
||||
the app's entry in F-Droid, or against `app/src/main/AndroidManifest.xml` in the
|
||||
source:
|
||||
|
||||
- `INTERNET`, `ACCESS_NETWORK_STATE` — CalDAV sync with the server you
|
||||
configure, and checking whether a connection exists before trying. Without a
|
||||
CalDAV account, no connection is made.
|
||||
@@ -195,7 +203,8 @@ process — it only opens the address.
|
||||
- `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` and
|
||||
`org.tasks.permission.READ_TASKS` / `WRITE_TASKS` — optional, requested only
|
||||
if you choose the external-provider storage mode, and only for the provider
|
||||
you selected (OpenTasks or tasks.org).
|
||||
you selected (OpenTasks or tasks.org). All four are declared in the manifest
|
||||
because a manifest is static, but none is requested until you pick that mode.
|
||||
- `WAKE_LOCK`, `FOREGROUND_SERVICE` — required by the Android system component
|
||||
used for scheduled background work (WorkManager); on older Android versions
|
||||
it needs them to run an expedited sync.
|
||||
|
||||
+74
-15
@@ -126,10 +126,14 @@ The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
|
||||
- ✅ Settings screen — landed early with M5 (`SettingsScreen` in the nav graph,
|
||||
reached by the gear on the lists overview). Covers theme, dynamic colour, due
|
||||
reminders (toggle + default offset + exact-alarm status), default list, and the
|
||||
add-a-subtask-row opt-out. Still ⬜ a **language** entry (deferred until there
|
||||
are translations to switch to).
|
||||
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
|
||||
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
|
||||
add-a-subtask-row opt-out — plus the **App language** picker, which landed with
|
||||
the 0.4.0 translations.
|
||||
- ⬜ Glance task-list widget — still just an idea, and now without a foothold:
|
||||
the `glance-appwidget` / `glance-material3` dependencies were declared but never
|
||||
used by a single line, so they were shipping dead weight in the APK and have
|
||||
been removed. Re-add them with the implementation, not before.
|
||||
- ✅ Translations — German and Brazilian Portuguese, via Weblate, shipped in
|
||||
0.4.0 (`res/values-de`, `res/values-pt-rBR`, `res/xml/locales_config.xml`).
|
||||
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
|
||||
|
||||
### ✅ Posture B — our own task store
|
||||
@@ -206,10 +210,13 @@ most of it broken, absent or unusable. Reasoning in
|
||||
`StorageMode.LOCAL` is gone (a stored `LOCAL` reads as `OWN`); `ProviderResolver`
|
||||
narrows to discovering external providers; `ProviderChangeReceiver` filters on
|
||||
the two external authorities only. `provider/PROVENANCE.md` is replaced by a
|
||||
postscript in `STORAGE-DECISION.md`. **Breaking:** the
|
||||
`de.jeanlucmakiola.agendula.tasks` authority and both custom permissions no
|
||||
longer exist — anyone who pointed DAVx5 at that authority loses it, and the
|
||||
release notes have to say so.
|
||||
postscript in `STORAGE-DECISION.md`. **Not a breaking change for anyone, as it
|
||||
turns out:** the `de.jeanlucmakiola.agendula.tasks` authority and its two custom
|
||||
permissions were added and deleted inside this same unreleased cycle
|
||||
(`git tag --contains` on the commit that added `:provider` comes back empty), so
|
||||
no published version ever carried them and nobody could have pointed DAVx5 at
|
||||
one. The release notes must *not* warn about losing something that never
|
||||
shipped.
|
||||
- ✅ Phase 6 — harden: `MigrationTestHelper` wired against the committed v1
|
||||
schema so v1 → v2 is cheap when sync adds columns, an Auto Backup restore test
|
||||
covering the WAL case in both directions, and a performance check at 5,000
|
||||
@@ -229,13 +236,65 @@ most of it broken, absent or unusable. Reasoning in
|
||||
stopped reading. `setCompletedInstance` now forks a `RECURRENCE-ID` override
|
||||
the way `updateInstance` does — phase 2 always specified this, only the edit
|
||||
half had it.
|
||||
- ⬜ **Run the instrumented suite on a device.** Six classes — the Room seam, the
|
||||
DAOs, the import, the migration harness, the restore path and the performance
|
||||
check — all compile and none has ever executed. Everything load-bearing about
|
||||
this migration is verified only by tests that have not run.
|
||||
- ⬜ Verify on a device: a fresh install on the Room store, and an upgrade from a
|
||||
v0.3.2 APK with seeded data landing every task, list and reminder.
|
||||
- ⬜ Per-locale release notes for the dropped authority and permissions.
|
||||
- ✅ **Ran the instrumented suite on a device** — 52 tests, 0 failures, Pixel 10
|
||||
/ API 36, 13 Aug 2026; all six classes (the Room seam, the DAOs, the import, the
|
||||
migration harness, the restore path, the performance check). Mind the ARM64
|
||||
`aapt` trap: the task can exit non-zero on a fully green run, so read
|
||||
`app/build/outputs/androidTest-results/connected/debug/*.xml` before believing
|
||||
the exit code.
|
||||
- ✅ **Re-ran it against the branch tip** — 66 tests, 0 failures, Pixel 10 /
|
||||
API 37, 22 Sep 2026, now including `ExternalImportTest`. Worth having done:
|
||||
the first run failed one test.
|
||||
`RoomTasksDataSourceTest.alarmsRoundTripAndReplaceRatherThanAccumulate`
|
||||
asserted `alarms()[id] == 30`, from before the seam returned a `TaskReminder`
|
||||
rather than a bare minute count — the production value was right
|
||||
(`TaskReminder(minutesBefore=30, fromStart=false)`) and the assertion was
|
||||
stale. It compiled because Truth's `isEqualTo` takes `Any?`, so nothing but
|
||||
executing it could have caught it. Exactly the defect class this item
|
||||
existed to find.
|
||||
- ⬜ Verify on a device — and note that the upgrade path this phase was designed
|
||||
around is **not** the one real users are on. `OneShotImport` reads a bundled
|
||||
dmfs provider's `databases/tasks.db`, and no release ever bundled one, so every
|
||||
existing install's tasks sit in OpenTasks or tasks.org instead. What has to be
|
||||
checked is therefore:
|
||||
1. a fresh install landing on the Room store with nothing installed;
|
||||
2. an upgrade from the 0.4.0 APK **with OpenTasks installed and permitted** —
|
||||
`autoMode()` must keep them on External and show their tasks unchanged;
|
||||
3. Settings → Storage → *Copy tasks from …* moving those tasks into the Room
|
||||
store, then the switch to *On this device* showing them, reminders included;
|
||||
4. `OneShotImport` itself, which on a real device is only reachable by
|
||||
side-loading a dev build of this branch first.
|
||||
- ⬜ Release notes for 1.0.0. Nothing to say about the dropped authority or
|
||||
permissions (see Phase 5); what needs saying is the own store, the copy path out
|
||||
of an external provider, iCalendar export and list management.
|
||||
|
||||
### ✅ A way onto the new store for the people already using Agendula
|
||||
The migration this branch was planned around turned out to serve nobody: the
|
||||
vendored provider it reads from never shipped, so no install has the file
|
||||
`OneShotImport` looks for. Everyone on 0.4.0 keeps their tasks in OpenTasks or
|
||||
tasks.org, which `autoMode()` correctly keeps them on — and until now the only
|
||||
route to the new store was to retype everything.
|
||||
- ✅ `ExternalImport` (`data/tasks/transfer/`) copies the external provider's
|
||||
lists, tasks and alarms into Room in one transaction with verified counts, the
|
||||
same discipline as the legacy import. Task `uid`s survive, so these rows can be
|
||||
attached to a CalDAV collection once sync lands instead of duplicating.
|
||||
- ✅ Offered as **Settings → Storage → Copy tasks from …**, behind a confirm that
|
||||
names the real number of tasks, and only while a provider is installed,
|
||||
permitted, and no copy has succeeded yet — a second run would leave two of
|
||||
everything, since what it writes is indistinguishable from hand-typed tasks
|
||||
afterwards.
|
||||
- ✅ One-directional by design. Writing a *list* into a third-party provider
|
||||
means impersonating its sync adapter, and the external store is already the one
|
||||
that can sync. What does not come across is documented on the class:
|
||||
per-occurrence overrides, `EXDATE`, `CLASS`, `DURATION`, the per-task timezone,
|
||||
and a task's exact `PRIORITY` digit.
|
||||
- ✅ Switching stores at all now asks first. Neither store hands its rows to the
|
||||
other, so the app looks emptied to anyone who expected a move.
|
||||
- ✅ A failed `OneShotImport` is no longer silent. It used to return an
|
||||
`ImportResult.Failed` that every caller dropped, leaving an upgrading user an
|
||||
empty app and no explanation; it is now recorded, logged, and surfaced in
|
||||
Settings → Storage as a retry — which is also what finally makes
|
||||
`reimportFromArchive()` reachable from the app.
|
||||
|
||||
### ✅ Managing lists in the app
|
||||
Owning the store made this mandatory: there is no longer a provider app to
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
> [`OWN-STORE.md`](OWN-STORE.md) for what replaces it. The permissions,
|
||||
> distribution, storage-mode and dead-end sections below remain accurate; treat
|
||||
> the "our own provider" sections as the historical record of a decision that was
|
||||
> made, shipped, and then costed properly.
|
||||
> made, built, and then costed properly. It never reached a release: the
|
||||
> `:provider` module was added and deleted inside this same unreleased cycle, so
|
||||
> no published version of Agendula ever carried an authority of its own.
|
||||
|
||||
> Decided direction, captured 2026-08-01. Supersedes the earlier "Posture B =
|
||||
> bundle OpenTasks" working notes, which are withdrawn (see
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
### Added
|
||||
- Agendula keeps your tasks itself now — nothing else to install. Manage lists
|
||||
in the app, repeat tasks, and export any list as iCalendar.
|
||||
- Settings → Storage picks where tasks live, and offers to copy them over from
|
||||
OpenTasks or tasks.org.
|
||||
|
||||
### Changed
|
||||
- Another task app is now optional. Already use one? Nothing changes on update.
|
||||
|
||||
### Fixed
|
||||
- Edited due times no longer revert, all-day tasks no longer drift a day, and
|
||||
per-task reminders are saved.
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
Agendula is a modern, open-source task app for Android. It works directly on an
|
||||
existing tasks provider (OpenTasks / tasks.org), so any CalDAV tasks synced to
|
||||
your device via DAVx5, SmoothSync or DecSync show up automatically, and changes
|
||||
you make sync back the same way — no own account, no own sync.
|
||||
Agendula is a modern, open-source task app for Android. It keeps your tasks in
|
||||
its own storage on your device — nothing else to install, no account, nothing to
|
||||
grant.
|
||||
|
||||
Prefer to keep them where they already are? Point Agendula at a tasks provider
|
||||
you already use (OpenTasks or tasks.org) and it reads and writes that instead, so
|
||||
any CalDAV tasks synced to your device via DAVx5, SmoothSync or DecSync show up
|
||||
automatically and your changes sync back the same way. Switch between the two in
|
||||
Settings → Storage, and copy your tasks across when you do.
|
||||
|
||||
What it does: lists you manage in the app, due dates and start dates, all-day
|
||||
tasks, subtasks, priorities, progress, repeating tasks, and reminders that fire
|
||||
at the exact due time. Export any list as a standard iCalendar .ics file through
|
||||
Android's own file picker.
|
||||
|
||||
The differentiator is the design: real Material 3 Expressive throughout, with
|
||||
dynamic color, expressive motion, and expressive shapes. Sibling to Calendula.
|
||||
|
||||
Privacy: zero telemetry, no analytics, no network access of its own — your data
|
||||
never leaves the device except through the sync app you already trust.
|
||||
Privacy: zero telemetry, no analytics, and no network access of its own — the app
|
||||
holds no internet permission at all. Your data leaves the device only if you
|
||||
export it, or through a sync app you already trust.
|
||||
|
||||
+33
-39
@@ -1,25 +1,25 @@
|
||||
[versions]
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.3.21"
|
||||
ksp = "2.3.9"
|
||||
hilt = "2.59.2"
|
||||
ksp = "2.3.11"
|
||||
hilt = "2.60.1"
|
||||
coreKtx = "1.19.0"
|
||||
appcompat = "1.7.1"
|
||||
lifecycleRuntime = "2.10.0"
|
||||
lifecycleRuntime = "2.11.0"
|
||||
activityCompose = "1.13.0"
|
||||
composeBom = "2026.05.01"
|
||||
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
|
||||
# Pin explicitly to override the BOM (which ships stable 1.4.0).
|
||||
# Re-evaluate when 1.5.0 stable lands.
|
||||
material3 = "1.5.0-alpha21"
|
||||
material3 = "1.5.0-alpha26"
|
||||
datastore = "1.2.1"
|
||||
# Room — Agendula's own task store (docs/OWN-STORE.md).
|
||||
room = "2.8.4"
|
||||
kotlinxSerialization = "1.8.1"
|
||||
# SAF directory writing for export/backup (DocumentFile).
|
||||
documentfile = "1.1.0"
|
||||
junit = "6.1.0"
|
||||
junitPlatform = "6.1.0"
|
||||
junit = "6.1.3"
|
||||
junitPlatform = "6.1.3"
|
||||
truth = "1.4.5"
|
||||
androidxJunit = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
@@ -29,16 +29,14 @@ turbine = "1.2.0"
|
||||
# androidx.hilt — one version for the whole group. hilt-work and
|
||||
# hilt-navigation-compose splitting versions inside a group is the kind of thing
|
||||
# that resolves fine and then fails at runtime.
|
||||
androidxHilt = "1.3.0"
|
||||
androidxHilt = "1.4.0"
|
||||
# WorkManager: sync runs here, triggered *through* the sync-adapter framework.
|
||||
work = "2.11.2"
|
||||
# Custom Tabs, for Nextcloud Login Flow v2.
|
||||
browser = "1.10.0"
|
||||
navigationCompose = "2.9.0"
|
||||
lifecycleCompose = "2.10.0"
|
||||
lifecycleCompose = "2.11.0"
|
||||
androidxTestRules = "1.7.0"
|
||||
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
||||
glance = "1.1.1"
|
||||
|
||||
# --- :dav (vendored dav4jvm) -------------------------------------------------
|
||||
# dav4jvm 2.2.1 is the last OkHttp release; 3.0.0 deleted the OkHttp package for
|
||||
@@ -56,22 +54,15 @@ xpp3 = "1.1.6"
|
||||
# and JNDI's DNS provider does not exist on Android at all. dnsjava is what DAVx5
|
||||
# uses. BSD-3 — it goes on the attribution screen with the rest.
|
||||
dnsjava = "3.6.3"
|
||||
|
||||
# --- :provider (vendored dmfs task provider) ---------------------------------
|
||||
# Versions the upstream 1.4.2 source was written against. These are its runtime
|
||||
# dependencies, not ours — nothing above the data layer touches them, and they
|
||||
# only move when we deliberately resync the fork. See provider/PROVENANCE.md.
|
||||
dmfsJems = "1.43"
|
||||
dmfsRfc5545Datetime = "0.2.4"
|
||||
dmfsLibRecur = "0.12.2"
|
||||
# The provider's own test suite is JUnit 4 + Robolectric, unlike the app's
|
||||
# JUnit 5. Kept as upstream wrote it (rewriting ~13 test classes would forfeit
|
||||
# the regression coverage that makes vendoring safe), but on current versions:
|
||||
# upstream pins Robolectric 3.5.1, which predates AGP's resource handling.
|
||||
robolectric = "4.16"
|
||||
# The vendored dav4jvm tests and :caldav's are JUnit 4, unlike the app's JUnit 5.
|
||||
junit4 = "4.13.2"
|
||||
hamcrest = "3.0"
|
||||
mockito = "5.20.0"
|
||||
|
||||
# RFC 5545 recurrence expansion. The last of the dmfs dependencies: the vendored
|
||||
# `:provider` module that needed the rest was deleted (docs/STORAGE-DECISION.md),
|
||||
# and lib-recur stayed because our own store expands a series in process.
|
||||
# Pinned at 0.12.2 — 0.16.0 removed RecurrenceSet. rfc5545-datetime arrives with
|
||||
# it as part of its API surface, so it is not declared separately.
|
||||
dmfsLibRecur = "0.12.2"
|
||||
|
||||
[libraries]
|
||||
# AndroidX core
|
||||
@@ -131,8 +122,11 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
|
||||
# Test - Flow assertions
|
||||
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
||||
|
||||
# Hilt navigation-compose (for hiltViewModel() in Composables)
|
||||
# Hilt navigation-compose (hiltViewModel() moved out of it in 1.4.0, into
|
||||
# hilt-lifecycle-viewmodel-compose — which it still brings in transitively, but
|
||||
# we call that API directly, so it is declared here rather than inherited)
|
||||
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHilt" }
|
||||
androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" }
|
||||
androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidxHilt" }
|
||||
androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" }
|
||||
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
|
||||
@@ -146,10 +140,6 @@ androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lif
|
||||
# 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" }
|
||||
androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" }
|
||||
|
||||
# Android tests - GrantPermissionRule
|
||||
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
|
||||
|
||||
@@ -162,16 +152,20 @@ xpp3 = { group = "org.ogce", name = "xpp3", version.ref = "xpp3" }
|
||||
|
||||
# :caldav — RFC 6764 service discovery
|
||||
dnsjava = { group = "dnsjava", name = "dnsjava", version.ref = "dnsjava" }
|
||||
|
||||
# :provider — vendored dmfs task provider (see provider/PROVENANCE.md)
|
||||
dmfs-jems = { group = "org.dmfs", name = "jems", version.ref = "dmfsJems" }
|
||||
dmfs-jems-testing = { group = "org.dmfs", name = "jems-testing", version.ref = "dmfsJems" }
|
||||
dmfs-rfc5545-datetime = { group = "org.dmfs", name = "rfc5545-datetime", version.ref = "dmfsRfc5545Datetime" }
|
||||
dmfs-lib-recur = { group = "org.dmfs", name = "lib-recur", version.ref = "dmfsLibRecur" }
|
||||
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
|
||||
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
|
||||
hamcrest = { group = "org.hamcrest", name = "hamcrest", version.ref = "hamcrest" }
|
||||
mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" }
|
||||
|
||||
# Recurrence expansion for our own store
|
||||
dmfs-lib-recur = { group = "org.dmfs", name = "lib-recur", version.ref = "dmfsLibRecur" }
|
||||
|
||||
# floret-kit — the shared house library, an included build (see settings.gradle.kts).
|
||||
# Deliberately version-less: the composite build substitutes these coordinates
|
||||
# with the submodule's own projects, so a version here would be fiction.
|
||||
floret-core-time = { group = "de.jeanlucmakiola.floret", name = "core-time" }
|
||||
floret-core-reminders = { group = "de.jeanlucmakiola.floret", name = "core-reminders" }
|
||||
floret-core-locale = { group = "de.jeanlucmakiola.floret", name = "core-locale" }
|
||||
floret-core-crash = { group = "de.jeanlucmakiola.floret", name = "core-crash" }
|
||||
floret-identity = { group = "de.jeanlucmakiola.floret", name = "identity" }
|
||||
floret-components = { group = "de.jeanlucmakiola.floret", name = "components" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -20,6 +20,10 @@ MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
|
||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
|
||||
# F-Droid's in-client changelog box truncates past roughly this length; CI reads
|
||||
# this value rather than hardcoding its own copy.
|
||||
MAX_CHARS=${MAX_CHARS:-500}
|
||||
|
||||
CL_DIR="fastlane/metadata/android/en-US/changelogs"
|
||||
mkdir -p "$CL_DIR"
|
||||
OUT="$CL_DIR/${VERSION_CODE}.txt"
|
||||
@@ -36,6 +40,21 @@ fi
|
||||
|
||||
CHARS=$(wc -m < "$OUT" | tr -d ' ')
|
||||
echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
|
||||
if [ "$CHARS" -gt 500 ]; then
|
||||
echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2
|
||||
|
||||
# Hard limit, not a note. F-Droid truncates the "What's New" box in-client, so
|
||||
# anything past this is written for nobody: the reader sees a sentence cut
|
||||
# mid-word and no way to expand it. Keep the section in CHANGELOG.md short
|
||||
# enough to survive that, and put the detail in the commit messages and docs,
|
||||
# where there is room for it.
|
||||
#
|
||||
# CI enforces the same bound (.forgejo/workflows/ci.yaml) so a release cannot
|
||||
# reach main with a changelog its users cannot read.
|
||||
if [ "$CHARS" -gt "$MAX_CHARS" ]; then
|
||||
cat >&2 <<MSG
|
||||
ERROR: $OUT is $CHARS characters, over the $MAX_CHARS-character limit.
|
||||
|
||||
F-Droid truncates the in-client changelog, so this one would be cut off
|
||||
mid-sentence. Shorten the "## [$VERSION]" section in CHANGELOG.md and re-run.
|
||||
MSG
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -38,11 +38,26 @@ adb shell pm revoke "$PKG" org.tasks.permission.WRITE_TASKS 2>/dev/null || t
|
||||
adb shell pm revoke "$PKG" android.permission.POST_NOTIFICATIONS 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo "Installed and reset. Now verify ON THE DEVICE before releasing:"
|
||||
echo " 1. Launch from a clean state — the permission screen must appear (no crash)."
|
||||
echo " 2. Grant tasks access — the task list must load."
|
||||
echo " 3. Create a task with a due reminder and confirm the notification fires."
|
||||
echo " 4. Exercise the release's headline changes end to end."
|
||||
echo "Installed and reset. Now verify ON THE DEVICE before releasing."
|
||||
echo
|
||||
echo "Which path you are on depends on what else is installed — since 1.0.0 the"
|
||||
echo "app owns its store, so the default path grants nothing at all:"
|
||||
echo
|
||||
echo " No OpenTasks / tasks.org on the device (the default, and what a fresh"
|
||||
echo " install looks like):"
|
||||
echo " 1. Launch from a clean state — the task list must load straight away."
|
||||
echo " There is no permission to grant in this mode, so no gate appears."
|
||||
echo " 2. Create a list, then a task in it — a fresh install has neither."
|
||||
echo " 3. Give the task a due reminder and confirm the notification fires."
|
||||
echo
|
||||
echo " With OpenTasks or tasks.org installed:"
|
||||
echo " 1. Launch from a clean state — the permission gate must appear (no crash)."
|
||||
echo " 2. Grant tasks access — that app's task list must load."
|
||||
echo " 3. Settings -> Storage -> Copy tasks from ... — the confirm must name a"
|
||||
echo " real count, and switching to \"On this device\" must then show them."
|
||||
echo " 4. Create a task with a due reminder and confirm the notification fires."
|
||||
echo
|
||||
echo " Either way: exercise the release's headline changes end to end."
|
||||
echo
|
||||
echo "Watch for crashes with: adb logcat -b crash"
|
||||
echo "Only merge the release branch to main once all of the above pass on a device"
|
||||
|
||||
Reference in New Issue
Block a user