From 633ec5b5d3da48067f7df0aa95c9c7c6e1a88b0c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 9 Sep 2026 16:52:17 +0200 Subject: [PATCH 01/10] Move the privacy policy into the repo (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy had no copy in this repository — it existed only inside the Astro page on jeanlucmakiola.de. This file becomes the single copy: the website build checks this repo out beside itself and renders `docs/PRIVACY.md` through a content collection, so the published page and the app's own documentation cannot drift. Same arrangement as calendula#293. Shaped as the content entry the site expects: `title` / `description` / `updated` frontmatter, an HTML maintainer note that cannot render, and a body starting below the `h1` the page supplies. The text is the published page carried over in full — controller and postal address, the two storage modes, CalDAV sync (what is stored, what is transmitted, RFC 6764 discovery, Nextcloud Login Flow v2, the user-CA trade-off), reminders and export, backups, crash reports, external links, permissions, distribution channels, deletion paths and GDPR rights. Two things differ from the older short version that lived on `feat/caldav-sync`: - Contact is `business@jeanlucmakiola.de`, matching the site and Calendula's policy, rather than `mail@`. - Cleartext HTTP is described as refused outright. `CalDavDiscovery.allowCleartext` is `false` with nothing wiring it true, and `network_security_config.xml` sets `cleartextTrafficPermitted="false"`, so the previous "unless you explicitly opt in for a specific account" described a feature that does not exist. It goes back if a per-account opt-in ships. Split out of `feat/caldav-sync` so the website change is not waiting on the whole sync branch. No issue to close — there is no open privacy/policy issue to reference. Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/12 --- docs/PRIVACY.md | 246 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/PRIVACY.md diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md new file mode 100644 index 0000000..2cffccb --- /dev/null +++ b/docs/PRIVACY.md @@ -0,0 +1,246 @@ +--- +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 +--- + + + +**Last updated:** 9 September 2026 +Applies to the Android app **Agendula** (package `de.jeanlucmakiola.agendula`), +all versions and all distribution channels. + +## In short + +Agendula has no servers, no user accounts and no analytics. Your tasks live on +your device. They leave it in exactly one case: if you set up a CalDAV account +yourself, they are synchronised with **the server you entered** — and with +nothing and no one else. Nothing is ever sent to the developer. + +## 1. Controller + +IT-Dienstleister | Jean-Luc Makiola +Mahlerstraße 10 +14772 Brandenburg an der Havel +Email: [business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de) + +## 2. No data collection by the developer + +Agendula contains **no analytics, no tracking, no advertising, no +crash-reporting SDK and no third-party service that reports anything +anywhere**. No user profile is created, no advertising or device identifier is +generated, and no data is shared with or sold to anyone. There is no Agendula +account, and the developer operates no server that the app talks to. + +All of this is verifiable in the +[source code](https://codeberg.org/jlmakiola/agendula), which is public. + +## 3. Where your tasks live — your choice + +- **On your device (the default)** — your task lists, tasks and reminders are + kept in Agendula's own database inside the app's private storage. Nothing is + published to other apps, and uninstalling the app removes it. +- **In a tasks provider you already use** — OpenTasks or tasks.org. Agendula + then reads and writes that app's task database through Android's provider + mechanism, after you grant its read/write permission. Whatever already + synchronises that provider (DAVx5, SmoothSync, DecSync CC, …) keeps doing so, + unchanged; that synchronisation is performed by those apps, not by Agendula, + and their privacy policies apply to it. + +## 4. CalDAV sync — the only case where your tasks leave the device + +Sync is optional and off until you add an account. If you add one, everything +below happens between your device and **the server you nominated**, and nowhere +else. + +### What is stored on your device + +The server address, your username, and your password or app password. The +password is encrypted with a key held in the Android Keystore, which cannot be +exported from the device. + +### What is transmitted, and to whom + +- The tasks in the synchronised lists, as standard iCalendar (`VTODO`) data, + and the credentials needed to authenticate. +- Requests carry the user agent `Agendula (Android)` — a fixed string, so that + you can recognise and revoke the session on your server. No device identifier + is sent. +- Connections are HTTPS. Cleartext HTTP is refused, so credentials are never + sent over an unencrypted connection. + +Nothing is sent anywhere else. In particular, nothing is sent to the developer. + +Under Google Play's Data Safety definitions this counts as **collected** — Play +defines collection as transmitting data off the device, regardless of who +receives it — and **not shared**, because the only recipient is the server you +nominated. Data is encrypted in transit. + +### Finding your server + +When you type a server address or an email domain, Agendula follows the +standard discovery procedure (RFC 6764): a DNS lookup for the `_caldavs._tcp` +service record of that domain, then `/.well-known/caldav` on the host. The DNS +query goes to whichever resolver your device or network uses, and the requests +go to the domain you typed — no directory of servers is consulted and no lookup +is sent to the developer. + +### Signing in to a Nextcloud + +If the server is a Nextcloud, Agendula uses Nextcloud's Login Flow v2: your +browser opens *your own server's* login page, you authorise there, and the +server hands the app a dedicated app password. Agendula never sees your actual +account password. The app password appears in your server's "Devices & +sessions" list as `Agendula (Android)`, and you can revoke it there at any +time. Removing the account in Agendula revokes it too, where the server +supports that. + +### Your server's own policy + +Your CalDAV provider has its own privacy policy, and your data on their server +is governed by it. Agendula has no relationship with them. + +A note on certificates: Agendula trusts private certificate authorities that +you have installed in your device's user store, because self-hosted servers +routinely use them. That is a deliberate trade-off in favour of self-hosters — +any CA installed on your device (for example by an employer's management +profile) can, in principle, intercept traffic from the app, as it can from +other apps that make the same choice. + +## 5. Other data Agendula handles on your device + +### Reminders and notifications + +Due-date reminders are scheduled by the app itself and displayed as local +notifications. Nothing is sent to a push service — there is no push service. + +### Export files + +You can export your tasks as standard iCalendar `.ics` files. Agendula writes +exactly the file you select through Android's system file picker, and has no +access to other files. + +### App settings + +Your preferences (theme, language, list and reminder defaults and similar) are +stored locally on your device and are removed when you uninstall the app. + +## 6. Backups + +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. + +## 7. Crash reports + +If Agendula crashes, it offers to report the problem. Nothing is sent +automatically, even though the app has network access. The report is copied to +your clipboard and your browser is opened with the project's issue tracker, the +text pre-filled. **You see the full content, you decide whether to submit it, +and you can edit or discard it.** + +Such a report contains: + +- app version, +- Android version, +- device manufacturer and model, +- your device language, +- the timestamp, +- and the technical stack trace. + +It is built from that fixed list and nothing else: **no** task data, **no** +server address or credentials, **no** account names, **no** log files and +**no** personal identifiers. + +If you choose to submit it, the report becomes a public issue on the project's +issue tracker at Codeberg, operated by Codeberg e. V. Their privacy policy then +applies to that submission. + +## 8. External links + +The app links to the source code, the licence, the issue tracker, the +translation platform (Weblate) and a voluntary donation page (Ko-fi). Following +one of these links opens your browser and leaves the app; the privacy policy of +the respective website then applies. Agendula transmits no data of yours in the +process — it only opens the address. + +## 9. Permissions and why they exist + +- `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. +- `READ_SYNC_SETTINGS`, `WRITE_SYNC_SETTINGS` — register the sync account with + Android's sync framework so it can be scheduled. +- `POST_NOTIFICATIONS` — show reminders. +- `USE_EXACT_ALARM`, `SCHEDULE_EXACT_ALARM` — deliver reminders at the exact + due time. +- `RECEIVE_BOOT_COMPLETED` — re-register pending reminders after a restart. +- `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). +- `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. + +Agendula publishes no content provider of its own and declares no permissions +that other apps could request. + +## 10. Distribution channels + +Agendula is distributed through the project's releases on Codeberg, a +self-hosted F-Droid repository, Obtainium, and — where applicable — F-Droid and +the Google Play Store. When you download or update the app, the operator of +that channel processes data (such as your IP address) under their own privacy +policy. This is outside the developer's control and unrelated to the app's own +behaviour. + +## 11. Children + +Agendula is not directed at children and collects nothing about anyone. + +## 12. Deleting your data + +- **Remove a CalDAV account** from Settings → Accounts. This deletes the stored + credential and, where the server supports it, revokes the app password. Task + lists become device-only lists rather than being destroyed. +- **Remove an account and delete its local data** removes the lists and tasks + as well. +- **Uninstalling the app** removes everything Agendula stored on the device. + +Deleting data from your CalDAV server is done on that server; data in an +external tasks provider is deleted in that app. + +## 13. Your rights + +The developer stores no personal data of yours — the only data transfer the app +performs is between your device and a server you operate or chose. There is +therefore no data held by the developer to which rights of access, +rectification, erasure, restriction, data portability or objection (Art. 15–21 +GDPR) could apply. Your tasks are exportable as standard `.ics` files from +within the app at any time. You may contact the address above with any +question, and you have the right to lodge a complaint with a supervisory +authority. + +## 14. Changes to this policy + +Should the app's functionality change in a way that affects data processing, +this policy will be updated and the date at the top adjusted. The history of +this file is public in the repository. From 44489c56650344957b17bde7eaa01c00ebc85817 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 15 Sep 2026 21:00:52 +0200 Subject: [PATCH 02/10] privacy: point the controller contact at support@ (#13) --- docs/PRIVACY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 2cffccb..3a6782e 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-15 --- + 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. From b49a8af83de16594b29542916a247fac0bdc42ed Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 21 Sep 2026 13:37:49 +0200 Subject: [PATCH 04/10] chore: clear out what the own-store branch left behind, and refresh deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dead weight, found by reading `lintDebug` rather than by anything breaking. Gone with the deleted `:provider` module: its entire dependency block in the version catalog — jems, rfc5545-datetime, Robolectric, JUnit 4, Hamcrest, Mockito — plus the comments explaining a `provider/PROVENANCE.md` that is not there any more. Only lib-recur survived the deletion and it is now documented where it lives. That block was also the source of most of the catalog's "newer version available" noise. Gone outright: `glance-appwidget` and `glance-material3`, declared but not referenced by a single line, so they were shipping in the APK for nothing. The WorkManager `ListenableWorker` keep rule went with them, since Glance was what dragged WorkManager in; the RoomDatabase keep rule stays and its comment now says why it matters *more* than it did — Room used to arrive transitively through that chain, and is now our own task store. Also gone: thirteen unused resources (twelve strings and a colour), which volunteers on Weblate were translating for nothing; a `SDK_INT < O` branch that cannot be false at minSdk 29; and the `-v26` qualifier on the mipmap folder, unnecessary at the same minSdk. `DemoSeeder` is injected as a `Provider` now. Its call site is `BuildConfig.DEBUG`-gated, i.e. compile-time dead in a release build, but injecting the instance still constructed one on every launch of the shipped app. `ProviderChangeReceiver` gets one intent-filter per authority rather than two `` tags in one filter. Identical behaviour — Android takes the cross product of every data attribute in a filter — but it no longer reads as if it might not, which is what lint's IntentFilterUniqueDataAttributes warns about. Dependency refresh, app-level only: KSP 2.3.11, Hilt 2.60.1, lifecycle 2.11.0, material3 1.5.0-alpha26, JUnit 6.1.3, hilt-navigation-compose 1.4.0. That last one moved `hiltViewModel` into `androidx.hilt.lifecycle.viewmodel.compose`, so nine call sites follow it and the artifact it now lives in is declared rather than inherited. The floret-kit coordinates move into the catalog, version-less, since the composite build substitutes them. Deliberately not touched: AGP, Gradle, Kotlin, the Compose BOM and kotlinx-* — toolchain moves that want their own change and a reproducible-build check, not a release cut. lib-recur stays pinned at 0.12.2 (0.16.0 removed `RecurrenceSet`). --- app/build.gradle.kts | 20 +++--- app/proguard-rules.pro | 14 ++-- app/src/main/AndroidManifest.xml | 14 +++- .../jeanlucmakiola/agendula/MainActivity.kt | 10 ++- .../agendula/data/reminders/TaskNotifier.kt | 1 - .../agendula/data/tasks/TasksContract.kt | 15 +++-- .../jeanlucmakiola/agendula/ui/RootScreen.kt | 2 +- .../agendula/ui/detail/TaskDetailScreen.kt | 2 +- .../agendula/ui/edit/TaskEditScreen.kt | 2 +- .../agendula/ui/export/ExportScreen.kt | 2 +- .../agendula/ui/lists/ListsScreen.kt | 2 +- .../agendula/ui/navigation/AgendulaNavHost.kt | 2 +- .../agendula/ui/settings/SettingsScreen.kt | 2 +- .../agendula/ui/tasklist/TaskListScreen.kt | 4 +- .../ic_launcher.xml | 0 .../ic_launcher_round.xml | 0 app/src/main/res/values-de/strings.xml | 11 ---- app/src/main/res/values-pt-rBR/strings.xml | 12 ---- app/src/main/res/values/colors.xml | 1 - app/src/main/res/values/strings.xml | 12 ---- gradle/libs.versions.toml | 65 ++++++++----------- 21 files changed, 75 insertions(+), 118 deletions(-) rename app/src/main/res/{mipmap-anydpi-v26 => mipmap-anydpi}/ic_launcher.xml (100%) rename app/src/main/res/{mipmap-anydpi-v26 => mipmap-anydpi}/ic_launcher_round.xml (100%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1639fed..b167842 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -174,12 +174,11 @@ 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) - // 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) implementation(libs.androidx.room.runtime) @@ -189,17 +188,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/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a50a376..8d39dc2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -83,16 +83,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 178c689..dfc935a 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 @@ -152,7 +145,6 @@ Overdue Upcoming All - %1$d open %1$d of %2$d done @@ -168,7 +160,6 @@ Search tasks Clear search - Close search No tasks match “%1$s” @@ -182,8 +173,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 Never miss what\'s due @@ -238,7 +227,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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c57fbae..9dd1c71 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,53 +1,42 @@ [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" kotlinxDatetime = "0.7.0" kotlinxCoroutines = "1.10.2" turbine = "1.2.0" -hiltNavigationCompose = "1.3.0" +hiltNavigationCompose = "1.4.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" -# --- :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" +# 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" -# 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" -junit4 = "4.13.2" -hamcrest = "3.0" -mockito = "5.20.0" [libraries] # AndroidX core @@ -107,8 +96,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 = "hiltNavigationCompose" } +androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "hiltNavigationCompose" } # Navigation-compose (the NavHost / back stack) androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } @@ -118,22 +110,21 @@ 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" } -# :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" } +# Recurrence expansion for our own store 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" } + +# 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" } From 3150781376e655b2d1620b9c8229391a347a88b6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 21 Sep 2026 13:38:23 +0200 Subject: [PATCH 05/10] docs: say what actually shipped, and what 1.0.0 actually is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch's documents describe a world where the vendored provider reached users. It never did, and several claims follow from that mistake. `ROADMAP.md`: - Phase 5's "**Breaking:** the 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" is wrong in the way that *removes* work: they were added and deleted inside this unreleased cycle, so nobody could have pointed anything at them. The release notes must not warn about losing something that never shipped. The per-locale release-notes item went with it. - "Run the instrumented suite on a device … none has ever executed" was stale: 52 tests, 0 failures, Pixel 10 / API 36, 13 Aug. What is genuinely open is a re-run against the tip, since the 4 Sep commits reworked the store and added instrumented cases that have never run. Both now say so, with the ARM64 aapt exit-code trap noted where someone will hit it. - The device-verification item described upgrading from a v0.3.2 APK with seeded data, which cannot be the real path. Replaced with the four cases that matter, including the one that only exists on a device that side-loaded a dev build of this branch. - M6's Glance item claimed "deps present in build.gradle.kts" — not any more. Translations and the language picker shipped in 0.4.0 and are marked done. `OWN-STORE.md` gets a correction banner over "Migrating existing users" saying the premise is wrong, and a section for the copy that replaces it. `STORAGE-AND-SYNC.md`'s banner said the vendored-provider decision was "made, shipped, and then costed properly" — built, not shipped. `PRIVACY.md` had the opposite problem: it describes CalDAV sync, Nextcloud Login Flow v2, RFC 6764 discovery and a Keystore-held password, none of which exist in 1.0.0 — the app holds no `INTERNET` permission at all. The permissions section listed six it does not declare. Since it is a legal document users are sent to from Settings → About, section 4 is now marked as describing a planned feature, section 9 lists exactly what the manifest declares (and says what is *not* there), and the backup and crash-report sections no longer assume network access or sync bookkeeping. Kept forward-looking rather than cut, so it does not have to change underneath anyone when sync lands. **Worth a read before merging** — it is the one change here with legal weight. `fastlane/.../full_description.txt` still opened with "It works directly on an existing tasks provider (OpenTasks / tasks.org) … no own account, no own sync" as the app's premise. That is the F-Droid listing for a release whose headline is that it needs nothing installed. Rewritten, with the feature list and the no-internet-permission point that is now literally true. `README.md` and `ExportWriter`'s "ships in eleven locales" (it is three) follow. --- README.md | 6 ++ .../agendula/data/export/ExportWriter.kt | 5 +- docs/OWN-STORE.md | 43 ++++++++++ docs/PRIVACY.md | 53 ++++++++---- docs/ROADMAP.md | 83 +++++++++++++++---- docs/STORAGE-AND-SYNC.md | 4 +- .../android/en-US/full_description.txt | 23 +++-- 7 files changed, 176 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 86bea13..95b8e77 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/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt index 03f43d5..071ad92 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt @@ -18,8 +18,9 @@ import javax.inject.Singleton data class ExportResult(val fileCount: Int, val taskListNames: List) /** - * 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, 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 3a6782e..b054104 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-15 +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. @@ -32,6 +32,14 @@ your device. They leave it in exactly one case: if you set up a CalDAV account yourself, they are synchronised with **the server you entered** — and with nothing and no one else. Nothing is ever sent to the developer. +> **As of version 1.0.0, CalDAV sync is not in the app yet.** It is designed and +> described here so that this policy does not have to change underneath you when +> it arrives, but the released app has **no network access of its own at all** — +> it does not hold Android's `INTERNET` permission, so section 4 cannot happen on +> this version. Until it ships, your tasks leave the device only if *you* export +> them, or if a separate sync app you installed yourself syncs a task provider you +> pointed Agendula at (section 3). + ## 1. Controller IT-Dienstleister | Jean-Luc Makiola @@ -64,6 +72,9 @@ All of this is verifiable in the ## 4. CalDAV sync — the only case where your tasks leave the device +*Not available in version 1.0.0 — see the note in "In short". This section +describes how it will behave, and is published in advance deliberately.* + Sync is optional and off until you add an account. If you add one, everything below happens between your device and **the server you nominated**, and nowhere else. @@ -144,16 +155,21 @@ 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. + +Once CalDAV sync ships, two further things will be kept out of that backup by +design: the stored password, and the per-device sync bookkeeping. Restoring onto +a new device will therefore mean signing in to your server again. ## 7. Crash reports If Agendula crashes, it offers to report the problem. Nothing is sent -automatically, even though the app has network access. The report is copied to -your clipboard and your browser is opened with the project's issue tracker, the -text pre-filled. **You see the full content, you decide whether to submit it, +automatically — and on this version the app could not send it if it wanted to, +having no network permission. The report is copied to your clipboard and your +browser is opened with the project's issue tracker, the text pre-filled. **You see the full content, you decide whether to submit it, and you can edit or discard it.** Such a report contains: @@ -183,11 +199,10 @@ process — it only opens the address. ## 9. Permissions and why they exist -- `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. -- `READ_SYNC_SETTINGS`, `WRITE_SYNC_SETTINGS` — register the sync account with - Android's sync framework so it can be scheduled. +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: + - `POST_NOTIFICATIONS` — show reminders. - `USE_EXACT_ALARM`, `SCHEDULE_EXACT_ALARM` — deliver reminders at the exact due time. @@ -195,10 +210,14 @@ 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). -- `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. + 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. + +Note what is **not** there: Agendula declares no `INTERNET` permission, so the +released app cannot make a network connection of any kind. When CalDAV sync +ships it will need `INTERNET` and `ACCESS_NETWORK_STATE`, and the sync-framework +permissions to schedule itself; this section will be updated in the same release +that adds them, never before. Agendula publishes no content provider of its own and declares no permissions that other apps could request. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 945d92b..2d45d84 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,59 @@ 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-run it against the branch tip.** That run predates the 4 Sep commits, + which reworked `RoomTasksDataSource`, `ModeRoutingTasksDataSource` and + `RecurrenceExpander` and added instrumented cases of their own — so the tests + covering the store code as it stands have still never executed. +- ⬜ 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/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. From 8dc95da01c2275d7e9056570e5bfe8b9ee493fc7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 21 Sep 2026 13:43:14 +0200 Subject: [PATCH 06/10] ci: fail the build when a changelog will not fit F-Droid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sync_changelog_to_fastlane.sh` has been printing "note: >500 chars — F-Droid may truncate this changelog in-client" for three releases, and every release since 0.2.0 has sailed past it: 1824, 696, 916 characters. A note nobody acts on is not a check. F-Droid truncates the in-client "What's New" box, so everything past the limit is written for nobody — the reader gets a sentence cut mid-word and no way to expand it. The limit is now a hard failure: the script exits non-zero, with a message naming the section to shorten, and `MAX_CHARS` is a variable so the bound lives in one place rather than being copied into the workflows. Enforced in two places, for two different failures: - `.forgejo/workflows/ci.yaml` runs it as an always-on guard beside the reproducible-release invariant, then checks `git status --porcelain` over the changelogs directory. That second half catches a CHANGELOG.md edit whose generated fastlane file was never committed — which until now degraded silently into the official F-Droid listing showing the *previous* version's notes, exactly as RELEASING.md step 3 warns. Porcelain rather than `git diff --exit-code`, so a brand-new file for a bumped versionCode counts as dirty instead of being missed as untracked. - `.gitea/workflows/release.yaml` gets the same call in the cheap `detect` gate. The release job already regenerated the file, but only at step 15 of 24 — after the build, the signing and the keystore setup. Failing in `detect` costs one bash invocation and publishes nothing. --- .forgejo/workflows/ci.yaml | 20 ++++++++++++++++++++ .gitea/workflows/release.yaml | 10 ++++++++++ scripts/sync_changelog_to_fastlane.sh | 23 +++++++++++++++++++++-- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index af678bc..a89ab4e 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/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 < Date: Mon, 21 Sep 2026 13:43:24 +0200 Subject: [PATCH 07/10] =?UTF-8?q?release:=20cut=201.0.0=20=E2=80=94=20Agen?= =?UTF-8?q?dula=20keeps=20your=20tasks=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit versionName 1.0.0, versionCode 10000. Merging this to main triggers .gitea/workflows/release.yaml, which builds it, publishes to the F-Droid repo, and mints the v1.0.0 tag — and this is the release where `prerelease` flips to false on its own, since the pipeline derives it from MAJOR. CHANGELOG's 1.0.0 section covers the own store, list management, recurrence, iCalendar export, the storage picker and the copy path, then the fixes to the external-provider mode that 0.4.0 and earlier used for everything — the reverting due times, the all-day drift, the silent save failure on tasks with a duration, recurrence detection on tasks.org, the inert reminder field, and the provider-failure recovery. Nothing about the dropped authority or the custom permissions: they never reached a release, so there is nothing for a user to have lost. `changelogs/10000.txt` is `scripts/sync_changelog_to_fastlane.sh` output, for the official F-Droid listing, at 471 of the 500 characters F-Droid will show — the section is written to that budget rather than trimmed to fit it, since the same text is the Gitea release notes. The detail behind each line is in the commit messages, CHANGELOG's older entries stay as they were. Still open before this merges, per docs/RELEASING.md step 4 and ROADMAP: `./gradlew :app:connectedDebugAndroidTest` against the tip, and `scripts/verify-release.sh` on a real device. --- CHANGELOG.md | 15 +++++++++++++++ app/build.gradle.kts | 9 +++++---- .../metadata/android/en-US/changelogs/10000.txt | 13 +++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/10000.txt 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/app/build.gradle.kts b/app/build.gradle.kts index b167842..8f4ae0b 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" } 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. + From a6a42870009ca9dbd1e9d49517af38e8bc2dac82 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 21 Sep 2026 13:52:31 +0200 Subject: [PATCH 08/10] build: pin floret-kit at v0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The submodule sat nine commits past v0.2.1, on a bare `main` commit. Naming the pin was meant to be a fresh tag on our side; it turned out v0.3.0 had already been cut upstream and our pin was fourteen commits behind it — e047a2b is an ancestor of the tag, so this moves forward onto a released version rather than sideways. What it brings, all in `components` (core-time, core-reminders, core-locale, core-crash and identity are untouched): a navigation slot, full-bleed content and scrolling actions for the onboarding shell, step progress for the onboarding scaffold, long-press and styled text on a grouped row, a per-option summary slot on OptionPicker, AboutCard joining a grouped list, and GroupedListInset exported. Agendula uses none of the new surface yet, so this is a pin move rather than a feature: nothing in `app/` changed, and nothing had to. Verified from a clean build — 152 unit tests green, lint identical to before the bump (88 issues, same categories), floret-kit's own test suite green, and the R8 `releaseTest` APK assembles. The onboarding shell is the one place to look on a device: our `ReminderOnboardingScreen` and `OnboardingHero` build on `Onboarding.kt`, which is where 146 of the changed lines are. It compiles unchanged, but "compiles" and "still lays out the way it did" are different claims, and the device pass this release already owes is where the second one gets settled. --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index e047a2b..05c79f2 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit e047a2bd48e6ea9cdc0d04f7dc9df311869ff2f1 +Subproject commit 05c79f2afc489e9cab7f2d42cce6ba853cc1f430 From ee29c9bca110d26a1db042ac3240332d2980e441 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 22 Sep 2026 08:53:16 +0200 Subject: [PATCH 09/10] test(store): assert the whole reminder, not a bare minute count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First on-device run of the instrumented suite against the branch tip: 66 tests, one failure, and it was the test that was wrong. `alarmsRoundTripAndReplaceRatherThanAccumulate` asserted `source.alarms()[id] == 30`. The seam stopped returning a bare `Int` in fcee1d1, when collapsing an alarm to a minute count turned out to be what fired an imported START-referenced reminder off DUE — `alarms()` has returned `Map` ever since. The production value was correct (`TaskReminder(minutesBefore=30, fromStart=false)`); only the expectation was left behind. It compiled the whole time because Truth's `isEqualTo` takes `Any?`, so an `Int` compared against a `TaskReminder?` is a perfectly legal call that can only ever be false. Nothing short of running it would have found this, which is the argument for the ROADMAP item that asked for the run. Now asserts the whole value, so the reference is part of the contract rather than something the test is free to ignore. 66/66 green after the fix. --- .../data/tasks/room/RoomTasksDataSourceTest.kt | 8 ++++++-- docs/ROADMAP.md | 14 ++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) 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 69f4e33..c7f58f3 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 @@ -309,12 +310,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/docs/ROADMAP.md b/docs/ROADMAP.md index 2d45d84..d67cec8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -242,10 +242,16 @@ most of it broken, absent or unusable. Reasoning in `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-run it against the branch tip.** That run predates the 4 Sep commits, - which reworked `RoomTasksDataSource`, `ModeRoutingTasksDataSource` and - `RecurrenceExpander` and added instrumented cases of their own — so the tests - covering the store code as it stands have still never executed. +- ✅ **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 From 60a3814eb3abd608afe78bd65ddad2960f1bbdc5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 22 Sep 2026 08:54:04 +0200 Subject: [PATCH 10/10] docs(releasing): the on-device checklist no longer fits the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 was "launch from a clean state — the permission screen must appear", which cannot happen on the path most people are now on. Since 1.0.0 the app owns its store: with no OpenTasks or tasks.org installed, `autoMode()` resolves to OWN, there is no permission to grant, and the app goes straight to the task list. A gate that never appears is a checklist item that either gets ignored or gets read as a failure. Verified against this branch on a Pixel 10 (API 37): the releaseTest APK installs, cold-starts in 549 ms with an empty crash buffer, and lands on the task list with nothing granted. The checklist now splits by what is installed, because the two paths verify different things — and the provider path is where the new copy flow lives, so it says to exercise it rather than leaving "the release's headline changes" to stand in for it. A fresh install in OWN mode also has no lists at all, which makes "create a task" impossible until you create one, so that is a step now. The script's actions are unchanged: revoking both permission sets is still right for the provider path, and revoking POST_NOTIFICATIONS still forces the reminder onboarding either way. --- scripts/verify-release.sh | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index a30adad..81b4894 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -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"