diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index c3add42..b759084 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -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`. diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 38d729b..4a26230 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ada51d..1a3e0ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 1377ed1..e131132 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0eae419..c9fb3a3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index d4314e3..4b96679 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -5,17 +5,11 @@ # Room instantiates its generated _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 { (); } -# 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 { - (android.content.Context, androidx.work.WorkerParameters); -} - # Compose Compiler may keep its own; defaults are fine -dontwarn org.jetbrains.annotations.** diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt index eb7b748..c69a615 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt @@ -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) diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt new file mode 100644 index 0000000..0f38d5e --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt @@ -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 + 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 = emptyList() + var tasks: Map> = emptyMap() + var alarms: Map = emptyMap() + var failOnExport = false + + override fun taskLists(): List = lists + + override fun exportTasks(listId: Long): List { + if (failOnExport) error("provider went away mid-read") + return tasks[listId].orEmpty() + } + + override fun alarms(): Map = alarms + + override fun tasks(query: TaskQuery): List = unused() + override fun task(taskId: Long): Task? = unused() + override fun subtasks(parentTaskId: Long): List = 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" +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f3d4f1b..71d1f2b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -113,16 +113,24 @@ + InvalidationTracker covers our own writes. + + An intent-filter host must be a literal, so both external authorities + are listed — one filter each. Two 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. --> - + + + + - #FF7A5C6B #FF7A5C6B diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2438f6a..dbc1776 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,5 @@ Agendula - A modern Material 3 Expressive task app.\nComing into bloom. Untitled task @@ -21,9 +20,6 @@ Edit - Edit task - New task - Title Description Could not save. Try again. @@ -52,11 +48,8 @@ High - Mark complete - Mark not complete List Due - Starts Priority Progress Subtasks @@ -160,7 +153,6 @@ Overdue Upcoming All - %1$d open %1$d of %2$d done @@ -176,7 +168,6 @@ Search tasks Clear search - Close search No tasks match “%1$s” @@ -190,8 +181,6 @@ Allow access to your tasks Agendula needs permission to read and write your tasks. That\'s the only thing it ever asks for. Grant task access - Install OpenTasks - Install tasks.org Everything you meant to do @@ -304,7 +293,6 @@ Exact timing Reminders fire at the exact time Blocked — tap to allow exact reminders - Tasks Default list First available list Show \"add a subtask\" row @@ -318,7 +306,7 @@ Storage Where tasks are kept, and export Task store - Each store keeps its own tasks. Switching does not move them across — export first if you want a copy. + Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them. On this device Agendula\'s own storage. Nothing else to install. Another task app @@ -326,6 +314,32 @@ No compatible task app is installed Permission denied The other app\'s tasks stay unreachable until you allow access. Tap to open app settings. + + + Switch task store? + 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. + Your tasks stay in %1$s — they are not moved. Agendula will show the other app\'s tasks instead. + Switch + Copy tasks from %1$s + Bring them into Agendula\'s own storage. A one-time copy — the originals stay where they are. + Copy tasks over? + Counting what there is to copy… + %1$s holds no tasks to copy. + + %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. + %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. + + Copy + Copying tasks… + + Copied %1$d task into %2$d list. Pick “On this device” above to see them. + Copied %1$d tasks into %2$d lists. Pick “On this device” above to see them. + + There was nothing to copy + The tasks could not be copied. Nothing was changed — your originals are untouched. + Your earlier tasks could not be moved + They are still saved and nothing was lost. Tap to try moving them again. + Moving them now… Export tasks Save your lists as iCalendar files 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. diff --git a/docs/OWN-STORE.md b/docs/OWN-STORE.md index 48a9db9..cb55510 100644 --- a/docs/OWN-STORE.md +++ b/docs/OWN-STORE.md @@ -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. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 2cffccb..60a91df 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -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 --- -**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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 945d92b..d67cec8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/STORAGE-AND-SYNC.md b/docs/STORAGE-AND-SYNC.md index 7224a45..2ccd172 100644 --- a/docs/STORAGE-AND-SYNC.md +++ b/docs/STORAGE-AND-SYNC.md @@ -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 diff --git a/fastlane/metadata/android/en-US/changelogs/10000.txt b/fastlane/metadata/android/en-US/changelogs/10000.txt new file mode 100644 index 0000000..baffaf0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/10000.txt @@ -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. + diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 5a33820..4f2651c 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -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. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a3987ba..7f6ce9e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/scripts/sync_changelog_to_fastlane.sh b/scripts/sync_changelog_to_fastlane.sh index f6d0e93..48d0749 100755 --- a/scripts/sync_changelog_to_fastlane.sh +++ b/scripts/sync_changelog_to_fastlane.sh @@ -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 </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"